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. `} { // 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]); };