From c4471587b6ff69241b0daa661222b12e49d709dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:10:02 +0000 Subject: [PATCH 1/3] fix(rewards): make the route switch label legible, and name soFUSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a user reading the upgrade screen could not get right. The selected segment's label was white on the white pill, for the third time. The colour was in the `style` prop, which cannot win: `ui/text` prepends `text-foreground` to every label, tailwind-merge only drops it when the label brings a colour class of its own, and on web a Tailwind colour beats the `style` prop outright because react-native-web inserts its stylesheet as `head.firstChild`, below the Tailwind sheet. The class list that shipped was literally `text-foreground web:select-text`. The colour is now declared in both layers, saying the same thing, and the class lives in its own module with a test that asserts the base colour is merged away — the thing that was actually broken. The lock takes soFUSE, the Savings position, not native FUSE. Every screen said "FUSE", which sends people looking in their wallet for a token the lock never touches. The rows now name soFUSE in the label and keep the FUSE the shares are worth in the value — the tier threshold is denominated in FUSE, so both belong on the row — and the prose says plainly that it is soFUSE from Savings and not native FUSE. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- .../NewRewards/TierMembershipSheetContent.tsx | 6 ++- .../UpgradeTier/UpgradeRouteSwitch.tsx | 25 ++++----- .../UpgradeTier/UpgradeTierReviewScreen.tsx | 9 ++-- .../UpgradeTier/UpgradeTierScreen.tsx | 14 +++-- .../__tests__/upgradeRouteSwitch.test.ts | 52 +++++++++++++++++++ .../UpgradeTier/upgradeRouteLabel.ts | 29 +++++++++++ hooks/useTierMembership.ts | 9 ++-- 7 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 components/Rewards/NewRewards/UpgradeTier/__tests__/upgradeRouteSwitch.test.ts create mode 100644 components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts 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/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/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/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 }); }, From 5c998ed305f40f0b2d584743c21d9fc67285ebeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:10:11 +0000 Subject: [PATCH 2/3] fix(rewards): keep the tier celebration off the upgrade flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "You're on Prime now!" appeared over the review screen, on a purchase nobody had made yet. Two things put it there. The celebration is mounted on the protected layout — deliberately, because a tier can arrive from a points milestone, a savings deposit or a trial, and all of them should get the same card — so it draws over whatever screen is up, review screen included. And it measured a promotion against the *last* tier seen, while the backend re-derives the tier from a lock, a subscription row and a soFUSE balance it caches for a minute: a read taken mid-reconciliation can come back a tier low and the next one put it back, which against the last tier is indistinguishable from an upgrade. So it now measures against the highest tier seen this session, where a recovery reads as what it is, and it does not draw at all on the two screens that buy a tier. Suppressed, not cancelled: the success survives in the store, so the card shows the moment the completed purchase replaces the route with Rewards — which is where it was always meant to appear. The fields an account switch clears are exported as one object, because a new field missed in a test's setup leaks between tests, and a leaked high-water mark is a promotion that silently stops being observed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- components/Rewards/RewardsUpgradeFeedback.tsx | 15 +++++-- hooks/__tests__/useRewards.test.tsx | 10 ++--- lib/__tests__/rewardsUpgrade.test.ts | 23 +++++++++- lib/rewardsUpgrade.ts | 19 ++++++++ .../__tests__/useRewardsUpgradeStore.test.ts | 37 ++++++++++++---- store/useRewardsUpgradeStore.ts | 44 ++++++++++++++----- 6 files changed, 119 insertions(+), 29 deletions(-) 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/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 From f50fa7a09a8c3657db0973641bd47fef6641e5ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:10:20 +0000 Subject: [PATCH 3/3] fix(rewards): one direction for the v3 gradients, and green on the club card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 washes had drifted onto three different diagonals. They run one way now, stated once in `tierGradients` and spread into each gradient: from the top-right corner, where the tint is, to the bottom-left, where it goes black. The upgrade card's backdrop was lit from the opposite corner and now is not; its far end is near-black rather than the grey it faded to. "Join Prime Club" is the deliberate exception and runs the other diagonal, bottom-right to top-left, which puts the heading on the dark corner and the tint under the benefit chips. Its green was also too faint to be green: at 0.16 over #1C1C1C the brand colour lands on rgb(47,62,44), a couple of points off the card it sits on, which reads as grey. It is now unmistakably the tier's colour, with a black stop behind the heading. The benefits screen's two scroll fades are left vertical — they dim content off the top and bottom edges, and a fade only works along the edge it belongs to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- .../Rewards/NewRewards/JoinTierClubCard.tsx | 30 ++++++++++++++----- .../Rewards/NewRewards/RewardsSummaryCard.tsx | 4 +-- .../Rewards/NewRewards/TierStatsBand.tsx | 10 ++++++- .../UpgradeTier/UpgradeTierHeroCard.tsx | 16 +++++----- .../Rewards/NewRewards/tierGradients.ts | 26 ++++++++++++++++ 5 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 components/Rewards/NewRewards/tierGradients.ts 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/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/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/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;