| /** | |
| * Pure helpers for the checkout-success banner's extended-unlock state | |
| * machine. Lives in its own module so tests (and any future consumer) | |
| * can exercise the decision logic without pulling in `dodopayments- | |
| * checkout` through `checkout.ts`. | |
| */ | |
| export type CheckoutSuccessBannerState = 'pending' | 'active' | 'timeout'; | |
| /** | |
| * How long to wait for the post-checkout entitlement transition before | |
| * switching into the `timeout` state. Median webhook-to-entitlement | |
| * latency observed in prod is <5s (per 2026-04-18 incident analysis); | |
| * 30s covers the long tail without letting a genuinely stuck | |
| * activation hide behind a "still loading" banner. | |
| */ | |
| export const EXTENDED_UNLOCK_TIMEOUT_MS = 30_000; | |
| /** | |
| * Auto-dismiss window for the classic (non-waitForEntitlement) banner. | |
| * Informational-only usage where the panel unlock is already guaranteed. | |
| */ | |
| export const CLASSIC_AUTO_DISMISS_MS = 5_000; | |
| /** | |
| * Mask an email address for display in the success banner so the | |
| * full address isn't rendered in plaintext (privacy — a top-of- | |
| * viewport banner can be screen-shared, photographed, or recorded). | |
| * | |
| * Shape: first character of the local part + `***` + `@domain`. | |
| * Short local parts (1 char) still render safely: `a***@x.com`. | |
| * IDN / plus-addressing / dots in the local part pass through the | |
| * domain unchanged so the user can still recognize "yes, that's | |
| * where the receipt went." | |
| * | |
| * Returns null when the input isn't a minimally-valid email so | |
| * callers can fall back to the email-less banner copy rather than | |
| * render obviously-broken output. | |
| */ | |
| export function maskEmail(email: string | undefined | null): string | null { | |
| if (!email || typeof email !== 'string') return null; | |
| const trimmed = email.trim(); | |
| const atIndex = trimmed.indexOf('@'); | |
| // Require at least `a@b` — one char local, one char domain. | |
| if (atIndex < 1 || atIndex === trimmed.length - 1) return null; | |
| const local = trimmed.slice(0, atIndex); | |
| const domain = trimmed.slice(atIndex); | |
| const firstChar = local.charAt(0); | |
| return `${firstChar}***${domain}`; | |
| } | |