diff --git a/src/components/Global/ReConsentModal/__tests__/index.test.tsx b/src/components/Global/ReConsentModal/__tests__/index.test.tsx index bc067ae52..b806d315a 100644 --- a/src/components/Global/ReConsentModal/__tests__/index.test.tsx +++ b/src/components/Global/ReConsentModal/__tests__/index.test.tsx @@ -1,12 +1,13 @@ /** @jest-environment jsdom */ /** - * ReConsentModal — the ToS §17 blocking click-through. + * ReConsentModal — the ToS §17 click-through. * - * This modal gates the entire app, so the safety properties matter more than - * the happy path: a failed status check must NEVER lock the app (fail-open), - * a failed accept must keep the retry path alive, and an account switch must - * not leak the previous user's consent state (a regression already caught - * once in review). + * The safety properties matter more than the happy path: a failed status check + * must NEVER block the app (fail-open), a failed accept must keep the retry + * path alive, an account switch must not leak the previous user's consent state + * (a regression already caught once in review), and — because §17.2 gives a + * 30-day runway and §17.3 requires a decliner to still reach their funds — the + * prompt must ALWAYS be escapable and must never ledger a refusal as consent. */ import React from 'react' import { render, screen, fireEvent, act } from '@testing-library/react' @@ -64,7 +65,8 @@ jest.mock('@/components/Global/DocsLink', () => ({ default: ({ children }: { children: React.ReactNode }) => {children}, })) -jest.mock('posthog-js', () => ({ capture: jest.fn() })) +const mockCapture = jest.fn() +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) const mockCaptureException = jest.fn() jest.mock('@sentry/nextjs', () => ({ @@ -72,6 +74,40 @@ jest.mock('@sentry/nextjs', () => ({ })) import ReConsentModal from '../index' +import { nextPromptAt, LEGAL_NOTICE_PERIOD_DAYS, MIN_SNOOZE_DAYS } from '../utils' + +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('nextPromptAt', () => { + // 2026-07-15 + 30d = 2026-08-14, the date the current terms take effect + const posted = '2026-07-15' + const dayAfterPosting = Date.parse('2026-07-16') + + it('defers to the effective date — the full §17.2 notice period, not a fixed interval', () => { + expect(nextPromptAt([posted], dayAfterPosting)).toBe(Date.parse('2026-08-14')) + }) + + it('uses the LATEST effective date when several documents are shown at once', () => { + expect(nextPromptAt(['2026-07-01', posted], dayAfterPosting)).toBe(Date.parse('2026-08-14')) + }) + + it('floors to MIN_SNOOZE_DAYS once a document is already past its notice period', () => { + // §17.3 already makes continued use acceptance here, so the prompt only + // still asks to record explicit consent — it must not nag every open + const longAfter = Date.parse('2027-01-01') + expect(nextPromptAt([posted], longAfter)).toBe(longAfter + MIN_SNOOZE_DAYS * DAY_MS) + }) + + it('falls back to the floor rather than throwing on an unparseable version', () => { + const now = Date.parse('2026-07-29') + expect(nextPromptAt(['not-a-date'], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS) + expect(nextPromptAt([], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS) + }) + + it('pins the notice period to the 30 days our own ToS §17.2 promises', () => { + expect(LEGAL_NOTICE_PERIOD_DAYS).toBe(30) + }) +}) const statusDoc = (slug: string) => ({ slug, @@ -85,9 +121,14 @@ const flush = () => act(async () => {}) beforeEach(() => { jest.clearAllMocks() + // the snooze is persisted per-user in localStorage — a leaked entry would + // silently suppress the prompt in every later test + window.localStorage.clear() mockUser = { user: { userId: 'user-1' } } }) +const capturedEvents = () => mockCapture.mock.calls.map(([event]) => event) + describe('ReConsentModal', () => { it('fails open: a failed status check never shows (or locks) the modal', async () => { mockGetStatus.mockRejectedValue(new Error('api down')) @@ -138,6 +179,71 @@ describe('ReConsentModal', () => { expect.objectContaining({ slug: 'privacy' }), ]) expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // acceptance must be distinguishable from refusal in analytics — + // the accept-vs-postpone ratio is the rollout's headline metric + expect(capturedEvents()).toEqual(['modal_shown', 'modal_cta_clicked']) + }) + + it('"Not now" is always available and never gated on the checkbox', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + render() + await flush() + + expect(screen.getByText('Accept & continue')).toBeDisabled() + // the escape hatch must not require ticking a consent box first + expect(screen.getByText('Not now')).not.toBeDisabled() + }) + + it('"Not now" dismisses without recording any consent (a refusal is not a ledger row)', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + render() + await flush() + + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(mockAccept).not.toHaveBeenCalled() + expect(capturedEvents()).toEqual(['modal_shown', 'modal_dismissed']) + }) + + it('a postponed prompt stays away on the next session, then returns once the snooze lapses', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + const first = render() + await flush() + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + first.unmount() + + // fresh session (remount → fresh refs), still inside the snooze window + render() + await flush() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // and we don't even spend the request while snoozed + expect(mockGetStatus).toHaveBeenCalledTimes(1) + + // snooze lapses → the prompt comes back + window.localStorage.setItem('peanut.reconsent.snoozedUntil.user-1', String(Date.now() - 1)) + render() + await flush() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('one user postponing does not suppress the prompt for a different account', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + const first = render() + await flush() + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + first.unmount() + + mockUser = { user: { userId: 'user-2' } } + render() + await flush() + expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('a failed accept keeps the modal, shows the error, and leaves retry enabled', async () => { diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index 974aef1fe..6a70dc04c 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/context/authContext' import { acceptedLegalDocument, consentApi, type ConsentStatusDocument } from '@/services/consent' import { LEGAL_DOCUMENT_VERSIONS, type LegalDocumentSlug } from '@/constants/legal-versions.generated' import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import { isReConsentSnoozed, snoozeReConsent } from './utils' const DOC_LABELS: Record = { terms: { name: 'Terms of Service', href: '/terms' }, @@ -19,12 +20,21 @@ const DOC_LABELS: Record = { 'card-prohibited-activities': { name: 'Prohibited Activities Policy', href: '/card-prohibited-activities' }, } +/** Keep "Not now" stacked BELOW the primary CTA at every width — side-by-side + * would read as two equally-weighted choices. */ +const STACKED_CTAS = 'flex-col sm:flex-col' + /** * Re-consent click-through (tos-v1 phase 2, ToS §17): when a legal document's - * published version moves past what the user last provably accepted, this - * blocking modal lists the updated documents and appends fresh consent-ledger - * rows on acceptance. Backed by GET /users/consent/status and - * POST /users/consent/accept — see peanut-api. + * published version moves past what the user last provably accepted, this modal + * lists the updated documents and appends fresh consent-ledger rows on + * acceptance. Backed by GET /users/consent/status and POST /users/consent/accept. + * + * It is a PROMPT, not a gate. §17.2 gives material changes 30 days and offers + * the click-through as a way to accept sooner; §17.3 requires that a user who + * declines can still stop using the Services — which, for a non-custodial + * wallet, means they must be able to reach `/withdraw`. So "Not now" always + * exists, and dismissing never writes a ledger row (declining is not consent). */ const ReConsentModal = () => { const { user } = useAuth() @@ -45,6 +55,8 @@ const ReConsentModal = () => { setOutdatedDocs([]) setChecked(false) setError(null) + // a recent "Not now" defers the prompt — don't even spend the request + if (isReConsentSnoozed(userId)) return consentApi .getStatus() .then((status) => { @@ -62,7 +74,7 @@ const ReConsentModal = () => { }) }) .catch((e) => { - // a failed status check must never lock the app — retry next + // a failed status check must never block the app — retry next // session. Sentry (not console): a systematic failure here means // re-consent silently stops rolling out, and prod must say so. Sentry.captureException(e, { tags: { feature: 're-consent', action: 'status' } }) @@ -75,7 +87,9 @@ const ReConsentModal = () => { setError(null) try { await consentApi.accept(outdatedDocs.map((d) => acceptedLegalDocument(d.slug as LegalDocumentSlug))) - posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { + // CTA_CLICKED, not DISMISSED — acceptance and refusal must be + // distinguishable, since their ratio is the rollout's headline metric + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.RE_CONSENT, documents: outdatedDocs.map((d) => d.slug), }) @@ -84,8 +98,8 @@ const ReConsentModal = () => { // must start with an unticked box setChecked(false) } catch (e) { - // Sentry (not console): if /accept fails systematically, every user - // sits behind this undismissable modal — that must be visible in prod + // Sentry (not console): if /accept fails systematically, nobody can + // record consent at all — that must be visible in prod Sentry.captureException(e, { tags: { feature: 're-consent', action: 'accept' } }) setError('Could not save your acceptance — please try again.') } finally { @@ -93,27 +107,46 @@ const ReConsentModal = () => { } } + /** "Not now", the close button, backdrop and Escape all land here. */ + const handlePostpone = () => { + if (submitting) return + const userId = user?.user.userId + // defer to the date these documents actually take effect (§17.2), not a + // fixed interval — a doc posted today buys the user its full 30 days + if (userId) + snoozeReConsent( + userId, + outdatedDocs.map((d) => d.currentVersion) + ) + posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { + modal_type: MODAL_TYPES.RE_CONSENT, + documents: outdatedDocs.map((d) => d.slug), + }) + setOutdatedDocs([]) + setChecked(false) + setError(null) + } + if (!outdatedDocs.length) return null return ( undefined} - preventClose - hideModalCloseButton + onClose={handlePostpone} icon="info" title="We've updated our terms" content={ - <> +

- To keep using Peanut, please review and accept the updated documents: + We've refreshed the documents below. Give them a read and accept when you're ready — you can + keep using Peanut in the meantime.

-
    +
      {outdatedDocs.map((doc) => { const label = DOC_LABELS[doc.slug] ?? { name: doc.slug, href: `/${doc.slug}` } return (
    • - + {label.name}
    • @@ -121,7 +154,7 @@ const ReConsentModal = () => { })}
    {error &&

    {error}

    } - +
} checkbox={{ text: 'I have read and accept the updated documents', @@ -131,10 +164,19 @@ const ReConsentModal = () => { ctas={[ { text: submitting ? 'Saving…' : 'Accept & continue', + variant: 'purple', + shadowSize: '4', disabled: !checked || submitting, onClick: handleAccept, }, + { + text: 'Not now', + variant: 'transparent', + disabled: submitting, + onClick: handlePostpone, + }, ]} + ctaClassName={STACKED_CTAS} /> ) } diff --git a/src/components/Global/ReConsentModal/utils.ts b/src/components/Global/ReConsentModal/utils.ts new file mode 100644 index 000000000..c07321277 --- /dev/null +++ b/src/components/Global/ReConsentModal/utils.ts @@ -0,0 +1,73 @@ +/** + * Local snooze for the re-consent prompt. + * + * ToS §17.2 gives material changes a 30-day notice period and offers the in-app + * click-through as a way to accept sooner, by choice. §17.3 then says a user who + * does not agree must be able to stop using the Services — which, for a + * non-custodial wallet, has to include reaching their own funds. So the prompt + * is a reminder, not a lock: "Not now" defers it to the date the documents + * actually take effect. + * + * Deliberately localStorage and not the consent ledger — declining is not a + * consent event and must never write a row. Losing the snooze (cleared storage, + * another device) only re-shows the prompt, which is the safe direction. + */ + +/** ToS §17.2 — material changes take effect no earlier than 30 days after posting. */ +export const LEGAL_NOTICE_PERIOD_DAYS = 30 + +/** + * Floor for a document already past its notice period. Past that date §17.3 + * makes continued use acceptance on its own, so the prompt only still asks in + * order to record explicit consent — worth a gentle cadence, not every app open. + */ +export const MIN_SNOOZE_DAYS = 7 + +const DAY_MS = 24 * 60 * 60 * 1000 + +const snoozeKey = (userId: string) => `peanut.reconsent.snoozedUntil.${userId}` + +/** Storage is absent during SSR and throws outright in Safari private mode. */ +function readStorage(key: string): string | null { + try { + return typeof window === 'undefined' ? null : window.localStorage.getItem(key) + } catch { + return null + } +} + +function writeStorage(key: string, value: string): void { + try { + if (typeof window !== 'undefined') window.localStorage.setItem(key, value) + } catch { + // a full or blocked quota only means the prompt returns next session + } +} + +/** + * When the prompt may reappear after a "Not now": the latest effective date + * across the documents shown, floored to MIN_SNOOZE_DAYS. + * + * `currentVersion` is the document's frontmatter `last_updated` (an ISO date), + * so effective = version + 30 days. An unparseable version contributes nothing + * rather than throwing — the floor still applies. + */ +export function nextPromptAt(versions: string[], now: number = Date.now()): number { + const effectiveDates = versions + .map((version) => Date.parse(version)) + .filter((parsed) => Number.isFinite(parsed)) + .map((parsed) => parsed + LEGAL_NOTICE_PERIOD_DAYS * DAY_MS) + return Math.max(now + MIN_SNOOZE_DAYS * DAY_MS, ...effectiveDates) +} + +export function isReConsentSnoozed(userId: string): boolean { + const raw = readStorage(snoozeKey(userId)) + if (!raw) return false + const until = Number(raw) + // a corrupt value must not suppress the prompt forever + return Number.isFinite(until) && until > Date.now() +} + +export function snoozeReConsent(userId: string, versions: string[]): void { + writeStorage(snoozeKey(userId), String(nextPromptAt(versions))) +} diff --git a/src/constants/legal-versions.generated.ts b/src/constants/legal-versions.generated.ts index 2042141bf..c5973fe12 100644 --- a/src/constants/legal-versions.generated.ts +++ b/src/constants/legal-versions.generated.ts @@ -21,8 +21,8 @@ export const LEGAL_DOCUMENT_VERSIONS = { hash: 'd0ea9e1b239179c9ca4e5c26badb49506d8453399958ed040f68bb6650fd6e6c', }, 'card-terms-international': { - version: '2026-06-01', - hash: 'a6476057cd5bcf553f2c1faffc5261ad3c5e59eecbc8bf068e3b561df22035cf', + version: '2026-07-14', + hash: '9eb731e649de21ef8a28fca821f4123d63be55f0765a0843fe42bac31bbbe0bd', }, 'card-terms-us': { version: '2026-06-01',