Skip to content

feat(shhhhh): store hand-off instead of web signup when pwa-sunset is on (TASK-21788) - #3012

Closed
0xkkonrad wants to merge 2 commits into
devfrom
feat/shhhhh-store-handoff
Closed

0xkkonrad wants to merge 2 commits into
devfrom
feat/shhhhh-store-handoff

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The /shhhhh door sends a signed-out visitor into /setup to create an account on the web. Once the pwa-sunset flag is on, that signup is the thing we are closing, so the door now hands the visitor to the app store instead: desktop opens the scan-to-download QR modal, phones deep-link their store, both carrying /card as the post-install destination so the app lands them on the door's real target.

Flag off, /shhhhh behaves exactly as it does on dev.

Part of the landing funnel work for TASK-21788 (PR 4 of 4).

What changed

  • src/app/shhhhh/ShhhhhLandingPage.tsx: handleCTA returns early while isFetchingUser, then captures DOOR_TRY, then queues any campaign tag, then if (!user && interceptGuestCta({ dest: '/card' })) return. Every door affordance (hero button, "or join the waitlist" link, §7 button, sticky mobile bar) calls handleCTA, so one branch covers all four, and both /setup routes below it (/setup?redirect_uri=/card and the campaign /setup?step=signup). The hook is asked for the guest impression with trackImpressionWhenGuest: !isFetchingUser && !user && !isJoined. The sticky mobile bar unmounts while storeHandoffModal is open, so it cannot paint over the QR sheet's own close / store buttons on a narrow desktop-mode phone.
  • src/hooks/useGuestStoreHandoff.tsx: optional surface argument, default MIGRATION_SURFACES.GUEST_FLOW, labelling openStore, ScanToDownloadModal and the guest-impression event. The desktop branch now also builds the deferred payload at click time (buildDeferredPayload(handoff.dest, handoff.invite)) and hands it to the modal, so the phone that scans lands on the caller's dest.
  • src/components/Migration/ScanToDownloadModal.tsx / DownloadQR.tsx: optional payload prop threaded to the QR URL (${origin}/app?${payload}; bare ${origin}/app when absent). See divergence 5 — PR 1 owns this prop and supersedes it on merge.
  • src/constants/migration.consts.ts: LANDING_DOOR: 'landing_door'.
  • src/app/shhhhh/ShhhhhLandingPage.test.tsx (10 tests): surface + settled impression flag, no impression and no action during the pre-auth flash, campaign tag queued before the intercept, sticky bar hidden while the modal is open, hand-off from the door and from the waitlist link, campaign-tagged link, flag-off keeps /setup?redirect_uri=%2Fcard, signed-in visitor still joins the waitlist and is never intercepted.
  • src/hooks/__tests__/useGuestStoreHandoff.test.tsx (9 tests): phone bounce carries the caller surface, default surface, desktop modal, handoff reaches the desktop QR payload, bare QR without a handoff, no interception inside Capacitor, no impression inside Capacitor, no interception with the flag off, impression event surface.

No new i18n strings, no new copy.

How verified

  • pnpm test -- src/app/shhhhh/ShhhhhLandingPage.test.tsx src/hooks/__tests__/useGuestStoreHandoff.test.tsx → 2 suites, 19 tests, all pass.
  • pnpm test -- src/components/Migration/__tests__/MigrationDownloadModal.test.tsx src/components/Invites/InvitesPage.test.tsx src/components/Profile/components/__tests__/PublicProfile.test.tsx (the other useGuestStoreHandoff / migration consumers) → 3 suites, 53 tests, all pass.
  • pnpm lint --quiet on the touched files → clean. prettier --check → clean.
  • pnpm typecheck → 6 pre-existing TS2307 Cannot find module errors, none in a file this PR touches: src/utils/web-vitals-shim.ts (x3), src/utils/__tests__/web-vitals-shim.test.ts, src/utils/app-review.ts (x2), src/utils/native-settings.ts. The worktree's node_modules is a symlink to the shared checkout and web-vitals, @capacitor/app-launcher, @capgo/capacitor-in-app-review and capacitor-native-settings are physically absent from it (verified by ls). Environment gap, not a type error in source; the count is identical before and after this PR. CI runs a real install and should be clean.
  • No dev server in this stage; the runtime check runs in the later stage of the workflow.

Divergences

  1. The door does not call openStore itself. The brief split the two branches between useGuestStoreHandoff (desktop) and a direct openStore(store, LANDING_DOOR, {dest:'/card'}) (phone). The hook already owns both branches and hardcoded GUEST_FLOW in three places, so a direct call would duplicate the device split and leave the desktop modal reporting guest_flow while the phone reported landing_door. Instead the hook takes an optional surface, defaulting to the old value, and the door passes LANDING_DOOR once.
  2. LANDING_DOOR is added here as well as in PR 1. PR 4 cannot compile without it and both branch off the same origin/dev. Same key, same value as the brief specifies. For the merger: if PR 1 lands first, drop this one line on rebase — the key and the value are identical, there is nothing to reconcile.
  3. The campaign-tagged door path is intercepted too, and the tag now rides the hand-off. Verification checklist item 5 wants no /setup left on the door when the flag is on, and /setup?step=signup is the same closed web signup. queuePendingBadgeCampaigns is hoisted above the intercept (behaviour-preserving — the campaign branch already queued before its own !user check), so the tag is in the cookie queue that buildDeferredPayload reads and it rides the Android install referrer, the iOS clipboard hand-off and the desktop QR payload; applyDeferredPayload re-queues it inside the app. Correction to the first round of this PR, which claimed the tag could not survive — it can, via the deferred-link mechanism built for exactly this. It is still lost for a desktop visitor who ignores the QR and installs by hand from a store listing. Flag off, the campaign path is untouched.
  4. pnpm typecheck is not clean — 6 pre-existing errors from the incomplete shared node_modules, itemized under "How verified".
  5. payload on DownloadQR / ScanToDownloadModal lands here, ahead of PR 1. PR 1 owns this prop, but without it PR 4's desktop branch dropped {dest:'/card'} on the floor and the QR encoded a bare /app, so a desktop visitor who scanned landed on /home instead of the door's destination — the stated purpose of the PR. Same prop name and semantics as PR 1's spec. PR-1 reconciliation: keep PR 1's version of DownloadQR (it also appends &s=<surface> and adds size) and drop this one on rebase. The two other handoff-passing callers (SendLinkActionList, SendWithPeanutCta, both {invite}) get the same upgrade — their desktop QR now carries the invite code and the current page as dest, matching what openStore already does for them on phones. InvitesPage and PublicProfile pass no handoff and stay byte-identical.
  6. MIGRATION_GUEST_CTA_SHOWN for the door is gated on settled auth. The brief says nothing about the impression event; every other migrated guest surface supplies trackImpressionWhenGuest, and without it landing_door would have hand-off clicks and no denominator. Gated with isFetchingUser (as in SendWithPeanutCta and PublicProfile) so the pre-auth flash never counts — and handleCTA returns early in that window, so a returning signed-in user who taps before auth resolves is not bounced to the store by a full-page navigation with no way back in-tab.

Full text: run/DIVERGENCES-shhhhh.md.

Flag-off impact

None on /shhhhh: the only new behaviour is behind interceptGuestCta, which returns false whenever useMigrationFlag() is false or the app is running under Capacitor, and the routes below it are unchanged. The isFetchingUser early return applies in both flag states and is a strict improvement (a tap during the auth round-trip previously misrouted a signed-in user through /setup). LANDING_DOOR is a new constant that nothing reads while the flag is off.

Outside /shhhhh, also flag-gated: the payload prop is only ever non-empty on a path that interceptGuestCta already gated on the flag, so SendLinkActionList and SendWithPeanutCta see the richer QR only with the flag on.

@notion-workspace

Copy link
Copy Markdown

Landing Page Changes

@vercel

vercel Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 7, 2026 9:39am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: f80c0aaa-843d-4867-9805-9e22bddfc275

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7251.68 → 7253.18 (+1.5)
Findings: +3 net (+21 new, -18 resolved)

🆕 New findings (21)

  • critical complexity — src/app/shhhhh/ShhhhhLandingPage.tsx — CC 55, MI 55.25, SLOC 326
  • high hotspot — src/app/shhhhh/ShhhhhLandingPage.tsx — 41 commits, +1211/-530 lines since 6 months ago
  • medium react-long-component — src/app/shhhhh/ShhhhhLandingPage.tsx:143 — ShhhhhLandingPage is 505 lines — split it
  • medium high-mdd — src/app/shhhhh/ShhhhhLandingPage.tsx:143 — ShhhhhLandingPage: MDD 197.3 (uses across many lines from declarations)
  • medium high-dlt — src/app/shhhhh/ShhhhhLandingPage.tsx:143 — ShhhhhLandingPage: DLT 34 (calls 34 distinct functions — high context load)
  • medium complexity — src/hooks/useGuestStoreHandoff.tsx — CC 15, MI 63.65, SLOC 40
  • medium react-effect-derives-state — src/app/shhhhh/ShhhhhLandingPage.tsx:98 — useEffect with empty deps + setState — derived state anti-pattern
  • medium complexity — src/constants/migration.consts.ts — CC 1, MI 53.7, SLOC 27
  • low structural-dup — app/shhhhh/ShhhhhLandingPage.tsx:102 — 16 duplicate lines / 84 tokens with components/LandingPage/StickyMobileCTA.tsx:47
  • low high-dlt — src/app/shhhhh/ShhhhhLandingPage.tsx:209 — handleCTA: DLT 15 (calls 15 distinct functions — high context load)
  • low high-mdd — src/hooks/useGuestStoreHandoff.tsx:27 — useGuestStoreHandoff: MDD 12.7 (uses across many lines from declarations)
  • low high-mdd — src/app/shhhhh/ShhhhhLandingPage.tsx:33 — WaitlistJoined: MDD 12.3 (uses across many lines from declarations)
  • low high-mdd — src/app/shhhhh/ShhhhhLandingPage.tsx:92 — StickyShhhhhCTA: MDD 10.2 (uses across many lines from declarations)
  • low structural-dup — app/m/[slug]/MerchantLandingPage.tsx:67 — 9 duplicate lines / 67 tokens with app/shhhhh/ShhhhhLandingPage.tsx:303
  • low structural-dup — app/m/[slug]/MerchantLandingPage.tsx:76 — 9 duplicate lines / 72 tokens with app/shhhhh/ShhhhhLandingPage.tsx:312
  • low structural-dup — app/m/[slug]/MerchantLandingPage.tsx:85 — 9 duplicate lines / 63 tokens with app/shhhhh/ShhhhhLandingPage.tsx:321
  • low structural-dup — app/m/[slug]/MerchantLandingPage.tsx:127 — 9 duplicate lines / 72 tokens with app/shhhhh/ShhhhhLandingPage.tsx:362
  • low structural-dup — app/m/[slug]/MerchantLandingPage.tsx:461 — 9 duplicate lines / 68 tokens with app/shhhhh/ShhhhhLandingPage.tsx:590
  • low structural-dup — app/m/[slug]/MerchantLandingPage.tsx:470 — 9 duplicate lines / 71 tokens with app/shhhhh/ShhhhhLandingPage.tsx:599
  • low missing-return-type — src/app/shhhhh/ShhhhhLandingPage.tsx:143 — ShhhhhLandingPage: exported fn missing return type annotation

…and 1 more.

✅ Resolved (18)

  • src/app/shhhhh/ShhhhhLandingPage.tsx — CC 48, MI 55.82, SLOC 313
  • src/app/shhhhh/ShhhhhLandingPage.tsx — 39 commits, +1172/-523 lines since 6 months ago
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage is 475 lines — split it
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage: MDD 185.4 (uses across many lines from declarations)
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage: DLT 32 (calls 32 distinct functions — high context load)
  • src/app/shhhhh/ShhhhhLandingPage.tsx:96 — useEffect with empty deps + setState — derived state anti-pattern
  • src/constants/migration.consts.ts — CC 1, MI 54.12, SLOC 26
  • app/shhhhh/ShhhhhLandingPage.tsx:100 — 16 duplicate lines / 84 tokens with components/LandingPage/StickyMobileCTA.tsx:47
  • src/app/shhhhh/ShhhhhLandingPage.tsx:31 — WaitlistJoined: MDD 12.3 (uses across many lines from declarations)
  • src/app/shhhhh/ShhhhhLandingPage.tsx:90 — StickyShhhhhCTA: MDD 10.2 (uses across many lines from declarations)
  • app/m/[slug]/MerchantLandingPage.tsx:67 — 9 duplicate lines / 67 tokens with app/shhhhh/ShhhhhLandingPage.tsx:274
  • app/m/[slug]/MerchantLandingPage.tsx:76 — 9 duplicate lines / 72 tokens with app/shhhhh/ShhhhhLandingPage.tsx:283
  • app/m/[slug]/MerchantLandingPage.tsx:85 — 9 duplicate lines / 63 tokens with app/shhhhh/ShhhhhLandingPage.tsx:292
  • app/m/[slug]/MerchantLandingPage.tsx:127 — 9 duplicate lines / 72 tokens with app/shhhhh/ShhhhhLandingPage.tsx:333
  • app/m/[slug]/MerchantLandingPage.tsx:461 — 9 duplicate lines / 68 tokens with app/shhhhh/ShhhhhLandingPage.tsx:561
  • app/m/[slug]/MerchantLandingPage.tsx:470 — 9 duplicate lines / 71 tokens with app/shhhhh/ShhhhhLandingPage.tsx:570
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage: exported fn missing return type annotation
  • src/hooks/useGuestStoreHandoff.tsx:22 — useGuestStoreHandoff: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/useGuestStoreHandoff.tsx 5.0 5.7 +0.7
src/app/shhhhh/ShhhhhLandingPage.tsx 12.2 12.7 +0.5

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • ✅ unit: 6102 ran, 0 failed, 0 skipped, 1.9m

📊 Coverage (unit)

metric %
statements 74.7%
branches 60.4%
functions 68.6%
lines 75.6%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
3.1s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

The new store handoff drops fresh badge-campaign intent on phones and the requested /card destination on desktop QR installs.

Findings

  • MAJOR · src/app/shhhhh/ShhhhhLandingPage.tsx:220 · Queue URL campaigns before the mobile handoff
    With pwa-sunset on, a fresh signed-out phone visitor following /shhhhh?badge_campaign=<active-skip-id> returns here before the URL-derived campaign is queued on line 223. openStore() builds the deferred payload from getPendingBadgeCampaigns(), so the install carries /card but not this campaign; first launch cannot restore or claim the intended Skip Pass and the card flow rejects the visitor. Queue badgeCampaigns before interception (or pass them explicitly into the deferred payload) and assert that the serialized mobile handoff contains them.

  • MAJOR · src/app/shhhhh/ShhhhhLandingPage.tsx:220 · Preserve the destination through the desktop QR
    On desktop with pwa-sunset on, this passes { dest: '/card' }, but the WEB branch only opens ScanToDownloadModal; DownloadQR encodes plain /app, whose smart redirect opens a bare store URL. After scanning and installing, the app therefore has no deferred /card destination and opens its default route instead of the door's target. Thread the handoff through the QR/smart-link path (with the same sanitization as openStore) and add an assertion for the encoded QR destination.

  • MAJOR · src/app/shhhhh/ShhhhhLandingPage.tsx:220 · [claude-opus] Store hand-off drops the /shhhhh campaign, so the documented Skip Pass is lost
    product/card.md:87 and :145 state the Skip Pass (WAITLIST_SKIP, skips the card waitlist queue) is obtained via /invite?campaign=skip or /shhhhh?campaign=skip. This change puts if (!user && interceptGuestCta({ dest: '/card' })) return at ShhhhhLandingPage.tsx:220, above the queuePendingBadgeCampaigns(badgeCampaigns, 30) call at :223. For a signed-out visitor on a phone during the migration window the flow is now: read campaign tags → intercept → openStore() → buildDeferredPayload(dest, invite). That payload picks campaigns up only from the cookie (getPendingBadgeCampaigns() in src/utils/deferred-link.ts) — which was never written, because the queue call is unreachable behind the early return. Passing dest: '/card' also overrides the default dest (current path + search), which would otherwise have carried ?campaign=skip back into the app. So both recovery routes are gone: the visitor installs the app, lands on /card with no campaign identity, hits the Berghain gate and is put on the waitlist instead of skipping it. Code disagrees with product truth, and the code is the wrong side.

The author already handles this correctly on the sibling surface: InvitesPage.tsx:255 queues the campaigns and only then calls interceptGuestCta(), with the comment "cookie/queue bookkeeping above still runs so a keep-web signup or post-install link re-tap recovers the invite context." The door skips that bookkeeping.

This is also the untested shared-state mutation here (the pending-badge-campaign cookie feeding a backend claim): the new case at ShhhhhLandingPage.test.tsx:117 ("hands off before the campaign signup route when the link carries campaign tags") asserts only that mockPush was not called, so it locks in the lossy behaviour rather than catching it.

Fix: move the intercept below const queuedBadgeCampaigns = queuePendingBadgeCampaigns(badgeCampaigns, 30) (or queue first when badgeCampaigns.length > 0, mirroring InvitesPage), and extend the campaign test to assert the queue was written before interceptGuestCta fired — i.e. that the deferred payload carries the campaign.

Checked clean

  • Verified the detached worktree HEAD, supplied base SHA, merge base, trusted author, base ref, and live PR metadata.
  • Traced signed-out, signed-in, campaign-tagged, flag-off, and native-app branches through the Shhhhh CTA.
  • Traced mobile deferred payload creation/restoration and the desktop DownloadQR -> /app -> bare-store redirect path.
  • Exact-head required CI is green, including unit, typecheck, eslint, format, native-export, and ci-success; advisory ds-shots was still in progress.
  • The focused local Jest command could not run because this detached checkout has no Jest binary; it made no worktree changes.
  • Reviewed the optional surface plumbing for existing hook callers and found no auth, secret, privacy, money, or privilege boundary change.

Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.

Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 0f2ad5702c4c · Context: repo · Took 8m

// desktop opens the scan-to-download QR, phones deep-link — carrying
// /card so the app lands them back on the door's destination. Flag off
// (and in the native app) this is a no-op and the routes below run.
if (!user && interceptGuestCta({ dest: '/card' })) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Queue URL campaigns before the mobile handoff

With pwa-sunset on, a fresh signed-out phone visitor following /shhhhh?badge_campaign=<active-skip-id> returns here before the URL-derived campaign is queued on line 223. openStore() builds the deferred payload from getPendingBadgeCampaigns(), so the install carries /card but not this campaign; first launch cannot restore or claim the intended Skip Pass and the card flow rejects the visitor. Queue badgeCampaigns before interception (or pass them explicitly into the deferred payload) and assert that the serialized mobile handoff contains them.

// desktop opens the scan-to-download QR, phones deep-link — carrying
// /card so the app lands them back on the door's destination. Flag off
// (and in the native app) this is a no-op and the routes below run.
if (!user && interceptGuestCta({ dest: '/card' })) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Preserve the destination through the desktop QR

On desktop with pwa-sunset on, this passes { dest: '/card' }, but the WEB branch only opens ScanToDownloadModal; DownloadQR encodes plain /app, whose smart redirect opens a bare store URL. After scanning and installing, the app therefore has no deferred /card destination and opens its default route instead of the door's target. Thread the handoff through the QR/smart-link path (with the same sanitization as openStore) and add an assertion for the encoded QR destination.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

🖼 Visual diff — 3 screens moved

3 of 66 shots changed · 63 identical · baseline b7f62ae → head 268fa75

worst % screen widths
1.78% avatar-picker 430
0.07% badges 320
0.03% withdraw 430
new screens (1)
  • card-pin

job summary · before/after/diff images — artifact

Fixture screenshots, no backend. Advisory — this check never blocks a merge. Posted from the default branch by ds-shots-comment.yml; the report it renders is untrusted data.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

The mobile campaign ordering is fixed, but the desktop QR still drops its deferred payload at /app, so the destination and campaign do not survive install.

Findings

  • MAJOR · src/components/Migration/DownloadQR.tsx:27 · Forward the QR payload past /app
    This line makes a desktop scan open /app?pnutdl=1&dest=%2Fcard..., but src/app/app/page.tsx never reads the query: its phone effect redirects to the bare STORE_URL[targetStore], while the already-installed native branch replaces the route with /home. An Android or iOS scan therefore still loses /card and any queued badge campaign before install/open; the modal's raw store badges lose them as well. Make /app forward the supplied payload into the platform handoff (and consume it when already native), then cover the QR-to-smart-store path with an integration test.

  • MAJOR · src/components/Migration/DownloadQR.tsx:27 · [claude-opus] Desktop QR payload is dropped by /app, so the Skip Pass is still lost
    The round-1 fix carries the campaign and dest through the desktop path by building a deferred payload and encoding it in the QR: DownloadQR now renders ${origin}/app?${payload} (src/components/Migration/DownloadQR.tsx:27), fed from buildDeferredPayload(handoff.dest, handoff.invite) in useGuestStoreHandoff.tsx:59.

The receiving end never reads it. src/app/app/page.tsx is the only handler for that route, it does not call useSearchParams or touch window.location.search anywhere, and it bounces the scanning phone with window.location.replace(STORE_URL[targetStore]) (page.tsx:84) — a bare store URL. The store buttons below it are the same bare href={STORE_URL[s]} (page.tsx:113). Neither branch appends &referrer=<payload> for Play nor calls copyIOSHandoff for the iOS clipboard, which are the only two channels restoreDeferredContext reads on first launch (src/utils/deferred-link.ts:253-282). There is no middleware or redirects.json rule for /app either. So pnutdl=1&campaign=…&dest=/card is discarded at the redirect.

What that costs, against product truth: product/card.md:87 and :145 declare the Skip Pass (WAITLIST_SKIP) obtainable via /invite?campaign=skip or /shhhhh?campaign=skip. Before this PR a signed-out desktop visitor on /shhhhh?campaign=skip was pushed to the web signup with the campaign queued in the cookie, and the claim ran on registration. With the pwa-sunset flag on, that visitor now gets the QR, installs on a phone with no payload, and registers as an organic install — no queued campaign, no Skip Pass, and no dest=/card either (which is prior finding P2, also still unfixed by the same root cause). The code is what is wrong here, not the product doc. The phone path is fine: openStore builds and writes the payload itself (src/utils/migration.utils.ts:96-113).

Fix: have /app read its own querystring and forward it — playStoreUrlWithReferrer(payload) for the Android redirect and both store anchors, copyIOSHandoff(payload) before the iOS bounce (the anchor click is a user gesture, so the clipboard write is allowed there; the automatic window.location.replace on iOS is not, and may need to fall back to showing the buttons when a payload is present). A test asserting the redirect target carries referrer= would have caught this; the current hook test only asserts the payload reaches the modal prop, which is one hop short of the consumer.

Checked clean

  • Verified the pinned head, base, author, and merge base; reviewed the landing CTA, guest handoff hook, QR modal, smart /app redirect, deferred-link build/restore, and affected consumers.
  • P1 and P3 are fixed for mobile handoffs: URL campaigns are queued before interception, and buildDeferredPayload reads that cookie queue synchronously.
  • Exact-head required CI is green, including unit, native-export, typecheck, eslint, format, ds-lint, be-baseline, analyze, human-authors, and ci-success; advisory ds-shots was still running.
  • Focused local Jest execution was unavailable because the detached worktree has no installed Jest binary.
  • Reviewed flag-off and native fallthrough, signed-in waitlist routing, modal state, analytics surface propagation, and unchanged hook consumers.

Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.

Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 268fa758c9a6 · Context: repo · Took 13m

return (
<div className="flex w-full flex-col items-center gap-3 py-2">
<QRCodeWrapper url={`${origin}/app`} />
<QRCodeWrapper url={payload ? `${origin}/app?${payload}` : `${origin}/app`} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Forward the QR payload past /app

This line makes a desktop scan open /app?pnutdl=1&dest=%2Fcard..., but src/app/app/page.tsx never reads the query: its phone effect redirects to the bare STORE_URL[targetStore], while the already-installed native branch replaces the route with /home. An Android or iOS scan therefore still loses /card and any queued badge campaign before install/open; the modal's raw store badges lose them as well. Make /app forward the supplied payload into the platform handoff (and consume it when already native), then cover the QR-to-smart-store path with an integration test.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Superseded by #3018 (all four folded into one PR per Konrad); branch kept until that merges.

@0xkkonrad 0xkkonrad closed this Sep 7, 2026

This branch was successfully deployed

1 active deployment
Preview — 268fa758 Deployed Sep 7, 2026 by vercel[bot]
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.

1 participant