feat(referral): attributed share links + invite nudge on every payment type - #2680
Conversation
…rfaces Foundation for the referral share fixes: registers the new REFERRAL_SOURCES tags (profile_header, public_profile_guest, badge_detail, badge_unlock, qr_pay_success, and the pre-existing transaction_receipt wire literal), documents the link_type property, and adds the guest-profile door + QR-success invite CTA copy to all four locale catalogs.
The receipt's referral nudge was gated on a hand-kept direction allow-list (send/withdraw/bank_withdraw) that silently excluded every QR pay and card spend — both arrive as direction 'qr_payment', and they are the two highest-frequency transaction types (~41k QR events vs ~1.4k send links). - new kind-based hasReferralNudge predicate (+ inbound-direction block so role-polymorphic kinds never nudge the receiving side), table-tested over all 14 IntentKinds - 2-CTA ceiling resolved by demotion, not exclusion: below two stacked action buttons the nudge keeps the filled treatment, at two it becomes an underlined text row above the support link - QR-pay success screen gets the invite row the 'Invite friends to earn even more' line always lacked, opening InviteFriendsModal; hidden while a reward is still claimable, deliberately not gated on isActivated - receipt INVITE_LINK_SHARED moves inside the try (outcome, not intent), gains link_type/variant, and a REFERRAL_CTA_SHOWN impression leg
The 18 card win captions shipped with no URL at all — a viral share with zero path back to the product. The sharer's own peanut.me/<handle> now rides the caption text on both share paths (native share-with-file and the desktop twitter intent) via one composed string, so mobile and desktop post the same line. - composeShareCaption(caption, url?) in share.utils.ts; caption unchanged when no url - anti-dox respected: the hideUsername toggle and an unknown username both drop the link (derived per render, not state, so a post-mount toggle flip is honored); never links the 'anon' placeholder - link_type property on all CARD_SHARE_ASSET_SHARED captures, incl. the rejection-appeal ones (which deliberately stay link-free)
A logged-out visitor on peanut.me/<username> used to hit a dead end: 'Peanut
is invite-only. Go beg your friend for an invite link!' — with no signup path
that credits the profile owner. Every bare profile link already shared in the
wild (profile pill, badge brags, card captions) leaked its referral.
- guest card now says '{username} has an invite for you' with a Claim-your-
invite CTA that writes the owner's invite code (session cookie, same
contract as InvitesPage's claim) BEFORE the pwa-sunset store handoff can
swallow the click, then routes to /invite?code=<owner>
- ProfileHeader: shareableUrl instead of the no-fallback BASE_URL (preview/
staging shared 'undefined/<user>'), self-profile-only guard on the share
pill, toast on the silent desktop clipboard path
- badge shares (detail modal + unlock drawer) switch from the bare profile
URL to the attributed invite link; detail modal keeps close-on-share
- referral analytics on all four surfaces (REFERRAL_CTA_SHOWN/CLICKED,
INVITE_LINK_SHARED with source + link_type)
Live QA caught a leftover dead end: a guest tapping Request on the fixed profile page still got the old 'Beg for an invite' modal — one tap from the crediting door, back to the uncredited path. Guests now get the same invitedLine + Claim-your-invite CTA in that modal; the beg flow stays for logged-in-without-access visitors, whose signup can no longer credit anyone.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR adds referral-aware sharing and invite CTAs across badge, card, profile, QR payment, and transaction receipt flows. It adds referral-source analytics, invite-code routing, localized invitation text, conditional share links, and focused tests. ChangesReferral sharing and invite entry points
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Guest
participant PublicProfile
participant PostHog
participant SessionCookie
participant InviteRoute
Guest->>PublicProfile: select join CTA
PublicProfile->>SessionCookie: save invite code
PublicProfile->>PostHog: capture referral CTA
PublicProfile->>InviteRoute: navigate with invite code
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
Code-analysis diffPainscore total: 7159.32 → 7151.89 (-7.43) 🆕 New findings (71)
…and 51 more. ✅ Resolved (68)
…and 48 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/`(mobile-ui)/qr-pay/page.tsx:
- Around line 1524-1530: Update the InviteFriendsModal visibility tracking so
each false-to-true opening emits MODAL_SHOWN and REFERRAL_CTA_SHOWN, rather than
suppressing subsequent openings while the component remains mounted. Reset the
hasTrackedShow guard on close or otherwise detect every visibility transition,
preserving the existing initial-open behavior.
In `@src/components/Profile/components/ProfileHeader.tsx`:
- Around line 92-96: Update the share/copy handler in ProfileHeader so it is
asynchronous, awaits navigator.clipboard.writeText(profileUrl), and displays
toast.info with shareButton.linkCopied only after the write resolves. Handle
rejected clipboard writes before showing the success toast, preserving the
existing share behavior.
In `@src/components/TransactionDetails/TransactionDetailsReceipt.tsx`:
- Around line 347-372: Add capture of ANALYTICS_EVENTS.REFERRAL_CTA_CLICKED in
shareInviteLink’s successful share path, using the same source, link_type, and
variant payload as INVITE_LINK_SHARED. Place it before the success path returns
and keep cancellation/error handling unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 05532b7c-1dec-4b59-8235-b3f622084e7e
📒 Files selected for processing (23)
src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsxsrc/app/(mobile-ui)/qr-pay/page.tsxsrc/components/Badges/BadgeDetailModal.tsxsrc/components/Badges/BadgeStatusDrawer.tsxsrc/components/Badges/__tests__/BadgeDetailModal.test.tsxsrc/components/Card/BadgeSkipCelebration.tsxsrc/components/Card/CardRejectionScreen.tsxsrc/components/Card/CardUnlockDrawer.tsxsrc/components/Card/share-asset/ShareAssetActions.tsxsrc/components/Card/share-asset/__tests__/share.utils.test.tssrc/components/Card/share-asset/share.utils.tssrc/components/Profile/components/ProfileHeader.tsxsrc/components/Profile/components/PublicProfile.tsxsrc/components/Profile/components/__tests__/ProfileHeader.test.tsxsrc/components/Profile/components/__tests__/PublicProfile.test.tsxsrc/components/TransactionDetails/TransactionDetailsReceipt.tsxsrc/components/TransactionDetails/__tests__/transaction-predicates.test.tssrc/components/TransactionDetails/transaction-predicates.tssrc/constants/analytics.consts.tssrc/i18n/app/messages/en.jsonsrc/i18n/app/messages/es-419.jsonsrc/i18n/app/messages/es-AR.jsonsrc/i18n/app/messages/pt-BR.json
…e gate per fact Applies the CodeRabbit + /code-review findings: - bank_claim receipts nudge again: 'Claimed to Bank' is only ever viewed by the paying sender (external claimers have no account; Peanut-user claims render as 'send'), so it was wrong to treat it as inbound - clipboard writes can't lie anymore: copyTextToClipboardWithFallback now reports success (execCommand's boolean was being dropped), and both the profile pill and the receipt toast only after a real write - receipt share: navigator.share rejection (WebView permission policy) now falls back to copy instead of a dead button; REFERRAL_CTA_CLICKED added so the receipt funnel matches the badge/modal event pair - InviteFriendsModal mounts only while open on the QR success screen — its per-mount shown-guard was swallowing re-open analytics, and a closed modal reconciled on every 50ms hold-to-claim tick - guest-door hardening: impression keyed by username (route reuses the component across profiles), click guarded against the pre-auth flash (fast tap wrote another user's invite code into a logged-in session) - badge shares: link_type reports what the share actually carried (bare origin when auth hasn't resolved), and both badge surfaces now emit the SHOWN impression leg - duplication collapsed behind helpers: getBadgeShareLink/captureBadgeShare (badge.utils), profileShareUrl (share.utils), rewardClaimable/rewardClaimed (qr-pay), showSplitCta/receiptUrl (receipt)
…ookie, whole funnels Applies the /code-review cross-file tracer's findings: - bank_claim back out of the nudge: the direction has two possible viewers (paying sender when claimed externally, AND a Peanut user viewing their own claim-to-bank — fiat-offramp only rewrites to 'send' on the sender side), and the direction alone can't tell them apart. Blocked until the predicate can see the viewer role; bank_request_fulfillment confirmed SENDER-only by its strategy and locked in by test - the guest door validates before persisting: a profile can exist without being a claimable invite, and an unvalidated cookie gets upgraded to 30 days by the accept retry and then skips the Landing screen — the exact PR #2346 lockout the session-scope choice was protecting against - link_type completed across the funnel: InviteFriendsModal (the qr_pay_success surface) now carries it, badge impressions latch once per open instead of re-firing when the username hydrates mid-open, and profile_header gains the SHOWN leg + outcome-based CLICKED so every REFERRAL_SOURCES member reports the same event shape
…e profile owner The store handoff ran window.open after an awaited validate — iOS drops the user gesture across a fetch, blocks the popup, and the intercepted branch skipped navigation, so the CTA died silently on mobile web. The intercept now fires synchronously first; the cookie write still lands because the handoff opens _blank and this tab lives on (store-hop attribution proper is TASK-21044). Crediting now requires the code to resolve to the profile owner themself: the API typo-fallback can resolve a waitlisted handle to a different real user (maria23 -> maria), and that click must not write a cookie nor report link_type invite_code.
…ests to tables, comments to file norm The receipt and profile pill hand-rolled the share/copy/toast/capture flow that ShareButton (imported in the same file) already owns; both route through it now, which also lets copyTextToClipboardWithFallback revert to its original contract. Design-history comments and repeated tellings of the same rule go — the allow-list story lives once, on REFERRAL_NUDGE_KINDS; the anti-dox rule once, on profileShareUrl. Mock-permutation tests collapse to test.each tables; suites that tested untouched code or pinned a Tailwind class are deleted. rewardClaimed reverted (readability refactor of untouched conditionals — out of scope). Behavior deltas, all from the ShareButton unification: mobile copies + toasts before the sheet; the receipt copy toast says 'Link copied'; a double failure now surfaces 'Sharing failed' instead of dying silently.
…action-predicates.test.ts
Review follow-ups from Kush's pass: 1. ShareButton: a cancelled share sheet after a successful copy now calls onSuccess — the link is on the clipboard and the 'copied' toast already showed, so consumers capturing INVITE_LINK_SHARED must count it. With no copy landed, cancellation stays quiet as before. 2. ProfileHeader: the share-pill impression re-arms when the pill hides — the [...recipient] route reuses the component instance, so a mount-scoped latch undercounted self → other → self navigations. Both matter because this PR's variant/link_type comparison is the metric these events feed.
kushagrasarathe
left a comment
There was a problem hiding this comment.
Reviewed twice — full-diff pass, then a second pass focused on attribution correctness and PII (Aleks's hunch).
Approving with one hard condition: peanut-api-ts#1342 must reach prod before or with this PR. The FE owner-match guard only blocks the silent cookie write — a mismatched code still navigates to /invite?code=<handle>, where the landing page validates against the old BE ladder and handleClaim persists the raw code. With this PR live and #1342 not deployed, every public profile funnels guests into that path. Verify at release time, not merge time.
PII pass: clean. No usernames/PII in any new analytics payload (checked every capture site — source/link_type/variant only), guests see nothing beyond the username already in the URL, the anti-dox rule lives in one place and is derived per-render, and the public-receipt nudge is !isPublic-gated. The BE pair actually closes a leak (ghost1 → ghost).
Pushed 13abe813f (review follow-ups, full suite green): ShareButton now counts a cancelled sheet after a landed copy as a share outcome, and the profile-pill impression re-arms when the pill hides on the reused route instance. Both undercounts would have biased exactly the variant/link_type comparison this PR exists to measure.
Remaining, non-blocking: task link still to be attached; the copy pass on the 3 new keys is still open per the PR body.
Task
What
Two linked referral-loop fixes (CPO growth-audit items 2 + 3):
1. Unattributed pride-shares + the guest dead end. The highest-intent share moments
(profile pill, badge shares, all 18 card win captions) shared bare
peanut.me/<u>links —which credit nobody — or no link at all. A logged-out friend clicking one landed on
"Peanut is invite-only. Go beg your friend for an invite link!" with no signup path that
credits the sharer.
2. The receipt share button barely existed. The receipt referral nudge was gated on a
hand-kept
['send','withdraw','bank_withdraw']direction list, silently excluding every QRpay and card spend (both
direction: 'qr_payment') — the two highest-frequency transactiontypes (~41k QR events, ~3.8k card spends vs ~1.4k send links). The QR success screen said
"Invite friends to earn even more" with no button. Six months of this surface: ~39 sharers.
How
The guest profile becomes the crediting door (retroactively attributes every bare
profile link already in the wild): the guest card now says "{username} has an invite for
you" with a Claim-your-invite CTA that writes the owner's invite code (session cookie, same
contract as InvitesPage's claim — durable cookies caused the #2346 lockout) BEFORE the
pwa-sunset store handoff can swallow the click, then routes to
/invite?code=<owner>.Per-surface link decisions:
/invite?code=(free-form text,no label constraint; logged-in recipients get redirected to the profile anyway)
peanut.me/<u>(the label IS the value) but viashareableUrl(the old non-fallback BASE_URL import rendered
undefined/<u>on preview builds), gatedself-profile-only, desktop copy now toasts
peanut.me/<handle>in the caption text (matches the pill bakedinto the PNG; works on both native-share-with-file and the twitter intent). The anti-dox
hideUsernametoggle drops the link; rejection-appeal shares deliberately stay link-freeReceipt coverage, kind-based: new
hasReferralNudgepredicate (allow-list of outboundkinds + inbound-direction block, table-tested over all 14 IntentKinds). The 2-CTA ceiling
that justified excluding QR pay is resolved by demotion, not exclusion: below two stacked
action buttons the nudge keeps the proven filled treatment; at two it becomes an underlined
text row. QR success screen gets the invite row (opens InviteFriendsModal), hidden while a
reward is still claimable, deliberately not gated on
isActivated(first QR pay = flagstill false server-side).
Analytics:
link_type(invite_code|profile|none) on all referral events, sixnew registered
REFERRAL_SOURCES, receiptINVITE_LINK_SHAREDmoved inside the try(outcome, not intent) + a new
REFERRAL_CTA_SHOWNimpression leg,variantproperty tomeasure the button→text-link demotion.
i18n: 3 new keys × 4 catalogs (es-AR voseo overrides included).
Review guide
Read in this order: analytics/i18n foundation → transaction-predicates + receipt → guest
door + profile/badges → card share-asset. The plan, constraints dossier and adversarial
review live in
mono/inbox/referral-fixes-aug-12/.Flags for the reviewer
"Peanut is invite-only — and {username} has an invite for you." (+ es-419/es-AR/pt-BR).
transaction-predicates.ts+ its test (differenthunks —
hasUserProfile). Mergeable either order; whoever lands second rebases trivially.openStore()does not carry a deferred invite payload, so astore bounce from ANY surface loses attribution once pwa-sunset flips to 100%
(TASK-21044). The door writes the cookie pre-intercept so the code is there to carry.
hasReferralNudge("every payment type" ≠ deposits).InviteFriendsModalwith empty username builds a dead/invite?code=QR (Profile passes?? ''); QRBottomDrawer says "Share your profile"over a
/pay/<u>URL.Testing
cookie-on-intercepted-path, ProfileHeader gating, share-caption composition)
mono/inbox/referral-fixes-aug-12/qa/)Screenshots
Captured on the Nutcracker harness (seeded activated user, 375×667). Assets branch
pr-assets-2680— delete post-merge.| Guest door on peanut.me/ (Vercel preview, 12/12 checks) |
|
|
| /invite landing after the door click — cookie written, inviter named |
Design notes / accepted trade-offs
variantrides the analytics so the demotion's cost is measurable.hasReferralNudgekeys onextraDataForDrawer.kind, which pre-intent rows don't carry, so old send/withdraw receipts no longer nudge. All new activity is intent-based; trading the dead tail for a kind-exhaustive predicate is deliberate.?? '').Follow-ups (filed in the readiness report, none blocking)
openStore()must carrybuildDeferredPayload()before pwa-sunset flips to 100% (TASK-21044) — store bounces lose attribution on every surface today.TextLinkRowcomponent — three copies of the underlined text-row pattern now exist.generateInviteCodeLinkcould returninvitePath— five inline/invite?code=literals repo-wide.GuestJoinCardextraction so logged-in profile views skip the migration-flag hook.Review round (13 Aug) — multi-agent review + live QA
Two confirmed defects in the guest door, both fixed in
e256c5f3e:interceptGuestCta()ran after the awaited validate, so the store handoff'swindow.opensat in a promise continuation — iOS drops the gesture and the CTA silently died. The intercept now fires synchronously inside the click; the cookie write still lands because the handoff opens_blankand this tab lives on (store-hop attribution proper is TASK-21044).maria23→maria). Crediting now requires the resolved inviter to be the profile owner themself; mismatches write nothing and reportlink_type: 'none'. The API-side source is removed in peanutprotocol/peanut-api-ts#1342 (data-backed ladder hardening — legacy suffix codes still drive ~16% of accepts, so suffix recovery stays; the handle-rewriting rules go).Design intent, for the record: every public profile is deliberately an invite door — bare
peanut.me/<u>links credit their owner retroactively. This is the design (Konrad, Aug 2026), not an accident to be cleaned up later.Live QA on the harness (guest door end-to-end incl. pt-BR, receipt nudge button/text_link variants, badge share links, share-pill toast): all pass. Known open follow-ups from review, not blocking: guest-card pre-auth flash guard (
isFetchingUseron the render gate),REFERRAL_CTA_SHOWNimpression for the QR-success row, guest-side title for the invite-gate modal.Trim round (
e0d812ba6): −340 net lines with the same behavior. The receipt nudge and profile pill now route through the sharedShareButton(mobile copies + toasts before the sheet, like every other share surface; a double failure surfaces "Sharing failed" instead of dying silently). Repeated rule-tellings collapsed to one home each; mock-permutation tests aretest.eachtables;rewardClaimedreverted as out-of-scope.Review round (14 Aug, Kush):
13abe813ffixes two share-outcome undercounts found in review — ShareButton now reports success when the share sheet is cancelled after the copy already landed (the link is on the clipboard), and the profile-pill impression re-arms when the pill hides (the reused route instance undercounted self → other → self). Both feed the variant/link_type comparison this PR measures. Full suite green.