Skip to content
Open
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
4 changes: 4 additions & 0 deletions app/(protected)/(tabs)/rewards/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export default function RewardsLayout() {
headerShown: false,
}}
/>
{/* Both upgrade screens draw their own header — back, title and a dismiss
that leaves the flow rather than stepping back through it. */}
<Stack.Screen name="upgrade" options={{ title: 'Upgrade tier', headerShown: false }} />
<Stack.Screen name="upgrade-review" options={{ title: 'Upgrade tier', headerShown: false }} />
</Stack>
);
}
5 changes: 5 additions & 0 deletions app/(protected)/(tabs)/rewards/upgrade-review.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import UpgradeTierReviewScreen from '@/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen';

export default function RewardsUpgradeReview() {
return <UpgradeTierReviewScreen />;
}
5 changes: 5 additions & 0 deletions app/(protected)/(tabs)/rewards/upgrade.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import UpgradeTierScreen from '@/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen';

export default function RewardsUpgrade() {
return <UpgradeTierScreen />;
}
14 changes: 14 additions & 0 deletions components/Earn/EarnScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { View } from 'react-native';
import { Href, router } from 'expo-router';

import { BalanceHeadline, BalancePillRow } from '@/components/BalanceHeadline';
import LockedFuseTile from '@/components/Earn/LockedFuseTile';
import HeaderHelpButton from '@/components/Navbar/HeaderHelpButton';
import PageLayout from '@/components/PageLayout';
import SavingsHelpModal from '@/components/Savings/NewSavings/SavingsHelpModal';
import Skeleton from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
import { useMaxAPY } from '@/hooks/useAnalytics';
import { useTierMembership } from '@/hooks/useTierMembership';
import { useTotalSavingsUSD } from '@/hooks/useTotalSavingsUSD';
import { type AssetPath } from '@/lib/assets';
import { isDevFeatureEnabled } from '@/lib/config';
Expand Down Expand Up @@ -71,6 +74,10 @@ export default function EarnScreen() {
const [isHelpOpen, setIsHelpOpen] = useState(false);
useVaultDetailPrefetch();
const { data: portfolioTotal, valuesByVault, isLoading } = useTotalSavingsUSD();
// Deliberately outside the portfolio total: a locked position cannot be
// withdrawn, and adding it to a headline the withdraw flow then refuses is
// worse than showing it as its own line.
const { data: membership } = useTierMembership();
const usdcApy = useMaxAPY(VaultType.USDC);
const ethApy = useMaxAPY(VaultType.ETH);
const fuseApy = useMaxAPY(VaultType.FUSE);
Expand Down Expand Up @@ -157,6 +164,13 @@ export default function EarnScreen() {
{row.length === 1 && <View className="flex-1" />}
</View>
))}

{membership?.lock ? (
<LockedFuseTile
lock={membership.lock}
onPress={() => router.push(path.REWARDS_UPGRADE)}
/>
) : null}
</View>

{/* Tokenized assets are still an in-development feature (the Stocks
Expand Down
64 changes: 64 additions & 0 deletions components/Earn/LockedFuseTile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Pressable, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { getTierDisplayName } from '@/lib/tierNames';
import { formatFuse, formatMembershipDay } from '@/lib/tierUpgrade';
import { RewardsTier, type TierLockState } from '@/lib/types';

interface LockedFuseTileProps {
lock: TierLockState;
onPress: () => void;
}

/**
* The FUSE a user has committed to hold a tier, shown apart from their savings.
*
* Its own row rather than folded into the FUSE vault tile, because it behaves
* differently in the one way that matters: it cannot be withdrawn until its
* date. Adding it to the savings figure would make the Earn page promise a
* balance the withdraw flow then refuses, which is worse than a second line.
*
* It is still earning — what is locked is the vault share, not the asset — so
* the tile says so rather than reading as money set aside and idle.
*/
const LockedFuseTile = ({ lock, onPress }: LockedFuseTileProps) => {
if (!lock.enabled || lock.lockedFuse <= 0) return null;

const unlockLabel =
lock.maturedFuse > 0
? 'Unlocking now'
: lock.nextUnlockAt
? `Unlocks ${formatMembershipDay(lock.nextUnlockAt)}`
: null;

return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Your locked FUSE"
onPress={onPress}
className="rounded-[20px] bg-[#1C1C1C] p-4 transition-all active:scale-[0.99] active:opacity-90"
>
<View className="flex-row items-center justify-between">
<Text className="text-[16px] font-semibold leading-5 text-white">Locked FUSE</Text>

{lock.unlockedTier !== RewardsTier.CORE ? (
<View className="rounded-full border border-[#94F27F]/15 bg-[#94F27F]/10 px-[10px] py-1">
<Text className="text-[13px] font-medium leading-4 text-[#94F27F]">
{getTierDisplayName(lock.unlockedTier)}
</Text>
</View>
) : null}
</View>

<Text className="mt-2 text-[24px] font-semibold leading-7 text-white">
{formatFuse(lock.lockedFuse)} FUSE
</Text>

<Text className="mt-1 text-[14px] leading-5 text-white/50">
{unlockLabel ? `${unlockLabel} · still earning` : 'Still earning while locked'}
</Text>
</Pressable>
);
};

export default LockedFuseTile;
21 changes: 17 additions & 4 deletions components/Rewards/NewRewards/PointsHeadline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TextStyle, View } from 'react-native';
import { Pressable, TextStyle, View } from 'react-native';
import { Image } from 'expo-image';

import { Text } from '@/components/ui/text';
Expand Down Expand Up @@ -33,17 +33,30 @@ interface PointsHeadlineProps {
* first thing to say about the tier just above it.
*/
badge?: React.ReactNode;
/**
* Opens the membership sheet. The tier name is the affordance for it
* everywhere it appears, so it is a press target here rather than a separate
* "details" control the design does not have.
*/
onPressTier?: () => void;
}

/** Current-tier badge + compact points count (e.g. "Prime" / "10.5M Points"). */
const PointsHeadline = ({ tier, points, badge }: PointsHeadlineProps) => {
const PointsHeadline = ({ tier, points, badge, onPressTier }: PointsHeadlineProps) => {
const formattedPoints = compactNumberFormat(points ?? 0);
const numberParts = formattedPoints.match(/^([\d,]+)(\.\d+)?(.*)$/);
const fadedNumberPart = numberParts ? `${numberParts[2] ?? ''}${numberParts[3] ?? ''}` : '';

return (
<View className="items-center gap-1 pt-2">
<View className="flex-row items-center gap-1.5">
<Pressable
accessibilityRole={onPressTier ? 'button' : undefined}
accessibilityLabel={onPressTier ? `${getTierDisplayName(tier)} membership` : undefined}
onPress={onPressTier}
disabled={!onPressTier}
hitSlop={8}
className="flex-row items-center gap-1.5 transition-opacity active:opacity-70"
>
<Image
source={getTierIcon(tier)}
style={{ width: 19, height: 19 }}
Expand All @@ -56,7 +69,7 @@ const PointsHeadline = ({ tier, points, badge }: PointsHeadlineProps) => {
>
{getTierDisplayName(tier)}
</Text>
</View>
</Pressable>
{badge}
<View className="flex-row items-baseline">
<Text style={NUMBER_STYLE}>{numberParts ? numberParts[1] : formattedPoints}</Text>
Expand Down
63 changes: 26 additions & 37 deletions components/Rewards/NewRewards/RewardsScreenNew.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Platform, Pressable, View } from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { Href, router, useLocalSearchParams } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useQuery } from '@tanstack/react-query';

Expand All @@ -15,14 +15,13 @@ import { path } from '@/constants/path';
import { SPIN_WIN } from '@/constants/spinWinDesign';
import { cardDetailsQueryOptions } from '@/hooks/cardDetailsQueryOptions';
import { useOptInToRewards, useReferralSummary, useRewardsUserData } from '@/hooks/useRewards';
import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow';
import { useSpinStatus } from '@/hooks/useSpinWin';
import { useTierMembership } from '@/hooks/useTierMembership';
import { monthlyCashbackTotal } from '@/lib/cashbackProgress';
import { isDevFeatureEnabled } from '@/lib/config';
import { resolveUserCashbackRate } from '@/lib/tierCashback';
import { nextPurchasableTier } from '@/lib/tierUpgrade';
import { RewardsTier } from '@/lib/types';
import { useSwapState } from '@/store/swapStore';
import { useDepositStore } from '@/store/useDepositStore';
import { useRewardsIntroStore } from '@/store/useRewardsIntroStore';
import { useRewardsWelcomePopupStore } from '@/store/useRewardsWelcomePopupStore';
import { useSpinWinModalStore } from '@/store/useSpinWinModalStore';
Expand All @@ -34,9 +33,9 @@ import RewardsSummaryCard from './RewardsSummaryCard';
import { resolveTierUpgradeCardData } from './skipTheLine';
import { resolveTierBenefitRates } from './tierBenefitCards';
import TierBenefitsGrid from './TierBenefitsGrid';
import TierMembershipSheet from './TierMembershipSheet';
import TierTrialPill from './TierTrialPill';
import TierUpgradeCard from './TierUpgradeCard';
import UpgradeTierSheet from './UpgradeTierSheet';

/**
* Redesigned rewards screen (Apple "glass" style), shown only on qa/preview
Expand All @@ -55,9 +54,8 @@ export default function RewardsScreenNew() {
const { data: referralSummary } = useReferralSummary();
const { data: cardDetails } = useQuery(cardDetailsQueryOptions(selectedUserId));
const { data: spinStatus } = useSpinStatus();
const { data: membership } = useTierMembership();
const openSpinWinModal = useSpinWinModalStore(state => state.setModal);
const openBuyFuse = useSwapState(state => state.actions.openBuyFuse);
const { selectToken: selectSavingsFundToken } = useSavingsFundFlow();
const { mutate: joinRewards, isPending: isJoining } = useOptInToRewards();
const hasCompletedIntro = useRewardsIntroStore(
state => !selectedUserId || Boolean(state.completedByUserId[selectedUserId]),
Expand All @@ -69,32 +67,28 @@ export default function RewardsScreenNew() {
const { referral: referralParam } = useLocalSearchParams<{ referral?: string }>();
const [isReferralModalOpen, setIsReferralModalOpen] = useState(false);
const [isHelpOpen, setIsHelpOpen] = useState(false);
const [isUpgradeSheetOpen, setIsUpgradeSheetOpen] = useState(false);
const [upgradeTier, setUpgradeTier] = useState<RewardsTier.PRIME | RewardsTier.ULTRA>(
RewardsTier.PRIME,
);

const handleUpgradeTier = useCallback((tier: RewardsTier | null) => {
if (tier !== RewardsTier.PRIME && tier !== RewardsTier.ULTRA) return;

setUpgradeTier(tier);
setIsUpgradeSheetOpen(true);
}, []);
const [isMembershipSheetOpen, setIsMembershipSheetOpen] = useState(false);

const handleDepositFuse = useCallback(() => {
setIsUpgradeSheetOpen(false);
/**
* Opens the upgrade flow on a specific tier.
*
* A screen rather than the old sheet: v3 sells the tier outright, so the
* decision now carries a price, a term and a choice of how to pay — more than
* a 470px sheet can put in front of someone before they sign for it.
*/
const handleUpgradeTier = useCallback(
(tier: RewardsTier | null) => {
const target =
tier === RewardsTier.PRIME || tier === RewardsTier.ULTRA
? tier
: nextPurchasableTier(membership);

const depositStore = useDepositStore.getState();
depositStore.resetDepositFlow();
depositStore.setSavingsFundIntent('savings');
depositStore.setDepositFromSolid(false);
selectSavingsFundToken('WFUSE');
}, [selectSavingsFundToken]);
if (!target) return;

const handleBuyFuse = useCallback(() => {
setIsUpgradeSheetOpen(false);
openBuyFuse(upgradeTier);
}, [openBuyFuse, upgradeTier]);
router.push({ pathname: '/rewards/upgrade', params: { tier: target } } as Href);
},
[membership],
);

// The rewards program requires an explicit opt-in; `hasOptedIn` defaults to
// true when the backend doesn't send it, so we never prompt prematurely.
Expand Down Expand Up @@ -230,6 +224,7 @@ export default function RewardsScreenNew() {
tier={currentTier}
points={totalPoints}
badge={<TierTrialPill trial={rewardsData?.activeTierTrial} />}
onPressTier={() => setIsMembershipSheetOpen(true)}
/>
<View className="flex-row gap-3 px-4">
<Pressable
Expand Down Expand Up @@ -319,13 +314,7 @@ export default function RewardsScreenNew() {
isOpen={isReferralModalOpen}
onClose={() => setIsReferralModalOpen(false)}
/>
<UpgradeTierSheet
open={isUpgradeSheetOpen}
tier={upgradeTier}
onOpenChange={setIsUpgradeSheetOpen}
onDepositFuse={handleDepositFuse}
onBuyFuse={handleBuyFuse}
/>
<TierMembershipSheet open={isMembershipSheetOpen} onOpenChange={setIsMembershipSheetOpen} />
</PageLayout>
);
}
Loading
Loading