Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions components/Rewards/NewRewards/JoinTierClubCard.tsx
Original file line number Diff line number Diff line change
@@ -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, readonly [string, string]> = {
[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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Join ${tierName} Club`}
onPress={onPress}
className="overflow-hidden rounded-[23px] bg-[#1C1C1C] transition-all active:opacity-80"
>
<LinearGradient
colors={TIER_GRADIENT[tier]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
pointerEvents="none"
style={StyleSheet.absoluteFill}
/>

<View className="p-5">
<View className="flex-row items-start justify-between gap-3">
<View className="flex-1">
<Text className="text-[18px] font-bold leading-[22px] text-white">
Join {tierName} Club
</Text>
<Text className="mt-1 text-[14px] leading-5 text-white/70">
Unlock extra cashback and benefits
</Text>
</View>

<ChevronRight color={CHEVRON_COLOR} size={20} />
</View>

{benefits.length > 0 ? (
<View className="mt-4 flex-row flex-wrap gap-2">
{benefits.map(benefit => (
<View key={benefit.key} className="rounded-full bg-white/10 px-3 py-2">
<Text className="text-[13px] font-medium leading-4 text-white">
{benefit.label}
</Text>
</View>
))}
</View>
) : null}
</View>
</Pressable>
);
};

export default JoinTierClubCard;
47 changes: 42 additions & 5 deletions components/Rewards/NewRewards/RewardsScreenNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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 (
<PageLayout isLoading={welcomeDismissed}>
Expand Down Expand Up @@ -295,9 +318,23 @@ export default function RewardsScreenNew() {
/>
</View>

{/* 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 ? (
<View className="mt-8 px-4">
<JoinTierClubCard
tier={joinClubTier}
benefits={joinClubBenefits}
onPress={() => handleUpgradeTier(joinClubTier)}
/>
</View>
) : showTierUpgradeCard ? (
<View className="mt-8 px-4">
<TierUpgradeCard
currentPoints={totalPoints}
Expand All @@ -307,7 +344,7 @@ export default function RewardsScreenNew() {
onUpgradeTier={() => handleUpgradeTier(nextTier)}
/>
</View>
)}
) : null}
</View>

<ReferralProgramModalNew
Expand Down
25 changes: 24 additions & 1 deletion components/Rewards/NewRewards/RewardsSummaryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Platform, Pressable, View } from 'react-native';
import { Platform, Pressable, StyleSheet, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import { LinearGradient } from 'expo-linear-gradient';

import { Text } from '@/components/ui/text';
import { formatBalanceUSD } from '@/lib/utils';
Expand All @@ -8,6 +9,14 @@ import CashbackDetailsSheet from './CashbackDetailsSheet';

import type { CashbackDetailsData } from './CashbackDetailsSheet.types';

/** #94F27F, the rewards green, dissolved to nothing. */
const CORNER_GRADIENT = [
'rgba(148,242,127,0.18)',
'rgba(148,242,127,0.05)',
'rgba(148,242,127,0)',
] as const;
const CORNER_GRADIENT_STOPS = [0, 0.35, 0.75] as const;

interface RewardsSummaryCardProps {
/** This month's cashback, settled and escrowed together. */
cashback: number;
Expand Down Expand Up @@ -77,6 +86,20 @@ const RewardsSummaryCard = ({
}: RewardsSummaryCardProps) => {
return (
<View className="relative mx-4 h-40 overflow-hidden rounded-twice bg-card">
{/* 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. */}
<LinearGradient
colors={CORNER_GRADIENT}
locations={CORNER_GRADIENT_STOPS}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
pointerEvents="none"
style={StyleSheet.absoluteFill}
/>

<View className="h-16 flex-row items-center px-3">
<RewardsIcon />
<Text
Expand Down
2 changes: 1 addition & 1 deletion components/Rewards/NewRewards/TierHero/TierStar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const TierStar = ({ tier, size = TIER_STAR_SIZES[tier] }: { tier: RewardsTier; s
style={{
width: size,
height: size,
transform: tierStarOffset(tier),
transform: tierStarOffset(tier, size),
}}
contentFit="contain"
autoplay
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
TIER_STAR_SIZES,
tierStarOffset,
} from '@/components/Rewards/NewRewards/TierHero/starLayout';
import { RewardsTier } from '@/lib/types';

describe('tierStarOffset', () => {
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);
}
});
});
16 changes: 13 additions & 3 deletions components/Rewards/NewRewards/TierHero/starLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ export const TIER_STAR_SIZES: Record<RewardsTier, number> = {
[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;
19 changes: 14 additions & 5 deletions components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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. */}
<Text
className={cn(
'text-[16px] leading-5',
isSelected ? 'font-semibold text-black' : 'font-medium text-white',
)}
className={cn('text-[16px] leading-5', isSelected ? 'font-semibold' : 'font-medium')}
style={isSelected ? styles.selectedLabel : styles.label}
>
{ROUTE_LABEL[route]}
</Text>
Expand All @@ -63,4 +67,9 @@ const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchPr
);
};

const styles = StyleSheet.create({
label: { color: '#FFFFFF' },
selectedLabel: { color: '#000000' },
});

export default UpgradeRouteSwitch;
Loading
Loading