fix(re-consent): make the terms prompt escapable, per our own ToS §17 - #2579
Conversation
The modal shipped as a hard gate: preventClose, hidden close button, and a single Accept CTA. It mounts in the mobile-ui layout, so it covered the whole app, including /withdraw. That conflicts with the terms it enforces. §17.2 gives material changes a 30-day runway and offers the click-through as a way to accept sooner, by choice. We published on 2026-07-15, so the new terms take effect around 2026-08-14; a hard gate enforces them about two weeks early. §17.3 says a user who does not agree must stop using the Services. For a non-custodial wallet that must still leave a path to their own funds. So the prompt now defers instead of blocking: - "Not now" (plus close, backdrop and Escape) dismisses it. The checkbox does not gate the exit. - A dismissal snoozes the prompt for 3 days per user, in localStorage. It never writes a ledger row, because a refusal is not consent. - Accept reports modal_cta_clicked, not modal_dismissed. Both events existed already. We need the accept-vs-refuse ratio to measure the rollout. - Copy no longer says "To keep using Peanut". That claim is no longer true. Accept stays the primary action: purple, shadow, first in the stack.
…interval "Not now" now snoozes until the documents actually take effect — the frontmatter version plus the 30 days our own ToS §17.2 promises — instead of a flat 3 days. A document posted today buys the user its full notice period. Past that date §17.3 already makes continued use acceptance, so the prompt only still asks in order to record explicit consent. That earns a gentle cadence, not a prompt on every app open, so the snooze floors at MIN_SNOOZE_DAYS.
The committed file said card-terms-international was 2026-06-01. The content submodule this branch pins says 2026-07-14 — the §8.4 Nigerian Users clause. `predev`/`prebuild` regenerate this file, so any dev server or Vercel build already produced the newer value and left the tree dirty. WARNING, cross-repo: peanut-api CURRENT_LEGAL_VERSIONS still says 2026-06-01 for the same document. sanitizeEchoedDocuments treats a client version newer than the server's as an attack and clamps it, dropping the hash. So the ledger would record 2026-06-01 for users who were shown 2026-07-14, and the clause update would never trigger re-consent. The constant needs the same bump in peanut-api-ts#1255.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughChangesThe re-consent modal now supports per-account snoozing with safe localStorage handling, updated postponement and CTA analytics, stacked prompt actions, fail-open status behavior, and tests for timing, persistence, expiry, and account isolation. Re-consent prompt flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ReConsentModal
participant localStorage
participant PostHog
User->>ReConsentModal: Select "Not now"
ReConsentModal->>localStorage: Save snooze timestamp
ReConsentModal->>PostHog: Capture MODAL_DISMISSED
User->>ReConsentModal: Select "Accept & continue"
ReConsentModal->>PostHog: Capture MODAL_CTA_CLICKED
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
46f321a
into
release/sp-153-cherry-picks
Targets
release/sp-153-cherry-picks, so it ships inside SP-153 rather than after it. Pairs with peanut-api-ts#1255.Why
ReConsentModalshipped as a hard gate:preventClose, hidden close button, one Accept CTA. It mounts in the(mobile-ui)layout, so it covers the whole app — including/withdraw.That conflicts with the terms it enforces:
Two problems. We posted on 2026-07-15, so the terms take effect around 2026-08-14 — a hard gate enforces them about two weeks early, and §17.2 frames the click-through as voluntary. And §17.3 assumes a user who declines can still stop using the Services, which for a non-custodial wallet has to leave a path to their own funds.
The prompt itself is correct and stays. Terms and privacy were genuinely rewritten (terms went 22 → 188 lines; privacy had not been touched since 2022), and 12,216 of 12,777 users predate that change. Only the gate strength changes.
What changed
localStorage. It never writes a ledger row — a refusal is not consent.modal_cta_clicked, notmodal_dismissed. Both events already existed; refusal now reportsmodal_dismissed. We need the accept-vs-refuse ratio to measure the rollout.Accept stays the primary action: purple, shadowed, first in the stack. "Not now" is a subordinate transparent button below it.
card-terms-internationalwas updated 2026-07-14 (§8.4 Nigerian Users, Rain-approved). Both constants still say2026-06-01:src/content(pinned on this branch)legal-versions.generated.ts(was committed)CURRENT_LEGAL_VERSIONSpredev/prebuildregenerate the FE file, so any dev server or Vercel build already produces2026-07-14. The regenerated file is committed here. ButsanitizeEchoedDocumentstreats a client version newer than the server's as an attack and clamps it, dropping the hash. Net effect: international card applicants get ledgered as accepting2026-06-01when they were shown2026-07-14, and the clause update never triggers re-consent. Only a warn log says so.The BE constant needs the same bump. One line, in #1255.
Screenshots
375×667 (iPhone SE), the two states of the prompt. The close button is now visible, and "Not now" sits below the primary CTA.
Captured from a throwaway
/devpage rendering the sameActionModalprops this component passes — so the pixels, theButton, and the Tailwind are all real. The page was deleted before push; it is not in the diff. The full-app route was not reachable locally in the time available — the app shell needs several endpoints stubbed past/users/mebefore the layout stops rendering its loading state. Assets live onpr-assets-2579— delete that branch after merge.Worktree note (fixed separately)
pnpm devcannot run in any peanut-ui worktree created byscripts/setup-worktree: Next 16 defaults to Turbopack, which rejects anode_modulessymlink pointing outside the project root.dev:fallbackdoes not help — plainnext devis Turbopack too. Replacing the symlink with a realpnpm install --frozen-lockfile --prefer-offlinetakes ~7s and fixes it. Being fixed inscripts/setup-worktreeseparately; not part of this PR.Design notes / accepted trade-offs
Revealed, not fixed (pre-existing — flagging per the boy-scout rule)
DOC_LABELShere,LEGAL_DOCUMENT_VERSIONS(generated), andCONSENT_DOCUMENT_SLUGSin peanut-api. Adding a document means editing all three, and nothing fails if you miss one.legal-versions.generated.tsagainst its source. The drift above went unnoticed because the mismatch is only visible once someone runspredev. A CI step that regenerates and diffs would have caught it the day the content landed.Open — BE decisions, not in this PR
users.created_atand the version live at that time would spare them. Rows would be inferred, so they wantsurface: 'backfill', not'signup'.surface: 'continued-use') once a user keeps using the app past the effective date, per §17.3. Closes the proof gap without nagging.Risk
Low, and strictly in the safe direction: the modal becomes escapable. No API contract change, no new endpoint, no migration. Worst case a user postpones and is asked again after the effective date. Deploy order is unchanged — BE #1255 first.
QA
16 unit tests, up from 7. The added ones pin the properties that matter:
acceptand emitsmodal_dismissednextPromptAthonours the effective date, takes the latest across documents, floors past the notice period, and does not throw on a bad versionWhole repo green locally: 169/169 suites, 2252 tests,
tsc --noEmitclean with zero errors.Worth noting for anyone who has fought worktrees: that full-green run is only possible because the
node_modulessymlink was replaced with a real install. The long-standing "phantom" worktree failures —TS2307on@/assets/*, missing@capacitor/*, ungeneratedpublic/flags— were all the symlink, and all disappear. They were never real.