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
30 changes: 23 additions & 7 deletions components/Rewards/NewRewards/JoinTierClubCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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)'],
/**
* 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, readonly [string, string, string]> = {
[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. */
Expand Down Expand Up @@ -49,8 +65,8 @@ const JoinTierClubCard = ({ tier, benefits, onPress }: JoinTierClubCardProps) =>
>
<LinearGradient
colors={TIER_GRADIENT[tier]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
locations={TIER_GRADIENT_STOPS}
{...BOTTOM_RIGHT_WASH}
pointerEvents="none"
style={StyleSheet.absoluteFill}
/>
Expand Down
4 changes: 2 additions & 2 deletions components/Rewards/NewRewards/RewardsSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -94,8 +95,7 @@ const RewardsSummaryCard = ({
<LinearGradient
colors={CORNER_GRADIENT}
locations={CORNER_GRADIENT_STOPS}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
{...TOP_RIGHT_WASH}
pointerEvents="none"
style={StyleSheet.absoluteFill}
/>
Expand Down
6 changes: 4 additions & 2 deletions components/Rewards/NewRewards/TierMembershipSheetContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand Down Expand Up @@ -89,8 +89,10 @@ const TierMembershipSheetContent = ({

{lock && lock.lockedFuse > 0 ? (
<View className="mt-4 overflow-hidden rounded-[20px] bg-[#232323]">
{/* 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. */}
<TierDetailRow
label="Locked FUSE"
label="Locked soFUSE"
value={`${formatFuse(lock.lockedFuse)} FUSE`}
withDivider
/>
Expand Down
10 changes: 9 additions & 1 deletion components/Rewards/NewRewards/TierStatsBand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ 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. */
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)';

Expand Down Expand Up @@ -64,7 +72,7 @@ const TierStatsBand = ({ stats }: { stats: readonly TierStat[] }) => (
style={{ height: TIER_STATS_BAND_HEIGHT }}
pointerEvents="none"
>
<LinearGradient colors={BAND_GRADIENT} style={StyleSheet.absoluteFill} />
<LinearGradient colors={BAND_GRADIENT} {...TOP_RIGHT_WASH} style={StyleSheet.absoluteFill} />
<WaveTexture />

<View className="flex-row" style={{ height: VISIBLE_HEIGHT }}>
Expand Down
25 changes: 10 additions & 15 deletions components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TierUpgradeRoute, string> = {
cash: 'Cash',
lock: 'Locked FUSE',
};
import type { TierUpgradeRoute } from '@/lib/tierUpgrade';

interface UpgradeRouteSwitchProps {
routes: TierUpgradeRoute[];
Expand All @@ -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 —
Expand All @@ -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. */}
<Text style={isSelected ? styles.selectedLabel : styles.label}>
{/* 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. */}
<Text
className={upgradeRouteLabelClass(isSelected)}
style={isSelected ? styles.selectedLabel : styles.label}
>
{ROUTE_LABEL[route]}
</Text>
</Pressable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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, readonly [string, string]> = {
[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. */
Expand All @@ -47,8 +50,7 @@ const UpgradeTierHeroCard = ({ tier, benefits, statusLabel }: UpgradeTierHeroCar
<View className="overflow-hidden rounded-[20px] bg-[#1C1C1C]">
<LinearGradient
colors={TIER_BACKDROP[tier]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{...TOP_RIGHT_WASH}
pointerEvents="none"
style={StyleSheet.absoluteFill}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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. */}
<TierDetailRow
label="Amount"
label="soFUSE to lock"
value={`${formatFuse(remainingFuse)} FUSE`}
withDivider
/>
Expand All @@ -175,7 +178,7 @@ export default function UpgradeTierReviewScreen() {

<Text className="mt-6 text-center text-[15px] leading-5 text-white/50">
{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.'}
Expand Down
14 changes: 11 additions & 3 deletions components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<TierDetailRow
label="Fuse amount"
label="soFUSE to lock"
value={`${formatFuse(remainingFuse)} FUSE`}
withDivider
/>
Expand All @@ -283,15 +288,18 @@ export default function UpgradeTierScreen() {
onExplain={() => void Linking.openURL(MEMBERSHIP_HELP_URL)}
withDivider
/>
<TierDetailRow label="Balance" value={`${formatFuseHeld(availableFuse)} FUSE`} />
<TierDetailRow
label="soFUSE balance"
value={`${formatFuseHeld(availableFuse)} FUSE`}
/>
</>
)}
</View>

<Text className="mt-6 text-center text-[15px] leading-5 text-white/50">
{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. `}
<Text
accessibilityRole="link"
onPress={() => void Linking.openURL(MEMBERSHIP_HELP_URL)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
29 changes: 29 additions & 0 deletions components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { TierUpgradeRoute } from '@/lib/tierUpgrade';

/** What each payment route is called on the switch. */
export const ROUTE_LABEL: Record<TierUpgradeRoute, string> = {
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';
26 changes: 26 additions & 0 deletions components/Rewards/NewRewards/tierGradients.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading