Skip to content

Referral rewards: invite/redeem/claim flows - #55

Open
memohnsen wants to merge 4 commits into
masterfrom
claude/meetcal-referral-rewards-ejq5un
Open

Referral rewards: invite/redeem/claim flows#55
memohnsen wants to merge 4 commits into
masterfrom
claude/meetcal-referral-rewards-ejq5un

Conversation

@memohnsen

Copy link
Copy Markdown
Collaborator

Summary

Client side of the referral rewards program: invite sharing, code redemption, progress, and per-platform reward claiming. Builds against the meetcal-backend API (companion PR on the same branch claude/meetcal-referral-rewards-ejq5un; meetcal-web hosts 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)

  • Typed functions for 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)

  • Invite code + share sheet, progress toward the next milestone, pending vs qualifying counts separated, and the reward list with a "Claim free month" action.

Redeem flow

  • Skippable invite-code prompt after sign-up, keyed to account creation so existing users signing back in don't see it.
  • meetcal://invite/{code} deep link stores the code and pre-fills it at sign-up.

Claim flow (per platform)

  • iOS: fetches the backend's promotional-offer signature and runs 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 from ios_offer_issued_at (survives app restart), and a re-claim 409 is handled as already-scheduled.
  • Android: server-side; confirms the billing date moves 30 days; 503 → "temporarily unavailable".

Cleanup

  • SubscriptionContext product mapping updated from the stale quarterly/lifetime to free | monthly | annual | lifetime, with legacy cache values normalized. Gating behavior unchanged.

Testing

  • tsc --noEmit clean; expo lint 0 errors.
  • 167 jest tests pass, including new unit tests for the referral helpers (timestamp parsing, offer-window logic, error mapping).

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

claude added 3 commits July 22, 2026 12:50
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread app/shared-screens/redeem-invite.tsx Outdated
};

const handleSkip = async () => {
await markRedeemPromptSeen();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Comment thread app/shared-screens/referral.tsx Outdated
Comment on lines +207 to +214
// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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
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