Referral rewards: invite/redeem/claim flows - #55
Conversation
Implements the client side of the MeetCal referral rewards program:
- API client: postJson helper plus typed fetchReferral / redeemReferralCode /
claimAndroidReward / claimIosReward against /users/me/referral endpoints.
- Referral screen (app/shared-screens/referral.tsx): invite code + native
Share, milestone progress, pending/qualifying/qualified breakdown, and the
reward list with per-platform "Claim free month". Entry point added to the
profile settings screen (auth-gated).
- Redeem flow: skippable post-signup prompt (redeem-invite screen) wired into
the sign-in redirect, plus a meetcal://invite/{code} deep link that stores
the code and pre-fills it. Error codes mapped to friendly messages in
utils/referral.ts (with unit tests).
- iOS claim uses Purchases.purchaseDiscountedProduct with a
PurchasesPromotionalOffer built from the backend signature bundle; Apple
sheet is preceded by a renewal-terms confirmation and cancellation is a
no-op. Android claim confirms the 30-day billing shift and handles 503.
- Cleanup: SubscriptionContext subscriptionType union is now
free | monthly | annual | lifetime, mapping product ids by
annual/year -> annual, month -> monthly, lifetime retained for legacy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsMx91snaHTFimvs7zaY11
…led state, legacy cache normalization - ApiReferralReward.id and claim*Reward reward id are now number (backend BIGSERIAL). claimingId state and comparisons updated accordingly. - referral.tsx: added an isMountedRef guard covering every async setState path (load, onRefresh, both claim finally-blocks) so no updates fire after unmount. - iOS claim: the backend now returns status "earned" and only flips to delivered on the RevenueCat redemption webhook, so a successful purchaseDiscountedProduct no longer implies a status change. Track a local scheduledIds set; a scheduled reward is not re-claimable and shows "Free month scheduled — may take a few minutes to reflect" instead of the Claim button. userCancelled remains a no-op (reward stays claimable). - SubscriptionContext: normalize legacy/unknown cached subscriptionType values (e.g. the retired 'quarterly') to 'unknown' on cache read so a stale label never renders before the next RevenueCat refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsMx91snaHTFimvs7zaY11
…rompt, honest Android type - Reward object now carries ios_offer_issued_at (nullable RFC3339/epoch). The referral screen derives the "Free month scheduled" state from that timestamp being recent (<48h) via isIosOfferOutstanding, so an app restart no longer re-shows the Claim button for an already-scheduled reward. The in-memory scheduledIds set is kept only as the immediate post-purchase bridge until the refetch lands. Tolerant parseTimestampMs handles ISO strings and epoch ms. - Claim handlers now treat HTTP 409 gracefully (offer already outstanding / claim in progress): mark scheduled locally on iOS, refetch, and show an informational toast instead of an error — no double-claim. - Redeem prompt is keyed to account creation: it surfaces only for a freshly created account (user.createdAt within 10 min) that hasn't dismissed it, or whenever a deep-link code is pending (explicit intent, any user). Existing users signing back in no longer see it, avoiding the not_eligible confusion. Still skippable. - ApiAndroidClaimResult.status narrowed to 'delivered' only (the backend never returns 'delivering' in the response body). - Added unit tests for parseTimestampMs and isIosOfferOutstanding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsMx91snaHTFimvs7zaY11
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af44a98a48
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }; | ||
|
|
||
| const handleSkip = async () => { | ||
| await markRedeemPromptSeen(); |
There was a problem hiding this comment.
Clear pending invite code when skipping
When the redeem prompt was opened from an invite deep link, tapping Skip only records that the prompt was seen and leaves pending_referral_code_v1 in AsyncStorage. The sign-in redirect checks pendingCode before promptSeen, so every later auth completion on this device will route back to /shared-screens/redeem-invite even though the user skipped; clear the pending code in the skip path as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in dfbe5a0. handleSkip now clears the pending code as well (Promise.all([markRedeemPromptSeen(), clearPendingReferralCode()])), so a deliberate skip no longer leaves pending_referral_code_v1 behind to re-trigger the redeem screen on later auth completions.
Generated by Claude Code
| // User backed out of Apple's sheet: reward stays claimable, do nothing. | ||
| if ( | ||
| e && | ||
| typeof e === "object" && | ||
| "userCancelled" in e && | ||
| (e as { userCancelled?: boolean }).userCancelled | ||
| ) { | ||
| return; |
There was a problem hiding this comment.
Release iOS reward reservations on cancellation
On iOS, this cancellation branch runs after claimIosReward has already requested a promotional-offer signature, and the new API/type comments say that issuing a signature makes the backend 409 re-claims while ios_offer_issued_at is outstanding. If the user backs out of Apple's sheet, returning here leaves that backend reservation in place, so the next retry/refetch can mark the reward as scheduled even though no purchase was completed; the cancellation path needs to clear/release the outstanding offer or avoid reserving it until after a successful purchase.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in dfbe5a0 (app) + backend b9dbbe4. A client-only fix couldn't fully resolve this since the reservation lives server-side, so I added POST /users/me/rewards/{id}/release-offer which clears ios_offer_issued_at while the reward is still earned. The iOS cancellation branch now calls it (best-effort) and refetches, so the reward returns to claimable immediately instead of showing "scheduled" or 409-ing re-claims for the throttle window. It's a no-op once a real $0 renewal has delivered the reward, so a mis-detected cancel can't undo a genuine delivery. Covered by a new backend integration test (claim → 409 → release → claimable again).
Generated by Claude Code
…ancel - redeem-invite Skip now also clears the pending deep-link code, so the sign-in redirect (which checks the pending code before the prompt-seen flag) no longer routes back to the redeem screen on every future auth after a deliberate skip. - On iOS purchase-sheet cancellation, call the new release-offer endpoint so the backend reservation is cleared: the reward stays immediately claimable and is not shown as "scheduled". Add releaseIosReward to the API client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsMx91snaHTFimvs7zaY11
Summary
Client side of the referral rewards program: invite sharing, code redemption, progress, and per-platform reward claiming. Builds against the
meetcal-backendAPI (companion PR on the same branchclaude/meetcal-referral-rewards-ejq5un;meetcal-webhosts the invite landing page).Program: invite friends; for every 5 who become new paid subscribers and stay paid 30 days, the referrer earns 1 free month.
What's included
API client (
lib/api/meetcal-api.ts)GET /users/me/referral,POST /users/me/referral/redeem, and the iOS/Android claim endpoints, using the existing Clerk-Bearer pattern.Referral screen (
app/shared-screens/referral.tsx, entry point in profile/settings)Redeem flow
meetcal://invite/{code}deep link stores the code and pre-fills it at sign-up.Claim flow (per platform)
Purchases.purchaseDiscountedProduct(verified against the installed SDK). Shows the required "next renewal includes 30 days free, then renews at the normal price" disclosure. A cancelled sheet leaves the reward claimable. Because the backend confirms delivery via webhook, a claimed reward shows a persistent "scheduled" state derived fromios_offer_issued_at(survives app restart), and a re-claim 409 is handled as already-scheduled.Cleanup
SubscriptionContextproduct mapping updated from the stalequarterly/lifetimetofree | monthly | annual | lifetime, with legacy cache values normalized. Gating behavior unchanged.Testing
tsc --noEmitclean;expo lint0 errors.Note: app↔backend calls and the iOS store purchase are not covered by automated tests — those need TestFlight/sandbox validation before launch.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VsMx91snaHTFimvs7zaY11
Generated by Claude Code