diff --git a/components/Rewards/NewRewards/JoinTierClubCard.tsx b/components/Rewards/NewRewards/JoinTierClubCard.tsx index 8673e38d..735cf3da 100644 --- a/components/Rewards/NewRewards/JoinTierClubCard.tsx +++ b/components/Rewards/NewRewards/JoinTierClubCard.tsx @@ -6,17 +6,33 @@ import { Text } from '@/components/ui/text'; import { getTierDisplayName } from '@/lib/tierNames'; import { RewardsTier } from '@/lib/types'; +import { BOTTOM_RIGHT_WASH } from './tierGradients'; + import type { TierUpgradeBenefit } from './UpgradeTier/tierUpgradeBenefits'; const CHEVRON_COLOR = 'rgba(255,255,255,0.4)'; -/** The tier's own tint, dissolved off the top-right corner. */ -const TIER_GRADIENT: Record = { - [RewardsTier.CORE]: ['rgba(255,255,255,0.06)', 'rgba(255,255,255,0)'], - [RewardsTier.PRIME]: ['rgba(148,242,127,0.16)', 'rgba(148,242,127,0)'], - [RewardsTier.ULTRA]: ['rgba(148,242,127,0.22)', 'rgba(148,242,127,0)'], +/** + * The tier's own tint, lit from the bottom-right and falling away to black. + * + * Deliberately stronger than it was. At 0.16 over `#1C1C1C` the brand green + * lands on rgb(47,62,44) — a couple of points off the card it sits on, which + * read as grey rather than as the tier's colour. A third of the way up it is + * unmistakably green and the white copy on top still clears contrast easily. + * + * The third stop is what makes the far corner black rather than merely + * untinted: the heading sits there, and black behind it is the most legible + * thing to put under white text. + */ +const TIER_GRADIENT: Record = { + [RewardsTier.CORE]: ['rgba(255,255,255,0.14)', 'rgba(255,255,255,0.04)', 'rgba(0,0,0,0.45)'], + [RewardsTier.PRIME]: ['rgba(148,242,127,0.30)', 'rgba(148,242,127,0.07)', 'rgba(0,0,0,0.45)'], + [RewardsTier.ULTRA]: ['rgba(148,242,127,0.36)', 'rgba(148,242,127,0.09)', 'rgba(0,0,0,0.45)'], }; +/** Tint held through the first stretch, then given over to the black. */ +const TIER_GRADIENT_STOPS = [0, 0.4, 1] as const; + interface JoinTierClubCardProps { tier: RewardsTier; /** The benefits to advertise, as chips. Shown in order; three fit a phone. */ @@ -49,8 +65,8 @@ const JoinTierClubCard = ({ tier, benefits, onPress }: JoinTierClubCardProps) => > diff --git a/components/Rewards/NewRewards/RewardsSummaryCard.tsx b/components/Rewards/NewRewards/RewardsSummaryCard.tsx index 40a0c625..2b37dccb 100644 --- a/components/Rewards/NewRewards/RewardsSummaryCard.tsx +++ b/components/Rewards/NewRewards/RewardsSummaryCard.tsx @@ -6,6 +6,7 @@ import { Text } from '@/components/ui/text'; import { formatBalanceUSD } from '@/lib/utils'; import CashbackDetailsSheet from './CashbackDetailsSheet'; +import { TOP_RIGHT_WASH } from './tierGradients'; import type { CashbackDetailsData } from './CashbackDetailsSheet.types'; @@ -94,8 +95,7 @@ const RewardsSummaryCard = ({ diff --git a/components/Rewards/NewRewards/TierMembershipSheetContent.tsx b/components/Rewards/NewRewards/TierMembershipSheetContent.tsx index f7ad693b..98170d35 100644 --- a/components/Rewards/NewRewards/TierMembershipSheetContent.tsx +++ b/components/Rewards/NewRewards/TierMembershipSheetContent.tsx @@ -33,7 +33,7 @@ interface TierMembershipSheetContentProps { * * Opened by tapping the tier anywhere it is shown, and deliberately readable * rather than promotional — this is the screen someone comes to when they want - * to know when their FUSE comes back or when they will next be charged, so + * to know when their soFUSE comes back or when they will next be charged, so * every line is a fact with a date on it. */ const TierMembershipSheetContent = ({ @@ -89,8 +89,10 @@ const TierMembershipSheetContent = ({ {lock && lock.lockedFuse > 0 ? ( + {/* soFUSE is what the lock holds; FUSE is what it is worth. Both are + on the row for the same reason the upgrade screen carries both. */} diff --git a/components/Rewards/NewRewards/TierStatsBand.tsx b/components/Rewards/NewRewards/TierStatsBand.tsx index 569d88fe..efba7829 100644 --- a/components/Rewards/NewRewards/TierStatsBand.tsx +++ b/components/Rewards/NewRewards/TierStatsBand.tsx @@ -5,6 +5,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Text } from '@/components/ui/text'; import { getAsset } from '@/lib/assets'; +import { TOP_RIGHT_WASH } from './tierGradients'; + /** Total height of the band. Its foot is tucked behind the perks card below. */ export const TIER_STATS_BAND_HEIGHT = 137; /** How much of that foot the perks card covers. */ @@ -12,6 +14,12 @@ export const TIER_STATS_BAND_TUCK = 41; /** The part that stays on screen — the stats sit here so nothing is tucked away. */ const VISIBLE_HEIGHT = TIER_STATS_BAND_HEIGHT - TIER_STATS_BAND_TUCK; +/** + * Green off the top-right corner, dissolving to the near-black screen at the + * bottom-left — the direction every v3 wash runs (see `tierGradients`). The + * band has no background of its own, so the faded end is the page showing + * through. + */ const BAND_GRADIENT = ['rgba(148, 242, 127, 0.28)', 'rgba(148, 242, 127, 0.05)'] as const; const DIVIDER_COLOR = 'rgba(255, 255, 255, 0.25)'; @@ -64,7 +72,7 @@ const TierStatsBand = ({ stats }: { stats: readonly TierStat[] }) => ( style={{ height: TIER_STATS_BAND_HEIGHT }} pointerEvents="none" > - + diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx index 27da880c..d9d06678 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx @@ -3,12 +3,9 @@ import { Pressable, StyleSheet, View } from 'react-native'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; -import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; +import { ROUTE_LABEL, upgradeRouteLabelClass } from './upgradeRouteLabel'; -const ROUTE_LABEL: Record = { - cash: 'Cash', - lock: 'Locked FUSE', -}; +import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; interface UpgradeRouteSwitchProps { routes: TierUpgradeRoute[]; @@ -21,7 +18,7 @@ interface UpgradeRouteSwitchProps { * * Renders whatever routes are actually on offer, which is why it takes a list * rather than a boolean: Prime is sold both ways and shows two segments, Ultra - * is FUSE-only and shows one full-width segment. A disabled second segment + * is soFUSE-only and shows one full-width segment. A disabled second segment * would advertise a way to buy Ultra that does not exist. * * Draws nothing at all for a single route on a tier that has no alternative — @@ -48,15 +45,13 @@ const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchPr isSelected && 'bg-white', )} > - {/* Styled entirely through StyleSheet, with no className at all. - `Text` composes its own class with whatever the surrounding text - context provides, and a selected label whose colour is lost in - that merge is black-on-white turning white-on-white — an empty - pill, which is how this shipped. An inline style cannot be - merged away, and the weight goes with it so nothing about this - label depends on class resolution. Matches how TierUpgradeCard - and TierSwitcher write their labels. */} - + {/* Metrics and weight in the style, colour in both layers — see + `upgradeRouteLabelClass` for why the class is not optional. The + same shape TierSwitcher uses for the tier tabs beside this. */} + {ROUTE_LABEL[route]} diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx index 91f448a4..b400a4a6 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx @@ -7,6 +7,7 @@ import { SubscriptionIcon, YieldBoostIcon, } from '@/components/Rewards/NewRewards/tierBenefitIcons'; +import { TOP_RIGHT_WASH } from '@/components/Rewards/NewRewards/tierGradients'; import TierStar from '@/components/Rewards/NewRewards/TierHero/TierStar'; import { Text } from '@/components/ui/text'; import { getTierDisplayName } from '@/lib/tierNames'; @@ -23,13 +24,15 @@ import type { TierUpgradeBenefit } from './tierUpgradeBenefits'; * copy of the same words, half-covered. * * The design asks for a plain grey gradient, so it is a gradient — two stops, - * top-left to bottom-right, lifting slightly for the higher tiers so Prime and - * Ultra still read as distinct without spelling anything out. + * grey at the top-right corner falling to near-black at the bottom-left, which + * is the direction every v3 wash runs (see `tierGradients`). It lifts slightly + * for the higher tiers so Prime and Ultra still read as distinct without + * spelling anything out. */ const TIER_BACKDROP: Record = { - [RewardsTier.CORE]: ['#2A2A2A', '#1C1C1C'], - [RewardsTier.PRIME]: ['#3A3A3A', '#1F1F1F'], - [RewardsTier.ULTRA]: ['#454545', '#212121'], + [RewardsTier.CORE]: ['#3A3A3A', '#141414'], + [RewardsTier.PRIME]: ['#454545', '#151515'], + [RewardsTier.ULTRA]: ['#505050', '#161616'], }; /** The benefit glyphs, at the 33px the tier card draws them. */ @@ -47,8 +50,7 @@ const UpgradeTierHeroCard = ({ tier, benefits, statusLabel }: UpgradeTierHeroCar diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx index c788858c..19e37b24 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx @@ -125,7 +125,7 @@ export default function UpgradeTierReviewScreen() { // never have offered this, so reaching it means the offer changed under // the user between screens — say so rather than charging them nothing. if (offer.annualFeeUsd === null) { - throw new Error('This tier cannot be bought with an annual fee. Lock FUSE to hold it.'); + throw new Error('This tier cannot be bought with an annual fee. Lock soFUSE to hold it.'); } track(TRACKING_EVENTS.TIER_SUBSCRIBE_PRESSED, { tier, price_usd: offer.annualFeeUsd }); @@ -152,8 +152,11 @@ export default function UpgradeTierReviewScreen() { {route === 'lock' ? ( <> + {/* soFUSE in the label, FUSE in the value — the lock takes the + Savings position, which is denominated in the FUSE it is + worth. Same wording as the row on the screen before this. */} @@ -175,7 +178,7 @@ export default function UpgradeTierReviewScreen() { {route === 'lock' - ? `Your FUSE will be unlocked automatically ${formatLockDuration( + ? `Your soFUSE will be unlocked automatically ${formatLockDuration( membership.lock.durationDays, )} from now, and keeps earning until then.` : 'Your membership renews once a year. Cancel any time — you keep the tier to the end of the period you have paid for.'} diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx index 5e817594..a21a8bf7 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx @@ -272,8 +272,13 @@ export default function UpgradeTierScreen() { ) : ( <> + {/* Labelled soFUSE, priced in FUSE. What the lock takes is the + Savings position — soFUSE shares — while the tier threshold + and every figure on this screen are denominated in the FUSE + those shares are worth. Saying only "FUSE" sent people looking + for native FUSE in their wallet. */} @@ -283,7 +288,10 @@ export default function UpgradeTierScreen() { onExplain={() => void Linking.openURL(MEMBERSHIP_HELP_URL)} withDivider /> - + )} @@ -291,7 +299,7 @@ export default function UpgradeTierScreen() { {route === 'cash' ? `Upgrade to the ${offer.tier === RewardsTier.ULTRA ? 'Ultra' : 'Prime'} tier with\nan annual fee. ` - : `Lock FUSE for ${formatLockDuration(membership.lock.durationDays)} to hold the tier — it keeps earning while it is locked. `} + : `Locks soFUSE from your Savings — not native FUSE — for ${formatLockDuration(membership.lock.durationDays)} to hold the tier. It keeps earning while it is locked. `} void Linking.openURL(MEMBERSHIP_HELP_URL)} diff --git a/components/Rewards/NewRewards/UpgradeTier/__tests__/upgradeRouteSwitch.test.ts b/components/Rewards/NewRewards/UpgradeTier/__tests__/upgradeRouteSwitch.test.ts new file mode 100644 index 00000000..0d868520 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/__tests__/upgradeRouteSwitch.test.ts @@ -0,0 +1,52 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +import { + ROUTE_LABEL, + upgradeRouteLabelClass, +} from '@/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel'; + +/** Exactly what `components/ui/text` puts in front of every label's own class. */ +const TEXT_BASE = 'text-foreground web:select-text'; + +/** + * The class list that actually reaches the element. + * + * `twMerge(clsx(…))` is the body of `cn` — inlined rather than imported because + * `@/lib/utils` reaches the whole wagmi/viem stack and this is a string test. + */ +const resolved = (labelClass: string) => twMerge(clsx([TEXT_BASE, labelClass])); + +describe('upgradeRouteLabelClass', () => { + it('paints the selected label black, for the white pill it sits on', () => { + expect(resolved(upgradeRouteLabelClass(true))).toContain('text-black'); + }); + + it('paints the unselected label white, for the dark track it sits on', () => { + expect(resolved(upgradeRouteLabelClass(false))).toContain('text-white'); + }); + + /** + * The bug, three times over. + * + * `text-foreground` is white and `ui/text` prepends it to every label. It + * survives the merge unless the label brings a colour of its own — and it + * beats a colour set through the `style` prop on web, because + * react-native-web inserts its stylesheet as `head.firstChild`, below the + * Tailwind sheet. A selected label that leaves it standing is white on white. + */ + it('leaves no inherited white for the selected label to lose to', () => { + expect(resolved(upgradeRouteLabelClass(true))).not.toContain('text-foreground'); + }); + + it('is what a style-only label could not do — the shape that shipped white', () => { + expect(resolved('text-[16px] leading-5 font-semibold')).toContain('text-foreground'); + }); +}); + +describe('ROUTE_LABEL', () => { + /** The lock takes soFUSE shares from Savings, never native FUSE. */ + it('names the token the lock actually takes', () => { + expect(ROUTE_LABEL.lock).toBe('Locked soFUSE'); + }); +}); diff --git a/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts b/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts new file mode 100644 index 00000000..2d717036 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts @@ -0,0 +1,29 @@ +import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; + +/** What each payment route is called on the switch. */ +export const ROUTE_LABEL: Record = { + cash: 'Cash', + // soFUSE, not FUSE: what gets locked is the Savings position, and a tab that + // says "FUSE" sends people looking for native FUSE in their wallet. + lock: 'Locked soFUSE', +}; + +/** + * The colour class for a segment label — its own module because this one class + * is the whole bug the switch has now been fixed for three times, and because a + * test for it should not have to mount a component to read it. + * + * `ui/text` renders `cn('text-foreground web:select-text', …, className, …)`, + * and `text-foreground` is white. tailwind-merge drops it only when the label + * brings a text colour of its own, so a label styled purely through `style` + * leaves the class list as literally `text-foreground web:select-text`. On web + * that class then wins outright, because react-native-web inserts its own + * stylesheet as `head.firstChild` — below the Tailwind sheet — so a Tailwind + * colour beats the atomic class generated from the `style` prop. Black on white + * came out white on white: an empty pill. + * + * The colour is therefore declared in both layers, saying the same thing. + * Whichever one the platform resolves last, the label is legible. + */ +export const upgradeRouteLabelClass = (isSelected: boolean) => + isSelected ? 'text-black' : 'text-white'; diff --git a/components/Rewards/NewRewards/tierGradients.ts b/components/Rewards/NewRewards/tierGradients.ts new file mode 100644 index 00000000..31a9bee4 --- /dev/null +++ b/components/Rewards/NewRewards/tierGradients.ts @@ -0,0 +1,26 @@ +/** + * Which way a rewards v3 wash runs. + * + * One rule, stated once, because three cards had drifted onto three diagonals: + * a v3 gradient starts at the **top-right** corner, where the tint is, and ends + * **bottom-left**, where it goes black. + * + * The "Join … Club" banner is the deliberate exception and runs the other + * diagonal — bottom-right to top-left — which puts its heading on the dark + * corner and the tint under the benefit chips. + * + * Spread these into `LinearGradient` rather than writing the points inline: + * `start={{ x: 1, y: 0 }}` and `start={{ x: 0, y: 0 }}` differ by one character + * and by ninety degrees, which is exactly how the upgrade card ended up lit + * from the wrong side. + */ +export const TOP_RIGHT_WASH = { + start: { x: 1, y: 0 }, + end: { x: 0, y: 1 }, +} as const; + +/** The "Join … Club" banner's exception: bottom-right to top-left. */ +export const BOTTOM_RIGHT_WASH = { + start: { x: 1, y: 1 }, + end: { x: 0, y: 0 }, +} as const; diff --git a/components/Rewards/RewardsUpgradeFeedback.tsx b/components/Rewards/RewardsUpgradeFeedback.tsx index de8e4568..f8ea7923 100644 --- a/components/Rewards/RewardsUpgradeFeedback.tsx +++ b/components/Rewards/RewardsUpgradeFeedback.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { router } from 'expo-router'; +import { router, usePathname } from 'expo-router'; import TierPopup from '@/components/Rewards/NewRewards/TierPopup'; import { @@ -9,7 +9,7 @@ import { } from '@/components/Rewards/NewRewards/tierTrialCopy'; import { path } from '@/constants/path'; import { useRewardsUserData, useTierBenefits } from '@/hooks/useRewards'; -import { REWARDS_RECONCILIATION_INTERVAL_MS } from '@/lib/rewardsUpgrade'; +import { isUpgradeFlowRoute, REWARDS_RECONCILIATION_INTERVAL_MS } from '@/lib/rewardsUpgrade'; import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; @@ -26,8 +26,14 @@ import { useUserStore } from '@/store/useUserStore'; * * The words change with the route (see `upgradeCelebrationCopy`); the card does * not (Figma 25480:2355). + * + * The one place it does not draw is the upgrade flow itself — see + * `isUpgradeFlowRoute`. Being mounted on the layout means it can land on the + * review screen, which is where it announced a tier over a purchase the user + * had not made yet. */ export default function RewardsUpgradeFeedback() { + const pathname = usePathname(); const userId = useUserStore(state => state.users.find(user => user.selected)?.userId); const state = useRewardsUpgradeStore(); const active = userId === state.userId; @@ -52,7 +58,10 @@ export default function RewardsUpgradeFeedback() { if (success) setShown(success); }, [success]); - if (!shown) return null; + // Held, not dropped: `success` survives in the store, so leaving the upgrade + // flow — which is `router.replace(path.REWARDS)` on a completed purchase — + // shows the card on Rewards, where it was always meant to appear. + if (!shown || isUpgradeFlowRoute(pathname)) return null; const copy = upgradeCelebrationCopy(shown); diff --git a/hooks/__tests__/useRewards.test.tsx b/hooks/__tests__/useRewards.test.tsx index a55a85d5..59309f69 100644 --- a/hooks/__tests__/useRewards.test.tsx +++ b/hooks/__tests__/useRewards.test.tsx @@ -5,7 +5,10 @@ import RewardsUpgradeFeedback from '@/components/Rewards/RewardsUpgradeFeedback' import { useRewardsUserData } from '@/hooks/useRewards'; import { fetchRewardsUserData } from '@/lib/api'; import { RewardsTier, RewardsUserData } from '@/lib/types'; -import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; +import { + REWARDS_UPGRADE_CLEARED_STATE, + useRewardsUpgradeStore, +} from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; // react-test-renderer is supplied by jest-expo without bundled declarations. // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -48,10 +51,7 @@ beforeEach(() => { useRewardsUpgradeStore.setState({ userId: 'a', session: 0, - confirmed: undefined, - success: undefined, - pendingUntil: undefined, - timedOut: false, + ...REWARDS_UPGRADE_CLEARED_STATE, }); client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); }); diff --git a/hooks/useTierMembership.ts b/hooks/useTierMembership.ts index 425609dc..63b39bc6 100644 --- a/hooks/useTierMembership.ts +++ b/hooks/useTierMembership.ts @@ -226,7 +226,10 @@ const useInvalidateAfterUpgrade = () => { }; /** - * Lock FUSE to hold a tier. + * Lock soFUSE to hold a tier. + * + * soFUSE, not native FUSE: the lock takes the Savings position's shares, and + * every figure the screens quote is the FUSE those shares are worth. * * Two calls in one user operation: approve the shares to the lock, then lock * them. Batched so the user signs once and so neither half can land without the @@ -290,7 +293,7 @@ export const useLockFuseForTier = () => { value: 0n, }, ], - 'Failed to lock your FUSE', + 'Failed to lock your soFUSE', fuse, ); @@ -317,7 +320,7 @@ export const useLockFuseForTier = () => { }); }, onError: (mutationError: Error) => { - const message = mutationError?.message || 'Failed to lock your FUSE'; + const message = mutationError?.message || 'Failed to lock your soFUSE'; setError(message); track(TRACKING_EVENTS.TIER_LOCK_FAILED, { error: message }); }, diff --git a/lib/__tests__/rewardsUpgrade.test.ts b/lib/__tests__/rewardsUpgrade.test.ts index 8d56f22e..1ac8af90 100644 --- a/lib/__tests__/rewardsUpgrade.test.ts +++ b/lib/__tests__/rewardsUpgrade.test.ts @@ -1,4 +1,4 @@ -import { getTierAction, isHigherTier } from '@/lib/rewardsUpgrade'; +import { getTierAction, isHigherTier, isUpgradeFlowRoute } from '@/lib/rewardsUpgrade'; import { RewardsTier } from '@/lib/types'; const { CORE, PRIME, ULTRA } = RewardsTier; @@ -23,3 +23,24 @@ describe('tier actions', () => { expect(isHigherTier(PRIME, undefined)).toBe(false); }); }); + +describe('isUpgradeFlowRoute', () => { + it('covers both screens that buy a tier, trailing slash or not', () => { + expect(isUpgradeFlowRoute('/rewards/upgrade')).toBe(true); + expect(isUpgradeFlowRoute('/rewards/upgrade-review')).toBe(true); + expect(isUpgradeFlowRoute('/rewards/upgrade/')).toBe(true); + }); + + /** Where the celebration is supposed to land. */ + it('leaves the rewards screen itself alone', () => { + expect(isUpgradeFlowRoute('/rewards')).toBe(false); + expect(isUpgradeFlowRoute('/rewards/benefits')).toBe(false); + }); + + it('draws everywhere else, including before the router has a path', () => { + expect(isUpgradeFlowRoute('/')).toBe(false); + expect(isUpgradeFlowRoute('/savings')).toBe(false); + expect(isUpgradeFlowRoute(undefined)).toBe(false); + expect(isUpgradeFlowRoute(null)).toBe(false); + }); +}); diff --git a/lib/rewardsUpgrade.ts b/lib/rewardsUpgrade.ts index adf646ef..e1f2103f 100644 --- a/lib/rewardsUpgrade.ts +++ b/lib/rewardsUpgrade.ts @@ -18,3 +18,22 @@ export const getTierAction = ( // Runs beyond the backend's 60-second soFUSE balance cache, then stops. export const REWARDS_RECONCILIATION_MS = 90_000; export const REWARDS_RECONCILIATION_INTERVAL_MS = 5_000; + +/** The screens that buy a tier, as `usePathname` reports them. */ +const UPGRADE_FLOW_ROUTES = ['/rewards/upgrade', '/rewards/upgrade-review']; + +/** + * Whether the user is part-way through buying a tier. + * + * The upgrade celebration is mounted on the protected layout, so it draws over + * whatever screen is up — including the review screen, where it announced a + * tier while the user was still deciding whether to sign for it. The upgrade + * flow is the one place it must never appear: either nothing has been bought + * yet, or the purchase is mid-flight and `router.replace` is about to put the + * user on Rewards, which is where the card belongs. + * + * Suppression, not cancellation. `success` stays in the store until it is + * dismissed, so the card shows the moment the flow is left. + */ +export const isUpgradeFlowRoute = (pathname?: string | null) => + !!pathname && UPGRADE_FLOW_ROUTES.includes(pathname.replace(/\/+$/, '')); diff --git a/store/__tests__/useRewardsUpgradeStore.test.ts b/store/__tests__/useRewardsUpgradeStore.test.ts index 217a21bc..76a5b796 100644 --- a/store/__tests__/useRewardsUpgradeStore.test.ts +++ b/store/__tests__/useRewardsUpgradeStore.test.ts @@ -1,6 +1,9 @@ import { REWARDS_RECONCILIATION_MS } from '@/lib/rewardsUpgrade'; import { RewardsTier, RewardsUserData } from '@/lib/types'; -import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; +import { + REWARDS_UPGRADE_CLEARED_STATE, + useRewardsUpgradeStore, +} from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; jest.mock('@/store/useUserStore', () => { @@ -18,14 +21,7 @@ const observe = (tier: RewardsTier) => beforeEach(() => { jest.useFakeTimers(); - store.setState({ - userId: 'a', - session: 0, - confirmed: undefined, - success: undefined, - pendingUntil: undefined, - timedOut: false, - }); + store.setState({ userId: 'a', session: 0, ...REWARDS_UPGRADE_CLEARED_STATE }); }); afterEach(() => jest.useRealTimers()); @@ -90,6 +86,29 @@ it('does not celebrate a downgrade', () => { expect(store.getState().success).toBeUndefined(); }); +/** + * The popup on the review screen, over a purchase nobody had made. + * + * The backend re-derives the tier from a lock, a subscription row and a soFUSE + * balance it caches for a minute, so a read taken mid-reconciliation can come + * back a tier low and the next one put it back. Measured against the last tier + * seen, that recovery is indistinguishable from an upgrade. + */ +it('does not celebrate a tier that merely comes back after a dip', () => { + observe(RewardsTier.PRIME); + observe(RewardsTier.CORE); + observe(RewardsTier.PRIME); + expect(store.getState().success).toBeUndefined(); +}); + +it('still celebrates a real upgrade taken after such a dip', () => { + observe(RewardsTier.PRIME); + observe(RewardsTier.CORE); + observe(RewardsTier.PRIME); + observe(RewardsTier.ULTRA); + expect(store.getState().success?.currentTier).toBe(RewardsTier.ULTRA); +}); + it('polls an ambiguous balance event quietly without blocking a wallet-funded upgrade', () => { store.setState({ savingsConfirmed: false }); store.getState().savingsChanged('a', false); diff --git a/store/useRewardsUpgradeStore.ts b/store/useRewardsUpgradeStore.ts index eac4f74d..29cd1136 100644 --- a/store/useRewardsUpgradeStore.ts +++ b/store/useRewardsUpgradeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { isHigherTier, REWARDS_RECONCILIATION_MS } from '@/lib/rewardsUpgrade'; -import { RewardsUserData } from '@/lib/types'; +import { RewardsTier, RewardsUserData } from '@/lib/types'; import { useUserStore } from '@/store/useUserStore'; export const selectedRewardsUserId = () => @@ -11,6 +11,19 @@ interface RewardsUpgradeState { userId?: string; session: number; confirmed?: RewardsUserData; + /** + * The highest tier seen this session, which is what a promotion is measured + * against. + * + * Not `confirmed.currentTier`. The backend re-derives the tier from a lock, a + * subscription row and a soFUSE balance it caches for a minute, so a read + * taken mid-reconciliation can come back a tier low and the next one put it + * back — and against the *last* tier that recovery reads as a promotion. That + * is the "You're on Prime now!" card appearing over a screen where nothing + * was bought. Against the *highest* tier, it reads as what it is: nothing + * happened. + */ + peak?: RewardsTier; success?: RewardsUserData; pendingUntil?: number; savingsConfirmed: boolean; @@ -22,6 +35,22 @@ interface RewardsUpgradeState { dismiss: () => void; } +/** + * Everything an account switch discards, in one place. + * + * Exported so a test setting up a fresh account can spread it rather than list + * the fields: a field added here and missed there leaks between tests, and a + * leaked `peak` is a promotion that silently stops being observed. + */ +export const REWARDS_UPGRADE_CLEARED_STATE = { + confirmed: undefined, + peak: undefined, + success: undefined, + pendingUntil: undefined, + timedOut: false, + savingsConfirmed: false, +} as const; + // Transient and global: one popup per observed promotion, even when several // screens consume the rewards query. Account switches discard the baseline. export const useRewardsUpgradeStore = create((set, get) => ({ @@ -31,22 +60,15 @@ export const useRewardsUpgradeStore = create((set, get) => savingsConfirmed: false, selectAccount: userId => { if (userId === get().userId) return; - set({ - userId, - session: get().session + 1, - confirmed: undefined, - success: undefined, - pendingUntil: undefined, - timedOut: false, - savingsConfirmed: false, - }); + set({ userId, session: get().session + 1, ...REWARDS_UPGRADE_CLEARED_STATE }); }, observe: (userId, session, data) => { const state = get(); if (state.userId !== userId || state.session !== session) return; - const promoted = isHigherTier(data.currentTier, state.confirmed?.currentTier); + const promoted = isHigherTier(data.currentTier, state.peak); set({ confirmed: data, + peak: promoted ? data.currentTier : (state.peak ?? data.currentTier), success: promoted ? data : state.success?.currentTier === data.currentTier