From 00860d5dfb1f3b837ae819b1220a820bdf6e0676 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 12:37:22 +0000 Subject: [PATCH 1/2] fix(rewards): eight defects on the v3 upgrade and rewards screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from QA against the Figma designs. - The rewards screen still offered v2's card — a points bar climbing to a tier points no longer grant, and a "skip the line" FUSE deposit that is now a priced year-long lock. Once the backend says points do not unlock tiers, it is replaced by JoinTierClubCard, which teases the tier and opens the upgrade screen. Both halves of the condition matter: without the first it duplicates v2's card, without a purchasable tier it invites an Ultra member to join a club they are in. - "Top up" on the cash route pushed to /deposit, dropping the user out of a decision they were mid-way through. It now opens the deposit chooser over the screen, which is the drawer the rest of the app uses. - A Core user could only ever be shown Prime. The upgrade screen now carries the same TierSwitcher the benefits page uses, listing the tiers actually on offer — so it collapses to nothing as the user climbs, since a one-tab switch is a label. - The selected route label was white on white: `Text` merges its class with the surrounding text context, and the merge lost `text-black`. The colour is an inline style now, which cannot be merged away — the same way every other black-on-light label in these screens is written. - The hero card's backdrop was `rewards-tiers/*-summary.png`, which are the finished marketing cards with "+2% Yield boost" set into the artwork. Behind this card's own benefit list they showed as a second, larger, half-covered copy of the same words. Replaced with the grey gradient the design asks for. - Prime's star carries a -5px nudge measured against the 235px hero, where it is 2% of the height. Beside a 28px heading it was 18% — a glyph adrift from its own text. The nudge now scales with the size it is drawn at, with a test pinning that it can never exceed the measured 5px. - The Rewards summary card had no gradient. Added the design's green wash off the top-right, faded out before it reaches the figures. - The annual-fee sentence breaks where the design breaks it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- .../Rewards/NewRewards/JoinTierClubCard.tsx | 88 +++++++++++++++++++ .../Rewards/NewRewards/RewardsScreenNew.tsx | 47 ++++++++-- .../Rewards/NewRewards/RewardsSummaryCard.tsx | 25 +++++- .../Rewards/NewRewards/TierHero/TierStar.tsx | 2 +- .../TierHero/__tests__/starLayout.test.ts | 40 +++++++++ .../Rewards/NewRewards/TierHero/starLayout.ts | 16 +++- .../UpgradeTier/UpgradeRouteSwitch.tsx | 19 ++-- .../UpgradeTier/UpgradeTierHeroCard.tsx | 40 +++++---- .../UpgradeTier/UpgradeTierScreen.tsx | 66 ++++++++++++-- constants/tracking-events.ts | 1 + 10 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 components/Rewards/NewRewards/JoinTierClubCard.tsx create mode 100644 components/Rewards/NewRewards/TierHero/__tests__/starLayout.test.ts diff --git a/components/Rewards/NewRewards/JoinTierClubCard.tsx b/components/Rewards/NewRewards/JoinTierClubCard.tsx new file mode 100644 index 00000000..8673e38d --- /dev/null +++ b/components/Rewards/NewRewards/JoinTierClubCard.tsx @@ -0,0 +1,88 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { ChevronRight } from 'lucide-react-native'; + +import { Text } from '@/components/ui/text'; +import { getTierDisplayName } from '@/lib/tierNames'; +import { RewardsTier } from '@/lib/types'; + +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)'], +}; + +interface JoinTierClubCardProps { + tier: RewardsTier; + /** The benefits to advertise, as chips. Shown in order; three fit a phone. */ + benefits: TierUpgradeBenefit[]; + onPress: () => void; +} + +/** + * "Join Prime Club" — the v3 route to the next tier. + * + * Replaces `TierUpgradeCard`, which offers two things v3 no longer sells: a + * points bar climbing to a tier points no longer unlock, and a "skip the line" + * FUSE deposit that is now a year-long lock with a price on it. Leaving that + * card up while `pointsUnlockEnabled` is false advertises a route the backend + * will refuse. + * + * Deliberately a teaser and not a form: the decision has a price, a term and a + * choice of how to pay, and all of that belongs on the upgrade screen this + * opens rather than in a card on a page about something else. + */ +const JoinTierClubCard = ({ tier, benefits, onPress }: JoinTierClubCardProps) => { + const tierName = getTierDisplayName(tier); + + return ( + + + + + + + + Join {tierName} Club + + + Unlock extra cashback and benefits + + + + + + + {benefits.length > 0 ? ( + + {benefits.map(benefit => ( + + + {benefit.label} + + + ))} + + ) : null} + + + ); +}; + +export default JoinTierClubCard; diff --git a/components/Rewards/NewRewards/RewardsScreenNew.tsx b/components/Rewards/NewRewards/RewardsScreenNew.tsx index bfadf122..d4f3ca7c 100644 --- a/components/Rewards/NewRewards/RewardsScreenNew.tsx +++ b/components/Rewards/NewRewards/RewardsScreenNew.tsx @@ -14,7 +14,12 @@ import { SPIN_WIN_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { SPIN_WIN } from '@/constants/spinWinDesign'; import { cardDetailsQueryOptions } from '@/hooks/cardDetailsQueryOptions'; -import { useOptInToRewards, useReferralSummary, useRewardsUserData } from '@/hooks/useRewards'; +import { + useOptInToRewards, + useReferralSummary, + useRewardsUserData, + useTierBenefits, +} from '@/hooks/useRewards'; import { useSpinStatus } from '@/hooks/useSpinWin'; import { useTierMembership } from '@/hooks/useTierMembership'; import { monthlyCashbackTotal } from '@/lib/cashbackProgress'; @@ -27,6 +32,7 @@ import { useRewardsWelcomePopupStore } from '@/store/useRewardsWelcomePopupStore import { useSpinWinModalStore } from '@/store/useSpinWinModalStore'; import { useUserStore } from '@/store/useUserStore'; +import JoinTierClubCard from './JoinTierClubCard'; import PointsHeadline from './PointsHeadline'; import RewardsHelpModal from './RewardsHelpModal'; import RewardsSummaryCard from './RewardsSummaryCard'; @@ -36,6 +42,7 @@ import TierBenefitsGrid from './TierBenefitsGrid'; import TierMembershipSheet from './TierMembershipSheet'; import TierTrialPill from './TierTrialPill'; import TierUpgradeCard from './TierUpgradeCard'; +import { findTierBenefits, resolveTierUpgradeBenefits } from './UpgradeTier/tierUpgradeBenefits'; /** * Redesigned rewards screen (Apple "glass" style), shown only on qa/preview @@ -55,6 +62,7 @@ export default function RewardsScreenNew() { const { data: cardDetails } = useQuery(cardDetailsQueryOptions(selectedUserId)); const { data: spinStatus } = useSpinStatus(); const { data: membership } = useTierMembership(); + const { data: tierBenefits } = useTierBenefits(); const openSpinWinModal = useSpinWinModalStore(state => state.setModal); const { mutate: joinRewards, isPending: isJoining } = useOptInToRewards(); const hasCompletedIntro = useRewardsIntroStore( @@ -146,6 +154,21 @@ export default function RewardsScreenNew() { allowFallback: isDevFeatureEnabled, }); + /** + * The membership teaser, and the tier it points at. + * + * Shown only once the backend says points no longer unlock tiers AND there is + * something to sell — a tier on offer that the user does not already hold. + * Both halves matter: without the first this would duplicate v2's card, and + * without the second it would invite an Ultra member to join a club they are + * already in. + */ + const joinClubTier = nextPurchasableTier(membership); + const showJoinClubCard = Boolean(membership && !membership.pointsUnlockEnabled && joinClubTier); + const joinClubBenefits = joinClubTier + ? resolveTierUpgradeBenefits(findTierBenefits(tierBenefits, joinClubTier)).slice(0, 3) + : []; + if (rewardsLocked) { return ( @@ -295,9 +318,23 @@ export default function RewardsScreenNew() { /> - {/* The compact card presents both routes to the next tier: normal points - progress and the optional FUSE shortcut configured by the backend. */} - {showTierUpgradeCard && ( + {/* Two cards for the same job, and which one shows is decided by + whether points still unlock a tier. + + v2's card offers a points bar and a FUSE "shortcut". Under v3 the + first of those climbs toward something points no longer grant, and + the second is a year-long lock with a price — so once + `pointsUnlockEnabled` is off, that card advertises a route the + backend refuses, and the membership teaser takes its place. */} + {showJoinClubCard && joinClubTier ? ( + + handleUpgradeTier(joinClubTier)} + /> + + ) : showTierUpgradeCard ? ( handleUpgradeTier(nextTier)} /> - )} + ) : null} { return ( + {/* The design's wash off the top-right corner, in the same green as the + card's own glyph. Drawn corner-to-corner and faded out well before the + middle, so it is a tint on the header rather than a band across the + figures — those have to stay legible, and a gradient that reaches them + is the version that makes them harder to read. */} + + { + it('only nudges Prime — the other two stars are centred in their own canvas', () => { + expect(tierStarOffset(RewardsTier.CORE)).toBeUndefined(); + expect(tierStarOffset(RewardsTier.ULTRA)).toBeUndefined(); + expect(tierStarOffset(RewardsTier.PRIME)).toBeDefined(); + }); + + it('nudges the hero star by the measured 5px', () => { + const [{ translateY }] = tierStarOffset(RewardsTier.PRIME)!; + expect(translateY).toBeCloseTo(-5, 5); + }); + + /** + * The bug this guards. The -5 was measured against the 235px hero, where it + * is 2% of the height. Applied flat to the 28px star beside a heading it is + * 18%, which is a glyph visibly adrift from its own text — which is exactly + * how it shipped on the upgrade screen. + */ + it('scales the nudge with the size the star is drawn at', () => { + const [{ translateY }] = tierStarOffset(RewardsTier.PRIME, 28)!; + + expect(translateY).toBeCloseTo((-5 * 28) / TIER_STAR_SIZES[RewardsTier.PRIME], 5); + // Under a third of a pixel at this size: present, and not something the eye + // can pick out as misalignment. + expect(Math.abs(translateY)).toBeLessThan(1); + }); + + it('never moves a star further than the measured nudge', () => { + for (const size of [8, 28, 64, 120, TIER_STAR_SIZES[RewardsTier.PRIME]]) { + const [{ translateY }] = tierStarOffset(RewardsTier.PRIME, size)!; + expect(Math.abs(translateY)).toBeLessThanOrEqual(5); + } + }); +}); diff --git a/components/Rewards/NewRewards/TierHero/starLayout.ts b/components/Rewards/NewRewards/TierHero/starLayout.ts index 2f868576..95af3efb 100644 --- a/components/Rewards/NewRewards/TierHero/starLayout.ts +++ b/components/Rewards/NewRewards/TierHero/starLayout.ts @@ -11,6 +11,16 @@ export const TIER_STAR_SIZES: Record = { [RewardsTier.ULTRA]: 236, }; -/** Prime's star sits slightly high in its own canvas; nudge it back onto centre. */ -export const tierStarOffset = (tier: RewardsTier) => - tier === RewardsTier.PRIME ? [{ translateY: -5 }] : undefined; +/** + * Prime's star sits slightly high in its own canvas; nudge it back onto centre. + * + * Scaled by the size it is actually drawn at. The -5 was measured against the + * 235px hero, where it is 2% of the height and invisible; applied flat to the + * 28px star beside a heading it is 18%, which reads as a glyph that has come + * loose from its own text. The nudge is a property of the artwork, so it has to + * shrink with the artwork. + */ +export const tierStarOffset = (tier: RewardsTier, size: number = TIER_STAR_SIZES[tier]) => + tier === RewardsTier.PRIME + ? [{ translateY: (-5 * size) / TIER_STAR_SIZES[RewardsTier.PRIME] }] + : undefined; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx index d8bde3da..e73b214e 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx @@ -1,4 +1,4 @@ -import { Pressable, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; @@ -48,11 +48,15 @@ const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchPr isSelected && 'bg-white', )} > + {/* The colour is a style, not a class. `Text` composes its own + class with whatever the surrounding text context provides, and + a selected label that loses that merge is white on white — an + empty pill, which is what this rendered as. An inline style + cannot be merged away. Every other black-on-light label in the + rewards screens is written the same way. */} {ROUTE_LABEL[route]} @@ -63,4 +67,9 @@ const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchPr ); }; +const styles = StyleSheet.create({ + label: { color: '#FFFFFF' }, + selectedLabel: { color: '#000000' }, +}); + export default UpgradeRouteSwitch; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx index d6d2a812..91f448a4 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx @@ -1,5 +1,5 @@ import { StyleSheet, View } from 'react-native'; -import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; import { CashbackIcon, @@ -9,17 +9,27 @@ import { } from '@/components/Rewards/NewRewards/tierBenefitIcons'; import TierStar from '@/components/Rewards/NewRewards/TierHero/TierStar'; import { Text } from '@/components/ui/text'; -import { type AssetPath, getAsset } from '@/lib/assets'; import { getTierDisplayName } from '@/lib/tierNames'; import { RewardsTier } from '@/lib/types'; import type { TierUpgradeBenefit } from './tierUpgradeBenefits'; -/** The per-tier card texture. Already in the asset registry, unused until now. */ -const TIER_TEXTURE: Record = { - [RewardsTier.CORE]: 'images/rewards-tiers/core-summary.png', - [RewardsTier.PRIME]: 'images/rewards-tiers/prime-summary.png', - [RewardsTier.ULTRA]: 'images/rewards-tiers/ultra-summary.png', +/** + * The card's backdrop, per tier. + * + * Was `rewards-tiers/*-summary.png`, which is the wrong asset: those are the + * finished marketing cards, with "+2% Yield boost" and the rest set into the + * artwork. Behind this card's own benefit list they showed as a second, larger + * 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. + */ +const TIER_BACKDROP: Record = { + [RewardsTier.CORE]: ['#2A2A2A', '#1C1C1C'], + [RewardsTier.PRIME]: ['#3A3A3A', '#1F1F1F'], + [RewardsTier.ULTRA]: ['#454545', '#212121'], }; /** The benefit glyphs, at the 33px the tier card draws them. */ @@ -32,19 +42,13 @@ interface UpgradeTierHeroCardProps { statusLabel?: string; } -/** - * The tier being bought, and what it gets you. - * - * The texture behind it is the tier's own, from the asset registry rather than - * a gradient written here — Prime's silver sheen and Ultra's darker one are - * design assets, and reproducing them in code is how they stop matching the - * rest of the rewards screens. - */ +/** The tier being bought, and what it gets you. */ const UpgradeTierHeroCard = ({ tier, benefits, statusLabel }: UpgradeTierHeroCardProps) => ( - diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx index 87966a72..e83f6315 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx @@ -4,8 +4,10 @@ import { router, useLocalSearchParams } from 'expo-router'; import Loading from '@/components/Loading'; import PageLayout from '@/components/PageLayout'; +import TierSwitcher from '@/components/Rewards/NewRewards/TierSwitcher'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useTierBenefits } from '@/hooks/useRewards'; @@ -35,6 +37,13 @@ import UpgradeRouteSwitch from './UpgradeRouteSwitch'; import UpgradeTierHeader from './UpgradeTierHeader'; import UpgradeTierHeroCard from './UpgradeTierHeroCard'; +/** Tab labels for the tier switch, which takes every tier's name up front. */ +const TIER_LABELS: Record = { + [RewardsTier.CORE]: getTierDisplayName(RewardsTier.CORE), + [RewardsTier.PRIME]: getTierDisplayName(RewardsTier.PRIME), + [RewardsTier.ULTRA]: getTierDisplayName(RewardsTier.ULTRA), +}; + /** Where "Learn more" and "How to earn points?" send the user. */ const MEMBERSHIP_HELP_URL = 'https://docs.solid.money/rewards/tiers'; @@ -54,12 +63,34 @@ export default function UpgradeTierScreen() { const { data: chain } = useTierUpgradeChainState(membership?.contracts); const { selectToken: selectSavingsFundToken } = useSavingsFundFlow(); - // The tier from the deep link when it names one, else the cheapest the user - // does not already hold — so "Upgrade" from anywhere lands somewhere useful. + /** + * The tiers actually on sale to this user, cheapest first. + * + * A tier they already hold is not one of them, which is what collapses the + * switch as they climb: a Core user is choosing between Prime and Ultra, a + * Prime user has only Ultra left, and an Ultra user has nothing to buy. + */ + const purchasableTiers = useMemo( + () => + (membership?.offers ?? []) + .filter( + candidate => !candidate.held && (candidate.lockAvailable || candidate.cashAvailable), + ) + .map(candidate => candidate.tier), + [membership], + ); + + const [pickedTier, setPickedTier] = useState(null); + + // The tier the user picked, else the one the deep link names, else the + // cheapest they do not already hold — so "Upgrade" from anywhere lands + // somewhere useful. A pick is dropped once it stops being on offer, which is + // what happens the moment they buy it. const tier = useMemo(() => { + if (pickedTier && purchasableTiers.includes(pickedTier)) return pickedTier; if (tierParam === RewardsTier.PRIME || tierParam === RewardsTier.ULTRA) return tierParam; return nextPurchasableTier(membership); - }, [membership, tierParam]); + }, [membership, pickedTier, purchasableTiers, tierParam]); const offer = tier ? findOffer(membership, tier) : undefined; // Memoised because the effect below depends on it: a fresh array every render @@ -150,6 +181,11 @@ export default function UpgradeTierScreen() { availableUsdc, }); + const handleTier = (next: RewardsTier) => { + setPickedTier(next); + track(TRACKING_EVENTS.TIER_UPGRADE_TIER_SELECTED, { tier: next }); + }; + const handleRoute = (next: TierUpgradeRoute) => { setRoute(next); track(TRACKING_EVENTS.TIER_UPGRADE_ROUTE_SELECTED, { tier, route: next }); @@ -158,8 +194,11 @@ export default function UpgradeTierScreen() { /** * Short of what the upgrade costs, so the press has to fix that first. * - * Each route tops up in its own currency and through the flow that already - * exists for it: FUSE through the savings funding flow, USDC through deposit. + * Each route tops up in its own currency, and both of them open over this + * screen rather than navigating away from it: the user is mid-decision, and + * coming back from a pushed route means finding their way here again. FUSE + * opens the savings funding sheet; USDC opens the deposit chooser, which is + * the same drawer the wallet uses. */ const handleTopUp = () => { const depositStore = useDepositStore.getState(); @@ -172,7 +211,7 @@ export default function UpgradeTierScreen() { return; } - router.push(path.DEPOSIT); + depositStore.setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_TYPE); }; const handleReview = () => { @@ -188,6 +227,19 @@ export default function UpgradeTierScreen() { + {/* Only when there is a choice to make. A Prime user can only buy + Ultra, and a one-tab switch is a label. */} + {purchasableTiers.length > 1 ? ( + + + + ) : null} + {route === 'cash' - ? `Upgrade to the ${offer.tier === RewardsTier.ULTRA ? 'Ultra' : 'Prime'} tier with an annual fee. ` + ? `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. `} Date: Sun, 20 Sep 2026 12:37:22 +0000 Subject: [PATCH 2/2] fix(rewards): open the reconciliation window after a v3 upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "You're on Prime now!" exists and is route-agnostic: RewardsUpgradeFeedback celebrates a tier it sees rise between two reads of the rewards payload, which is how points, a savings deposit and an activated trial all get the same card. A lock and an annual fee did not, and would mostly have been missed. Both invalidated the rewards query, which buys exactly one read — taken the instant the transaction lands, before the backend has re-derived the tier from a lock it has not indexed or a subscription row written in the same breath. That read returns the old tier, nothing appears to have risen, and the upgrade the user just paid for goes unacknowledged. Arming `savingsChanged` opens the polling window, so the payload is re-read until the new tier arrives — the same mechanism the savings path uses, so all four routes into a tier now produce the identical celebration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- hooks/useTierMembership.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/hooks/useTierMembership.ts b/hooks/useTierMembership.ts index dc78321c..9e50d7f7 100644 --- a/hooks/useTierMembership.ts +++ b/hooks/useTierMembership.ts @@ -21,6 +21,7 @@ import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; import { fuseSharesForAmount } from '@/lib/tierUpgrade'; import { RewardsTier, TierMembershipState } from '@/lib/types'; import { publicClient } from '@/lib/wagmi'; +import { selectedRewardsUserId, useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; export const TIER_MEMBERSHIP_QUERY_KEY = 'tierMembership'; @@ -180,6 +181,23 @@ const useInvalidateAfterUpgrade = () => { // shape `refreshRewardsAfterSavings` invalidates, minus the user id, so one // upgrade refreshes whichever account is selected. queryClient.invalidateQueries({ queryKey: ['rewards', 'userData'] }); + + // And open the reconciliation window, which is what actually gets the + // "You're on Prime now!" card shown. + // + // `RewardsUpgradeFeedback` celebrates a tier it sees *rise* between two + // reads of the rewards payload. One invalidation gives it a single read, + // taken the instant the transaction lands — before the backend has + // re-derived the tier from a lock it has not indexed yet, or a + // subscription row written in the same breath. That read returns the old + // tier, nothing appears to have risen, and the upgrade the user just paid + // for is never acknowledged. + // + // Arming the window makes it poll until the new tier arrives, exactly as a + // savings deposit does. Same mechanism, so all four routes into a tier — + // points, savings, a lock, an annual fee — get the identical celebration. + const userId = selectedRewardsUserId(); + if (userId) useRewardsUpgradeStore.getState().savingsChanged(userId); }, [queryClient]); };