File size: 3,375 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | /**
* Checkout attempt lifecycle — retry context store.
*
* Separate from `PENDING_CHECKOUT_KEY` in checkout.ts because the two
* keys have different terminal-clear rules:
*
* PENDING_CHECKOUT_KEY — "should we auto-open the overlay on
* next mount?" Cleared on overlay close
* to avoid silent auto-retries.
*
* LAST_CHECKOUT_ATTEMPT_KEY — "what product should the failure-retry
* banner re-open?" MUST survive Dodo
* emitting `checkout.closed` before the
* browser navigates to ?status=failed,
* so the retry button has context.
*
* Living in its own file so unit tests can exercise the helpers
* without pulling in `dodopayments-checkout` (which is browser-only
* and breaks Node test runners on import).
*/
import { clearReferralOnAttribution } from './referral-capture';
export const LAST_CHECKOUT_ATTEMPT_KEY = 'wm-last-checkout-attempt';
export interface CheckoutAttempt {
productId: string;
referralCode?: string;
discountCode?: string;
startedAt: number;
}
export type CheckoutAttemptClearReason =
| 'success'
| 'duplicate'
| 'signout'
| 'dismissed';
/**
* Maximum age of a saved attempt before we treat it as stale and
* ignore on read. Generous (24h) so a user declined this morning can
* return this afternoon and retry the exact product they picked.
* This is the SOLE staleness gate — there is no separate abandonment
* sweep; records older than this are ignored by `loadCheckoutAttempt`.
*/
export const CHECKOUT_ATTEMPT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export function saveCheckoutAttempt(attempt: CheckoutAttempt): void {
try {
sessionStorage.setItem(LAST_CHECKOUT_ATTEMPT_KEY, JSON.stringify(attempt));
} catch {
// Storage disabled (private browsing); retry banner will degrade
// gracefully to omitting the "Try again" button.
}
}
export function loadCheckoutAttempt(): CheckoutAttempt | null {
try {
const raw = sessionStorage.getItem(LAST_CHECKOUT_ATTEMPT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CheckoutAttempt;
if (!parsed || typeof parsed.productId !== 'string' || typeof parsed.startedAt !== 'number') {
return null;
}
if (Date.now() - parsed.startedAt > CHECKOUT_ATTEMPT_MAX_AGE_MS) {
return null;
}
return parsed;
} catch {
return null;
}
}
export function clearCheckoutAttempt(reason: CheckoutAttemptClearReason): void {
try {
sessionStorage.removeItem(LAST_CHECKOUT_ATTEMPT_KEY);
} catch {
// Ignore storage failures.
}
// Referral attribution is retired in TWO terminal cases:
// - `success`: the share has been credited — clear so the same
// code can't credit a second purchase from this user.
// - `signout`: the session is ending (or being rotated to a new
// user). Leaving the referral intact would attribute user B's
// future purchase to the sharer who sent user A the link —
// cross-user referral leak.
// Other reasons (duplicate, dismissed) leave the ref in place so
// the user can retry their own purchase without losing attribution.
if (reason === 'success' || reason === 'signout') {
clearReferralOnAttribution();
}
}
|