Skip to content

fix(re-consent): make the terms prompt escapable, per our own ToS §17 - #2579

Merged
jjramirezn merged 3 commits into
release/sp-153-cherry-picksfrom
fix/re-consent-soft-gate
Jul 29, 2026
Merged

fix(re-consent): make the terms prompt escapable, per our own ToS §17#2579
jjramirezn merged 3 commits into
release/sp-153-cherry-picksfrom
fix/re-consent-soft-gate

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Targets release/sp-153-cherry-picks, so it ships inside SP-153 rather than after it. Pairs with peanut-api-ts#1255.

Why

ReConsentModal shipped 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:

§17.2 Material changes take effect for existing users no earlier than 30 days after we post the updated terms, unless you accept them sooner through a mechanism we provide (such as a click-through acceptance).

§17.3 If you do not agree to the updated terms, you must stop using the Services before they take effect.

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

  • "Not now" always exists — plus close button, backdrop and Escape. The checkbox does not gate the exit.
  • A dismissal defers to the effective date: the document's frontmatter version plus the 30 days §17.2 promises, floored to 7 days for a document already past its notice period. Stored per user in localStorage. It never writes a ledger row — a refusal is not consent.
  • Accept reports modal_cta_clicked, not modal_dismissed. Both events already existed; refusal now reports modal_dismissed. 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, shadowed, first in the stack. "Not now" is a subordinate transparent button below it.

⚠️ Cross-repo — needs a fix in peanut-api-ts#1255

card-terms-international was updated 2026-07-14 (§8.4 Nigerian Users, Rain-approved). Both constants still say 2026-06-01:

Source Version
src/content (pinned on this branch) 2026-07-14
legal-versions.generated.ts (was committed) 2026-06-01 — stale
peanut-api CURRENT_LEGAL_VERSIONS 2026-06-01 — still stale

predev/prebuild regenerate the FE file, so any dev server or Vercel build already produces 2026-07-14. The regenerated file is committed here. But sanitizeEchoedDocuments treats 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 accepting 2026-06-01 when they were shown 2026-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.

Opens (primary gated by the checkbox) Checkbox ticked (accept live)

Captured from a throwaway /dev page rendering the same ActionModal props this component passes — so the pixels, the Button, 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/me before the layout stops rendering its loading state. Assets live on pr-assets-2579delete that branch after merge.

Unrelated, pre-existing, not fixed here: ActionModal's checkbox renders the browser-default blue when checked in light mode. It sets dark:checked:bg-primary-1 but has no light-mode equivalent, so it misses the Peanut pink. Visible in the second shot.

Worktree note (fixed separately)

pnpm dev cannot run in any peanut-ui worktree created by scripts/setup-worktree: Next 16 defaults to Turbopack, which rejects a node_modules symlink pointing outside the project root. dev:fallback does not help — plain next dev is Turbopack too. Replacing the symlink with a real pnpm install --frozen-lockfile --prefer-offline takes ~7s and fixes it. Being fixed in scripts/setup-worktree separately; not part of this PR.

Design notes / accepted trade-offs

  • The checkbox stays. One-tap accept would be smoother, but this is a legal consent artifact and removing the affirmation is Legal's call, not a UI cleanup. Drop it in one line if Legal is comfortable.
  • The snooze is client-side. Consequence: cleared storage or a second device re-shows the prompt. That is the safe direction — the failure mode is asking again, never suppressing.
  • A dismissal records nothing. After the effective date §17.3 makes continued use acceptance on its own, so the legal position holds — but there is no evidence row for anyone who never clicks. Closing that gap needs a BE decision, listed below.

Revealed, not fixed (pre-existing — flagging per the boy-scout rule)

  • A legal document's slug now lives in three places: DOC_LABELS here, LEGAL_DOCUMENT_VERSIONS (generated), and CONSENT_DOCUMENT_SLUGS in peanut-api. Adding a document means editing all three, and nothing fails if you miss one.
  • Nothing checks the committed legal-versions.generated.ts against its source. The drift above went unnoticed because the mismatch is only visible once someone runs predev. A CI step that regenerates and diffs would have caught it the day the content landed.

Open — BE decisions, not in this PR

  1. The 561. 12,216 of 12,777 users predate 2026-07-15 and legitimately need to re-consent. The other 561 signed up after and already accepted at signup; they are prompted only because the ledger starts empty. A backfill keyed on users.created_at and the version live at that time would spare them. Rows would be inferred, so they want surface: 'backfill', not 'signup'.
  2. The evidence tail. Optionally record an implied-consent row (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:

  • "Not now" is never gated on the checkbox
  • dismissing calls no accept and emits modal_dismissed
  • a postponed prompt stays away next session, returns once the snooze lapses, and does not spend the status request while snoozed
  • one user postponing does not suppress the prompt for a different account
  • nextPromptAt honours the effective date, takes the latest across documents, floors past the notice period, and does not throw on a bad version

Whole repo green locally: 169/169 suites, 2252 tests, tsc --noEmit clean with zero errors.

Worth noting for anyone who has fought worktrees: that full-green run is only possible because the node_modules symlink was replaced with a real install. The long-standing "phantom" worktree failures — TS2307 on @/assets/*, missing @capacitor/*, ungenerated public/flags — were all the symlink, and all disappear. They were never real.

Hugo0 added 3 commits July 29, 2026 17:50
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.
@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

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.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 29, 2026 5:07pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The 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

Layer / File(s) Summary
Snooze timing and storage
src/components/Global/ReConsentModal/utils.ts
Adds 30-day legal notice and 7-day minimum snooze calculations, per-user storage, SSR/private-mode guards, and invalid-value handling.
Prompt dismissal and CTA integration
src/components/Global/ReConsentModal/index.tsx
Skips status checks for snoozed accounts, routes “Not now” and close actions through postponement, and distinguishes CTA-clicked from dismissed analytics.
Timing, analytics, and account-isolation tests
src/components/Global/ReConsentModal/__tests__/index.test.tsx
Tests effective-date timing, fallback behavior, analytics events, persistence, expiry, and account-specific snoozes.

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
Loading

Possibly related PRs

Suggested reviewers: jjramirezn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making the re-consent prompt escapable in line with ToS §17.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/re-consent-soft-gate

Comment @coderabbitai help to get the list of available commands.

@Hugo0

Hugo0 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Hugo0
Hugo0 marked this pull request as ready for review July 29, 2026 17:19
@Hugo0
Hugo0 requested a review from jjramirezn July 29, 2026 17:19
@jjramirezn
jjramirezn merged commit 46f321a into release/sp-153-cherry-picks Jul 29, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants