diff --git a/app/(protected)/(tabs)/activity/[clientTxId].tsx b/app/(protected)/(tabs)/activity/[clientTxId].tsx
index 9ae6c3480..a037ed484 100644
--- a/app/(protected)/(tabs)/activity/[clientTxId].tsx
+++ b/app/(protected)/(tabs)/activity/[clientTxId].tsx
@@ -3,8 +3,8 @@ import { Linking, Pressable, View } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import * as Sentry from '@sentry/react-native';
import { useQuery } from '@tanstack/react-query';
-import { format, minutesToSeconds } from 'date-fns';
-import { ArrowUpRight, ChevronLeft, X } from 'lucide-react-native';
+import { format, formatDistanceStrict, minutesToSeconds } from 'date-fns';
+import { ArrowUpRight, X } from 'lucide-react-native';
import { mainnet } from 'viem/chains';
import Diamond from '@/assets/images/diamond';
@@ -13,6 +13,7 @@ import CopyToClipboard from '@/components/CopyToClipboard';
import EstimatedTime from '@/components/EstimatedTime';
import PageLayout from '@/components/PageLayout';
import RenderTokenIcon from '@/components/RenderTokenIcon';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { Underline } from '@/components/ui/underline';
@@ -92,6 +93,21 @@ const Value = memo(function Value({ children, className }: ValueProps) {
return {children};
});
+const EscrowTimeLeft = memo(function EscrowTimeLeft({ payoutAt }: { payoutAt: string }) {
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ const interval = setInterval(() => setNow(Date.now()), 60_000);
+ return () => clearInterval(interval);
+ }, []);
+
+ const target = useMemo(() => new Date(payoutAt).getTime(), [payoutAt]);
+
+ if (target - now <= 0) return Releasing soon;
+
+ return {formatDistanceStrict(target, now)};
+});
+
const Back = memo(function Back({ title, className }: BackProps) {
const router = useRouter();
const params = useLocalSearchParams<{ tab?: string; from?: string }>();
@@ -106,12 +122,11 @@ const Back = memo(function Back({ title, className }: BackProps) {
}, [params.from, params.tab, router]);
return (
-
-
-
-
+
+
+
+
{title}
-
);
});
@@ -162,7 +177,11 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
activity,
cardProvider,
}: CardTransactionDetailProps) {
- const merchantName = transaction.merchant_name || transaction.description || 'Unknown';
+ const merchantName = (
+ transaction.merchant_name?.trim() ||
+ transaction.description?.trim() ||
+ 'Unknown'
+ );
const merchantLocation = [transaction.merchant_city, transaction.merchant_country]
.filter(Boolean)
.join(' ') || undefined;
@@ -171,6 +190,8 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
const txHash = transaction.crypto_transaction_details?.tx_hash;
const isApproved = transaction.status === 'approved';
+ const isDeclined = transaction.status === 'declined';
+ const isReversed = transaction.status === 'reversed';
const postedDate = useMemo(() => {
const dateStr = isApproved
? transaction.authorized_at || transaction.posted_at
@@ -193,18 +214,37 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
const cashbackInfo = getCashbackAmount(transaction.id, cashbacks);
+ const statusLabel = isApproved
+ ? 'Pending'
+ : isDeclined
+ ? 'Declined'
+ : isReversed
+ ? 'Reversed'
+ : 'Confirmed';
+ const statusColor = isApproved
+ ? 'text-yellow-500'
+ : isDeclined
+ ? 'text-red-400'
+ : '';
+
const rows = useMemo(() => {
const allRows = [
{ key: 'from', label: , value: Card },
{
key: 'status',
label: ,
- value: (
-
- {isApproved ? 'Pending' : 'Confirmed'}
-
- ),
+ value: {statusLabel},
},
+ isDeclined &&
+ transaction.declined_reason && {
+ key: 'reason',
+ label: ,
+ value: (
+
+ {toTitleCase(transaction.declined_reason)}
+
+ ),
+ },
cashbackInfo && {
key: 'cashback',
label: (
@@ -217,12 +257,24 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
- {cashbackInfo.isPending && cashbackInfo.amount !== 'Pending'
- ? `${cashbackInfo.amount} (Pending)`
- : cashbackInfo.amount}
+ {cashbackInfo.amount === 'Pending'
+ ? cashbackInfo.isEscrowed
+ ? 'Escrowed'
+ : 'Pending'
+ : cashbackInfo.isEscrowed
+ ? `${cashbackInfo.amount} (Escrowed)`
+ : cashbackInfo.isPending
+ ? `${cashbackInfo.amount} (Pending)`
+ : cashbackInfo.amount}
),
},
+ cashbackInfo?.isEscrowed &&
+ cashbackInfo.payoutAt && {
+ key: 'cashback-escrow-time-left',
+ label: ,
+ value: ,
+ },
txHash && {
key: 'explorer',
label: ,
@@ -240,7 +292,15 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
].filter(Boolean) as { key: string; label: React.ReactNode; value: React.ReactNode }[];
return allRows;
- }, [cashbackInfo, txHash, handleExplorerPress, isApproved]);
+ }, [
+ cashbackInfo,
+ txHash,
+ handleExplorerPress,
+ statusLabel,
+ statusColor,
+ isDeclined,
+ transaction.declined_reason,
+ ]);
const tokenIcon = useMemo(
() => getTokenIcon({ tokenSymbol: transaction.currency?.toUpperCase(), size: 75 }),
@@ -253,7 +313,7 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
{merchantLocation && (
- {merchantLocation}
+ {merchantLocation}
)}
diff --git a/app/(protected)/(tabs)/add-referrer.tsx b/app/(protected)/(tabs)/add-referrer.tsx
index 6395cd3b8..f9961c5f4 100644
--- a/app/(protected)/(tabs)/add-referrer.tsx
+++ b/app/(protected)/(tabs)/add-referrer.tsx
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
-import { ActivityIndicator, Pressable, TextInput, View } from 'react-native';
+import { ActivityIndicator, TextInput, View } from 'react-native';
import { router } from 'expo-router';
-import { ArrowLeft } from 'lucide-react-native';
import InfoError from '@/assets/images/info-error';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
@@ -68,9 +68,7 @@ export default function AddReferrer() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Enter your friend's referral code
diff --git a/app/(protected)/(tabs)/bridge-kyc.tsx b/app/(protected)/(tabs)/bridge-kyc.tsx
index 491e5750d..7e4dee4ae 100644
--- a/app/(protected)/(tabs)/bridge-kyc.tsx
+++ b/app/(protected)/(tabs)/bridge-kyc.tsx
@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
-import { Pressable, StyleSheet, View } from 'react-native';
+import { StyleSheet, View } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
-import { ArrowLeft } from 'lucide-react-native';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { track } from '@/lib/analytics';
@@ -246,12 +246,7 @@ export default function BridgeKyc({ onSuccess }: BridgeKycParams = {}) {
- (router.canGoBack() ? router.back() : router.replace('/'))}
- className="web:hover:opacity-70"
- >
-
-
+
Verify identity
diff --git a/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx b/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx
index 359a2627f..9943e63e7 100644
--- a/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx
+++ b/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx
@@ -1,9 +1,10 @@
import React from 'react';
-import { Pressable, View } from 'react-native';
+import { View } from 'react-native';
import { useRouter } from 'expo-router';
-import { ArrowLeft, ShieldAlert } from 'lucide-react-native';
+import { ShieldAlert } from 'lucide-react-native';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
@@ -42,9 +43,7 @@ export default function CountryVerificationRequired() {
{/* Header */}
-
-
-
+
Verification Required
diff --git a/app/(protected)/(tabs)/card-onboard/country_selection.tsx b/app/(protected)/(tabs)/card-onboard/country_selection.tsx
index d78bf34be..da4d4ec3f 100644
--- a/app/(protected)/(tabs)/card-onboard/country_selection.tsx
+++ b/app/(protected)/(tabs)/card-onboard/country_selection.tsx
@@ -9,12 +9,13 @@ import {
View,
} from 'react-native';
import { useRouter } from 'expo-router';
-import { ArrowLeft, ChevronDown } from 'lucide-react-native';
+import { ChevronDown } from 'lucide-react-native';
import { useShallow } from 'zustand/react/shallow';
import CountryFlagImage from '@/components/CountryFlagImage';
import { NotificationEmailModalDialog } from '@/components/NotificationEmailModal/NotificationEmailModalDialog';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { COUNTRIES, Country } from '@/constants/countries';
@@ -330,9 +331,7 @@ export default function CountrySelection() {
/>
-
-
-
+
Solid card
diff --git a/app/(protected)/(tabs)/card/activate/country_selection.tsx b/app/(protected)/(tabs)/card/activate/country_selection.tsx
index e863629bd..a7520d378 100644
--- a/app/(protected)/(tabs)/card/activate/country_selection.tsx
+++ b/app/(protected)/(tabs)/card/activate/country_selection.tsx
@@ -1,11 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Modal, Pressable, ScrollView, TextInput, View } from 'react-native';
import { useRouter } from 'expo-router';
-import { ArrowLeft, ChevronDown } from 'lucide-react-native';
+import { ChevronDown } from 'lucide-react-native';
import { useShallow } from 'zustand/react/shallow';
import CountryFlagImage from '@/components/CountryFlagImage';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { COUNTRIES, Country } from '@/constants/countries';
@@ -260,9 +261,7 @@ export default function ActivateCountrySelection() {
-
-
-
+
Solid card
diff --git a/app/(protected)/(tabs)/card/deposit.tsx b/app/(protected)/(tabs)/card/deposit.tsx
index 70061b450..63597f03d 100644
--- a/app/(protected)/(tabs)/card/deposit.tsx
+++ b/app/(protected)/(tabs)/card/deposit.tsx
@@ -3,10 +3,10 @@ import { ActivityIndicator, Pressable, View } from 'react-native';
import Toast from 'react-native-toast-message';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
-import { ArrowLeft } from 'lucide-react-native';
import { Address, formatUnits } from 'viem';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
@@ -216,9 +216,7 @@ const DepositToCard = () => {
- router.back()} className="web:hover:opacity-70">
-
-
+
Deposit to card
diff --git a/app/(protected)/(tabs)/card/details.tsx b/app/(protected)/(tabs)/card/details.tsx
index 189acf128..ffd3cd2bf 100644
--- a/app/(protected)/(tabs)/card/details.tsx
+++ b/app/(protected)/(tabs)/card/details.tsx
@@ -13,12 +13,10 @@ import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
-import { useQuery } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Copy,
- CreditCard,
KeyRound,
Plus,
Settings,
@@ -29,10 +27,6 @@ import { BorrowPositionCard } from '@/components/Card/BorrowPositionCard';
import { CircularActionButton } from '@/components/Card/CircularActionButton';
import DepositToCardModal from '@/components/Card/DepositToCardModal';
import ManagePinModal from '@/components/Card/ManagePinModal';
-import CancelPhysicalCardModal from '@/components/Card/CancelPhysicalCardModal';
-import OrderPhysicalCardModal, {
- PHYSICAL_CARD_STATUS_QUERY_KEY,
-} from '@/components/Card/OrderPhysicalCardModal';
import WithdrawToCardModal from '@/components/Card/WithdrawToCardModal';
import PageLayout from '@/components/PageLayout';
import { Button } from '@/components/ui/button';
@@ -52,11 +46,11 @@ import { useCardProvider } from '@/hooks/useCardProvider';
import { useCardWithdrawals } from '@/hooks/useCardWithdrawals';
import { useCustomer } from '@/hooks/useCustomer';
import { useDimension } from '@/hooks/useDimension';
-import { freezeCard, getPhysicalCardStatus, unfreezeCard } from '@/lib/api';
+import { freezeCard, unfreezeCard } from '@/lib/api';
import { getAsset } from '@/lib/assets';
import { EXPO_PUBLIC_ENVIRONMENT } from '@/lib/config';
import { CardHolderName, CardProvider, CardStatus, FreezeInitiator, KycStatus } from '@/lib/types';
-import { cn, withRefreshToken } from '@/lib/utils/utils';
+import { cn } from '@/lib/utils/utils';
import { CardDepositSource, useCardDepositStore } from '@/store/useCardDepositStore';
export default function CardDetails() {
@@ -72,18 +66,8 @@ export default function CardDetails() {
const [isLoadingCardDetails, setIsLoadingCardDetails] = useState(false);
const [shouldRevealDetails, setShouldRevealDetails] = useState(false);
const [isAddToWalletModalOpen, setIsAddToWalletModalOpen] = useState(false);
- const [isOrderPhysicalCardModalOpen, setIsOrderPhysicalCardModalOpen] = useState(false);
- const [isCancelPhysicalCardModalOpen, setIsCancelPhysicalCardModalOpen] = useState(false);
const flipAnimation = useRef(new Animated.Value(0)).current;
- const { data: physicalCardStatusData } = useQuery({
- queryKey: [PHYSICAL_CARD_STATUS_QUERY_KEY],
- queryFn: () => withRefreshToken(() => getPhysicalCardStatus()),
- enabled: provider === CardProvider.RAIN,
- });
-
- const hasPhysicalCard = physicalCardStatusData?.hasPhysicalCard ?? false;
-
const availableBalance = cardDetails?.balances.available;
const availableAmount = Number(availableBalance?.amount || '0').toString();
const isCardFrozen = cardDetails?.status === CardStatus.FROZEN;
@@ -158,12 +142,6 @@ export default function CardDetails() {
onFreezeToggle={handleFreezeToggle}
isWithdrawFromCardAllowed={isWithdrawFromCardAllowed}
isRain={provider === CardProvider.RAIN}
- hasPhysicalCard={hasPhysicalCard}
- onPhysicalCardPress={() =>
- hasPhysicalCard
- ? setIsCancelPhysicalCardModalOpen(true)
- : setIsOrderPhysicalCardModalOpen(true)
- }
/>
) : (
@@ -222,16 +200,6 @@ export default function CardDetails() {
onOpenChange={setIsAddToWalletModalOpen}
trigger={null}
/>
-
-
);
}
@@ -262,12 +230,6 @@ export default function CardDetails() {
onFreezeToggle={handleFreezeToggle}
isWithdrawFromCardAllowed={isWithdrawFromCardAllowed}
isRain={provider === CardProvider.RAIN}
- hasPhysicalCard={hasPhysicalCard}
- onPhysicalCardPress={() =>
- hasPhysicalCard
- ? setIsCancelPhysicalCardModalOpen(true)
- : setIsOrderPhysicalCardModalOpen(true)
- }
/>
@@ -283,16 +245,6 @@ export default function CardDetails() {
onOpenChange={setIsAddToWalletModalOpen}
trigger={null}
/>
-
-
);
}
@@ -311,8 +263,6 @@ interface DesktopHeaderProps {
onFreezeToggle: () => Promise;
isWithdrawFromCardAllowed: boolean;
isRain: boolean;
- hasPhysicalCard: boolean;
- onPhysicalCardPress: () => void;
}
function DesktopHeader({
@@ -325,8 +275,6 @@ function DesktopHeader({
onFreezeToggle,
isWithdrawFromCardAllowed,
isRain,
- hasPhysicalCard,
- onPhysicalCardPress,
}: DesktopHeaderProps) {
const [isManageOpen, setIsManageOpen] = useState(false);
const manageRef = useRef(null);
@@ -430,22 +378,6 @@ function DesktopHeader({
)}
)}
- {isRain && (
-
- )}
{isWithdrawFromCardAllowed && (
Promise;
isWithdrawFromCardAllowed: boolean;
isRain: boolean;
- hasPhysicalCard: boolean;
- onPhysicalCardPress: () => void;
}
function CardActions({
@@ -869,8 +799,6 @@ function CardActions({
onFreezeToggle,
isWithdrawFromCardAllowed,
isRain,
- hasPhysicalCard,
- onPhysicalCardPress,
}: CardActionsProps) {
const [isManageSheetOpen, setIsManageSheetOpen] = useState(false);
const showManageButton = isRain || !isCardFrozen || canUnfreeze;
@@ -962,20 +890,6 @@ function CardActions({
)}
- {isRain && (
-
-
-
-
-
- {hasPhysicalCard ? 'Cancel' : 'Physical'}
-
-
- )}
{isWithdrawFromCardAllowed && (
-
- router.canGoBack() ? router.back() : router.replace(path.CARD_DETAILS)
- }
- className="web:hover:opacity-70"
- >
-
-
+
Solid card transactions
diff --git a/app/(protected)/(tabs)/card/pending.tsx b/app/(protected)/(tabs)/card/pending.tsx
index 5dbf3c5af..136b5cd10 100644
--- a/app/(protected)/(tabs)/card/pending.tsx
+++ b/app/(protected)/(tabs)/card/pending.tsx
@@ -1,9 +1,55 @@
+import { useEffect } from 'react';
+import { useRouter } from 'expo-router';
+
import { CardStatusPage } from '@/components/Card/CardStatusPage';
+import { path } from '@/constants/path';
+import { useCardStatus } from '@/hooks/useCardStatus';
+import { CardStatus, KycStatus, RainApplicationStatus } from '@/lib/types';
+import { hasCard } from '@/lib/utils';
+
+const POLL_INTERVAL_MS = 5000;
export default function CardPending() {
+ const router = useRouter();
+ const { data: cardStatusResponse } = useCardStatus({ refetchInterval: POLL_INTERVAL_MS });
+
+ useEffect(() => {
+ if (!cardStatusResponse) return;
+
+ // User already has a card (e.g. status synced after this tab was open).
+ if (hasCard(cardStatusResponse) && cardStatusResponse.status !== CardStatus.PENDING) {
+ router.replace(path.CARD_DETAILS);
+ return;
+ }
+
+ const { kycStatus, rainApplicationStatus } = cardStatusResponse;
+
+ // Still under manual review — keep showing the pending page.
+ if (kycStatus === KycStatus.UNDER_REVIEW) return;
+
+ // Didit approved.
+ if (kycStatus === KycStatus.APPROVED) {
+ if (rainApplicationStatus === RainApplicationStatus.APPROVED) {
+ router.replace(path.CARD_READY);
+ } else {
+ // Rain still needs to finish (pending, needsInformation, etc.) — let
+ // the activate page render the appropriate next step / status.
+ router.replace(path.CARD_ACTIVATE);
+ }
+ return;
+ }
+
+ // Any other terminal/incomplete state (rejected, offboarded, incomplete,
+ // resubmitted, etc.) — bounce back to the activate page so the user sees
+ // the error or retry CTA.
+ if (kycStatus && kycStatus !== KycStatus.NOT_STARTED) {
+ router.replace(`${String(path.CARD_ACTIVATE)}?kycStatus=${kycStatus}` as any);
+ }
+ }, [cardStatusResponse, router]);
+
return (
;
+
+const initialConsents: ConsentState = {
+ agreedToEsign: false,
+ agreedToAccountOpeningPrivacy: false,
+ isTermsOfServiceAccepted: false,
+ agreedToCertify: false,
+ agreedToNoSolicitation: false,
+};
+
+const ESIGN_CONSENT_URL =
+ 'https://support.solid.xyz/en/articles/14167249-e-sign-electronic-communications-notice';
+const ACCOUNT_OPENING_PRIVACY_URL =
+ 'https://support.solid.xyz/en/articles/14285527-account-opening-privacy-notice-fuse-network-lt-solid-xyz';
+const US_CARD_TERMS_URL =
+ 'https://support.solid.xyz/en/articles/14285503-fuse-network-ltd-card-terms-for-u-s-consumer-program';
+const INTL_CARD_TERMS_URL =
+ 'https://support.solid.xyz/en/articles/14167076-card-terms-for-international-consumer-program';
+const ISSUER_PRIVACY_URL = 'https://www.third-national.com/privacypolicy';
+
+const underlineProps = {
+ textClassName: 'text-sm font-bold text-white' as const,
+ borderColor: 'rgba(255, 255, 255, 1)' as const,
+};
export default function CardReady() {
const router = useRouter();
const queryClient = useQueryClient();
const [activating, setActivating] = useState(false);
+ const [consents, setConsents] = useState(initialConsents);
+
+ const countryCode = useCountryStore(state => state.countryInfo?.countryCode);
+ const isUS = countryCode?.toUpperCase() === 'US';
+ const cardTermsUrl = isUS ? US_CARD_TERMS_URL : INTL_CARD_TERMS_URL;
+
+ const requiredKeys = useMemo(
+ () =>
+ isUS
+ ? [
+ 'agreedToEsign',
+ 'agreedToAccountOpeningPrivacy',
+ 'isTermsOfServiceAccepted',
+ 'agreedToCertify',
+ 'agreedToNoSolicitation',
+ ]
+ : [
+ 'agreedToEsign',
+ 'isTermsOfServiceAccepted',
+ 'agreedToCertify',
+ 'agreedToNoSolicitation',
+ ],
+ [isUS],
+ );
+
+ const allAccepted = useMemo(
+ () => requiredKeys.every(key => consents[key]),
+ [requiredKeys, consents],
+ );
+
+ const toggle = (key: ConsentKey) => setConsents(prev => ({ ...prev, [key]: !prev[key] }));
const handleActivateCard = async () => {
+ if (!allAccepted) return;
+
try {
setActivating(true);
+
+ await withRefreshToken(() =>
+ submitCardConsents({
+ ...consents,
+ // Non-US users never see this consent; send false so the field is always present.
+ agreedToAccountOpeningPrivacy: isUS ? consents.agreedToAccountOpeningPrivacy : false,
+ }),
+ );
+
const card = await withRefreshToken(() => createCard());
if (!card) throw new Error('Failed to create card');
@@ -49,15 +127,67 @@ export default function CardReady() {
};
return (
-
+
+
+ toggle('agreedToEsign')}>
+ I accept the{' '}
+ Linking.openURL(ESIGN_CONSENT_URL)}>
+ E-Sign Consent
+
+ .
+
+
+ {isUS && (
+ toggle('agreedToAccountOpeningPrivacy')}
+ >
+ I accept the{' '}
+ Linking.openURL(ACCOUNT_OPENING_PRIVACY_URL)}
+ >
+ Account Opening Privacy Notice
+
+ .
+
+ )}
+
+ toggle('isTermsOfServiceAccepted')}
+ >
+ I accept the{' '}
+ Linking.openURL(cardTermsUrl)}>
+ Solid Card Terms
+ {' '}
+ and the{' '}
+ Linking.openURL(ISSUER_PRIVACY_URL)}>
+ Issuer Privacy Policy
+
+ .
+
+
+ toggle('agreedToCertify')}>
+ I certify that the information I have provided is accurate and that I will abide by all
+ the rules and requirements related to my Solid Spend Card.
+
+
+ toggle('agreedToNoSolicitation')}
+ >
+ I acknowledge that applying for the Solid Spend Card does not constitute unauthorized
+ solicitation.
+
+
+
);
}
+
+function ConsentRow({
+ checked,
+ onToggle,
+ children,
+}: {
+ checked: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/(protected)/(tabs)/kyc.native.tsx b/app/(protected)/(tabs)/kyc.native.tsx
index bf70592d5..46fb42dea 100644
--- a/app/(protected)/(tabs)/kyc.native.tsx
+++ b/app/(protected)/(tabs)/kyc.native.tsx
@@ -17,6 +17,7 @@ export default function KycNative() {
markStarted,
onVerificationComplete,
onVerificationPending,
+ onVerificationDeclined,
onVerificationError,
} = useDiditSession();
@@ -40,7 +41,10 @@ export default function KycNative() {
if (result.session.status === VerificationStatus.Approved) {
onVerificationComplete();
} else if (result.session.status === VerificationStatus.Declined) {
- onVerificationError('Your identity verification was declined.');
+ // Redirect to /card/activate so the user sees specific Didit warnings
+ // (DOCUMENT_EXPIRED, DATE_OF_BIRTH_NOT_DETECTED, ...) instead of a
+ // generic declined screen with a retry button that loops.
+ onVerificationDeclined();
} else {
// 'Pending', 'In Review', etc. — redirect back to activate page
// so user sees "Under Review" state instead of blank page
@@ -71,6 +75,7 @@ export default function KycNative() {
initSession,
onVerificationComplete,
onVerificationPending,
+ onVerificationDeclined,
onVerificationError,
]);
diff --git a/app/(protected)/(tabs)/kyc.tsx b/app/(protected)/(tabs)/kyc.tsx
index 7de82d684..3ecbbd9a0 100644
--- a/app/(protected)/(tabs)/kyc.tsx
+++ b/app/(protected)/(tabs)/kyc.tsx
@@ -16,6 +16,7 @@ export default function KycWeb() {
markStarted,
onVerificationComplete,
onVerificationPending,
+ onVerificationDeclined,
onVerificationError,
} = useDiditSession();
const hasStartedRef = useRef(false);
@@ -27,6 +28,13 @@ export default function KycWeb() {
hasStartedRef.current = true;
+ // Reset BEFORE wiring handlers. `DiditSdk.reset()` is a static method that
+ // destroys the singleton (`_instance = null`), so the next `DiditSdk.shared`
+ // access creates a fresh instance. If we attach `onComplete` / `onEvent`
+ // before resetting, the handlers land on the about-to-be-destroyed instance
+ // and the new instance silently has no callbacks at all.
+ DiditSdk.reset();
+
DiditSdk.shared.onComplete = result => {
switch (result.type) {
case 'completed':
@@ -34,10 +42,12 @@ export default function KycWeb() {
if (result.session?.status === 'Approved') {
onVerificationComplete();
} else if (result.session?.status === 'Declined') {
- onVerificationError('Your identity verification was declined.');
+ // Redirect to /card/activate so the user sees specific Didit warnings
+ // (DOCUMENT_EXPIRED, DATE_OF_BIRTH_NOT_DETECTED, ...) instead of a
+ // generic "declined" screen with a retry button that loops.
+ onVerificationDeclined();
} else {
- // 'Pending', 'In Review', etc. — redirect back to activate page
- // so user sees "Under Review" state instead of blank page
+ // 'Pending' shows up here for manual-review sessions.
onVerificationPending();
}
break;
@@ -52,8 +62,53 @@ export default function KycWeb() {
}
};
- // Reset any previous SDK state so the embed container can be reused on retry
- DiditSdk.reset();
+ // With manual review enabled the SDK never fires `didit:completed` (that
+ // only fires for terminal Approved/Declined states), so `onComplete` won't
+ // run for an In Review session and the user just stares at a blank Didit
+ // screen. We listen to `didit:status_updated` to catch the moment Didit
+ // moves the session into a review/terminal state. Per the Didit docs the
+ // values that surface here are: Not Started, In Progress, Approved,
+ // Declined, In Review, Awaiting User, Resubmitted, Expired, Abandoned,
+ // Kyc Expired. ('Pending' shows up in `onComplete` only.)
+ //
+ // Note: `didit:verification_submitted` fires for every step (document,
+ // selfie, questionnaire), so it can't be used to detect that the user
+ // has finished the entire flow.
+ DiditSdk.shared.onEvent = event => {
+ if (!hasStartedRef.current) return;
+ if (event.type !== 'didit:status_updated') return;
+
+ const status = event.data?.status;
+ switch (status) {
+ case 'Approved':
+ hasStartedRef.current = false;
+ onVerificationComplete();
+ break;
+ case 'Declined':
+ hasStartedRef.current = false;
+ onVerificationDeclined();
+ break;
+ case 'Expired':
+ case 'Kyc Expired':
+ hasStartedRef.current = false;
+ onVerificationError('Your verification session expired. Please try again.');
+ break;
+ case 'Abandoned':
+ hasStartedRef.current = false;
+ onVerificationError('Your verification was abandoned. Please try again.');
+ break;
+ case 'In Review':
+ case 'Resubmitted':
+ hasStartedRef.current = false;
+ onVerificationPending();
+ break;
+ // 'Not Started', 'In Progress', 'Awaiting User' — keep the user in
+ // the widget; they still have something to do or are mid-flow.
+ default:
+ break;
+ }
+ };
+
DiditSdk.shared.startVerification({
url: verificationUrl,
configuration: {
@@ -69,6 +124,7 @@ export default function KycWeb() {
initSession,
onVerificationComplete,
onVerificationPending,
+ onVerificationDeclined,
onVerificationError,
]);
diff --git a/app/(protected)/(tabs)/referral.tsx b/app/(protected)/(tabs)/referral.tsx
index 8c1544a14..55fc8ad3b 100644
--- a/app/(protected)/(tabs)/referral.tsx
+++ b/app/(protected)/(tabs)/referral.tsx
@@ -1,11 +1,11 @@
import React from 'react';
-import { Pressable, View } from 'react-native';
+import { View } from 'react-native';
import { Image } from 'expo-image';
-import { Link, router } from 'expo-router';
-import { ArrowLeft } from 'lucide-react-native';
+import { Link } from 'expo-router';
import CopyToClipboard from '@/components/CopyToClipboard';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
import useUser from '@/hooks/useUser';
@@ -19,9 +19,7 @@ export default function Referral() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Share your referral code
diff --git a/app/(protected)/(tabs)/rewards/benefits.tsx b/app/(protected)/(tabs)/rewards/benefits.tsx
index 97a2e1218..dd75c33e9 100644
--- a/app/(protected)/(tabs)/rewards/benefits.tsx
+++ b/app/(protected)/(tabs)/rewards/benefits.tsx
@@ -1,11 +1,11 @@
-import { Pressable, View } from 'react-native';
+import { View } from 'react-native';
import { router } from 'expo-router';
-import { ArrowLeft } from 'lucide-react-native';
import PageLayout from '@/components/PageLayout';
import CompareTiersTable from '@/components/Rewards/CompareTiersTable';
import EarnPointsSection from '@/components/Rewards/EarnPointsSection';
import TierFeesTable from '@/components/Rewards/TierFeesTable';
+import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
import { useDimension } from '@/hooks/useDimension';
@@ -23,12 +23,7 @@ export default function RewardsBenefits() {
- router.push(path.REWARDS)}
- className="flex h-10 w-10 items-center justify-center rounded-full border-0 bg-popover web:transition-colors web:hover:bg-muted"
- >
-
-
+ router.push(path.REWARDS)} />
Rewards
diff --git a/app/(protected)/(tabs)/settings/account.tsx b/app/(protected)/(tabs)/settings/account.tsx
index ee0dafdd7..f0d5263b3 100644
--- a/app/(protected)/(tabs)/settings/account.tsx
+++ b/app/(protected)/(tabs)/settings/account.tsx
@@ -1,8 +1,7 @@
import { useState } from 'react';
import { ActivityIndicator, Alert, Modal, Pressable, Text, View } from 'react-native';
import { Image } from 'expo-image';
-import { router } from 'expo-router';
-import { ArrowLeft, ChevronLeft, ChevronRight, X } from 'lucide-react-native';
+import { ChevronRight, X } from 'lucide-react-native';
import { Address } from 'viem';
import WalletIcon from '@/assets/images/wallet';
@@ -10,6 +9,7 @@ import CopyToClipboard from '@/components/CopyToClipboard';
import Navbar from '@/components/Navbar';
import PageLayout from '@/components/PageLayout';
import { SettingsCard } from '@/components/Settings';
+import { BackButton } from '@/components/ui/back-button';
import { useDimension } from '@/hooks/useDimension';
import useUser from '@/hooks/useUser';
import { getAsset } from '@/lib/assets';
@@ -41,9 +41,7 @@ export default function Account() {
const mobileHeader = (
- router.back()} className="p-2">
-
-
+
Account details
);
@@ -53,9 +51,7 @@ export default function Account() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Account details
diff --git a/app/(protected)/(tabs)/settings/email.tsx b/app/(protected)/(tabs)/settings/email.tsx
index ace1f8a3e..3edb958af 100644
--- a/app/(protected)/(tabs)/settings/email.tsx
+++ b/app/(protected)/(tabs)/settings/email.tsx
@@ -1,13 +1,13 @@
import { useEffect, useRef } from 'react';
import { Controller } from 'react-hook-form';
-import { ActivityIndicator, Pressable, TextInput, View } from 'react-native';
+import { ActivityIndicator, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
-import { ArrowLeft, ChevronLeft } from 'lucide-react-native';
import Checkmark from '@/assets/images/checkmark';
import Navbar from '@/components/Navbar';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useDimension } from '@/hooks/useDimension';
@@ -60,9 +60,7 @@ export default function Email() {
const mobileHeader = (
- router.back()} className="p-2">
-
-
+
Email
);
@@ -72,9 +70,7 @@ export default function Email() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Email
diff --git a/app/(protected)/(tabs)/settings/help.tsx b/app/(protected)/(tabs)/settings/help.tsx
index 6b35882b2..487faeb4a 100644
--- a/app/(protected)/(tabs)/settings/help.tsx
+++ b/app/(protected)/(tabs)/settings/help.tsx
@@ -1,6 +1,4 @@
-import { Pressable, Text, View } from 'react-native';
-import { router } from 'expo-router';
-import { ArrowLeft, ChevronLeft } from 'lucide-react-native';
+import { Text, View } from 'react-native';
import EmailIcon from '@/assets/images/email';
import LegalIcon from '@/assets/images/legal';
@@ -9,6 +7,7 @@ import MessageCircle from '@/assets/images/messages';
import Navbar from '@/components/Navbar';
import PageLayout from '@/components/PageLayout';
import { SettingsCard } from '@/components/Settings';
+import { BackButton } from '@/components/ui/back-button';
import { useDimension } from '@/hooks/useDimension';
import { openIntercom } from '@/lib/intercom';
import { cn } from '@/lib/utils';
@@ -53,9 +52,7 @@ export default function Help() {
const mobileHeader = (
- router.back()} className="p-2">
-
-
+
Help & Support
);
@@ -65,9 +62,7 @@ export default function Help() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Help & Support
diff --git a/app/(protected)/(tabs)/settings/index.tsx b/app/(protected)/(tabs)/settings/index.tsx
index 71e7eca91..385f05407 100644
--- a/app/(protected)/(tabs)/settings/index.tsx
+++ b/app/(protected)/(tabs)/settings/index.tsx
@@ -2,12 +2,12 @@ import { Linking, Platform, Pressable, Text, View } from 'react-native';
import * as Application from 'expo-application';
import { Image } from 'expo-image';
import * as IntentLauncher from 'expo-intent-launcher';
-import { router } from 'expo-router';
-import { ArrowLeft, Bell, ChevronLeft } from 'lucide-react-native';
+import { Bell } from 'lucide-react-native';
import Navbar from '@/components/Navbar';
import PageLayout from '@/components/PageLayout';
import { SettingsCard } from '@/components/Settings';
+import { BackButton } from '@/components/ui/back-button';
import { useDimension } from '@/hooks/useDimension';
import useNotificationPermissionStatus from '@/hooks/useNotificationPermissionStatus';
import useUser from '@/hooks/useUser';
@@ -43,9 +43,7 @@ export default function Settings() {
const mobileHeader = (
- router.back()} className="p-2">
-
-
+
Settings
);
@@ -55,9 +53,7 @@ export default function Settings() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Settings
diff --git a/app/(protected)/(tabs)/settings/security.tsx b/app/(protected)/(tabs)/settings/security.tsx
index 96474cbf6..72c80dafa 100644
--- a/app/(protected)/(tabs)/settings/security.tsx
+++ b/app/(protected)/(tabs)/settings/security.tsx
@@ -1,14 +1,13 @@
import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
import { Image } from 'expo-image';
-import { router } from 'expo-router';
import * as Sentry from '@sentry/react-native';
import { StamperType, useTurnkey } from '@turnkey/react-native-wallet-kit';
-import { ArrowLeft, ChevronLeft } from 'lucide-react-native';
import Navbar from '@/components/Navbar';
import PageLayout from '@/components/PageLayout';
import { SettingsCard } from '@/components/Settings';
+import { BackButton } from '@/components/ui/back-button';
import { useDimension } from '@/hooks/useDimension';
import useUser from '@/hooks/useUser';
import { getTotpStatus } from '@/lib/api';
@@ -149,14 +148,7 @@ export default function Security() {
const mobileHeader = (
- router.back()}
- className="p-2"
- accessibilityLabel="Go back"
- accessibilityRole="button"
- >
-
-
+
Security
);
@@ -166,14 +158,7 @@ export default function Security() {
- router.back()}
- className="web:hover:opacity-70"
- accessibilityLabel="Go back"
- accessibilityRole="button"
- >
-
-
+
Security
diff --git a/app/(protected)/(tabs)/user-kyc-info.tsx b/app/(protected)/(tabs)/user-kyc-info.tsx
index 3bd4bd3be..620307dcb 100644
--- a/app/(protected)/(tabs)/user-kyc-info.tsx
+++ b/app/(protected)/(tabs)/user-kyc-info.tsx
@@ -1,13 +1,13 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
-import { Pressable, View } from 'react-native';
+import { View } from 'react-native';
import Toast from 'react-native-toast-message';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { zodResolver } from '@hookform/resolvers/zod';
-import { ArrowLeft } from 'lucide-react-native';
import { z } from 'zod';
import PageLayout from '@/components/PageLayout';
+import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
import { UserInfoFooter, UserInfoForm, UserInfoHeader } from '@/components/UserKyc';
import { KycMode, type UserInfoFormData, userInfoSchema } from '@/components/UserKyc/types';
@@ -128,9 +128,7 @@ export default function UserKycInfo() {
- router.back()} className="web:hover:opacity-70">
-
-
+
Identity verification
diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx
index 94c8753ca..0c9eb575c 100644
--- a/app/(protected)/_layout.tsx
+++ b/app/(protected)/_layout.tsx
@@ -215,6 +215,12 @@ export default function ProtectedLayout() {
presentation: 'fullScreenModal',
}}
/>
+
);
}
diff --git a/app/(protected)/agent/index.tsx b/app/(protected)/agent/index.tsx
new file mode 100644
index 000000000..12f501c30
--- /dev/null
+++ b/app/(protected)/agent/index.tsx
@@ -0,0 +1,404 @@
+import { useState } from 'react';
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import Toast from 'react-native-toast-message';
+import * as Clipboard from 'expo-clipboard';
+import { LinearGradient } from 'expo-linear-gradient';
+import { useLocalSearchParams } from 'expo-router';
+import { FileText, KeyRound, Plus } from 'lucide-react-native';
+
+import AgentDepositModal from '@/components/Agent/AgentDepositModal';
+import ApiKeyList from '@/components/Agent/ApiKeyList';
+import ApiKeyRevealModal from '@/components/Agent/ApiKeyRevealModal';
+import IntegrationSnippet from '@/components/Agent/IntegrationSnippet';
+import CopyToClipboard from '@/components/CopyToClipboard';
+import PageLayout from '@/components/PageLayout';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { buildAgentPromptTemplate } from '@/constants/agentPromptTemplate';
+import {
+ useAgentApiKeys,
+ useAgentBalance,
+ useAgentQuery,
+ useGenerateAgentApiKey,
+ useProvisionAgent,
+ useRevokeAgentApiKey,
+} from '@/hooks/useAgent';
+import { useDimension } from '@/hooks/useDimension';
+import { EXPO_PUBLIC_FLASH_API_BASE_URL, isProduction } from '@/lib/config';
+import { eclipseAddress } from '@/lib/utils';
+
+const formatUsdc = (raw?: bigint) => {
+ if (raw === undefined) return '—';
+ const value = Number(raw) / 1_000_000;
+ return `$${value.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}`;
+};
+
+/**
+ * Render-state override for visual debugging. Pass via query param:
+ * /agent?status=loading
+ * /agent?status=not_provisioned
+ * /agent?status=provisioned
+ * /agent?status=deposited (provisioned + non-zero balance)
+ *
+ * Disabled in production builds — the param is ignored when isProduction.
+ */
+type AgentStatusOverride = 'loading' | 'not_provisioned' | 'provisioned' | 'deposited';
+const VALID_OVERRIDES: AgentStatusOverride[] = [
+ 'loading',
+ 'not_provisioned',
+ 'provisioned',
+ 'deposited',
+];
+const DEMO_AGENT_ADDRESS = '0x0000000000000000000000000000000000000000';
+const DEMO_BALANCE_USDC = 12_345_670n; // $12.34
+
+const handleCopyPrompt = async () => {
+ const template = buildAgentPromptTemplate({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL });
+ await Clipboard.setStringAsync(template);
+ Toast.show({
+ type: 'success',
+ text1: 'Prompt template copied',
+ text2: 'Paste into Claude Desktop or ChatGPT instructions',
+ props: { badgeText: 'Copied' },
+ });
+};
+
+export default function AgentPage() {
+ const { status } = useLocalSearchParams<{ status?: string }>();
+ const { isScreenMedium } = useDimension();
+ const statusOverride: AgentStatusOverride | undefined =
+ !isProduction && VALID_OVERRIDES.includes(status as AgentStatusOverride)
+ ? (status as AgentStatusOverride)
+ : undefined;
+
+ const agentQuery = useAgentQuery();
+ const provision = useProvisionAgent();
+ const apiKeysQuery = useAgentApiKeys();
+ const generateApiKey = useGenerateAgentApiKey();
+ const revokeApiKey = useRevokeAgentApiKey();
+
+ const liveAgent = agentQuery.data;
+ const liveIsProvisioned = !!liveAgent?.agentEoaAddress;
+
+ const isLoading =
+ statusOverride === 'loading' || (statusOverride === undefined && agentQuery.isLoading);
+ const isProvisioned =
+ statusOverride === 'provisioned' ||
+ statusOverride === 'deposited' ||
+ (statusOverride === undefined && liveIsProvisioned);
+ const agentEoaAddress: string | undefined =
+ statusOverride === 'provisioned' || statusOverride === 'deposited'
+ ? DEMO_AGENT_ADDRESS
+ : liveAgent?.agentEoaAddress;
+
+ const balanceQuery = useAgentBalance(agentEoaAddress);
+ const balance =
+ statusOverride === 'deposited' || statusOverride === 'provisioned'
+ ? DEMO_BALANCE_USDC
+ : balanceQuery.data;
+ const balanceLoading = statusOverride === undefined ? balanceQuery.isLoading : false;
+
+ const [revealedKey, setRevealedKey] = useState(null);
+ const [depositOpen, setDepositOpen] = useState(false);
+
+ const handleProvision = async () => {
+ try {
+ await provision.mutateAsync();
+ } catch {
+ // useProvisionAgent shows its own error toast.
+ }
+ };
+
+ const handleGenerate = async () => {
+ try {
+ const result = await generateApiKey.mutateAsync(undefined);
+ setRevealedKey(result.key);
+ } catch {
+ Toast.show({
+ type: 'error',
+ text1: 'Failed to generate API key',
+ props: { badgeText: 'Error' },
+ });
+ }
+ };
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : !isProvisioned ? (
+
+
+
+ Set up your Agent Wallet
+
+
+ We'll create a new EOA under your existing Turnkey wallet that can sign x402
+ payments on Base. Start earning yield on idle USDC in your agent wallet.
+
+
+
+ ) : (
+ <>
+ setDepositOpen(true)}
+ onGenerateApiKey={handleGenerate}
+ onCopyPrompt={handleCopyPrompt}
+ />
+
+
+
+
+
+
+ revokeApiKey.mutate(id)}
+ revokingId={
+ revokeApiKey.isPending ? (revokeApiKey.variables as string) : undefined
+ }
+ />
+
+
+
+
+ How to use
+
+
+ >
+ )}
+
+ setRevealedKey(null)}
+ apiKey={revealedKey}
+ />
+ setDepositOpen(false)}
+ agentEoaAddress={agentEoaAddress}
+ />
+
+
+ );
+}
+
+interface ProvisionedHeaderProps {
+ isScreenMedium: boolean;
+ isGenerating: boolean;
+ onDeposit: () => void;
+ onGenerateApiKey: () => void;
+ onCopyPrompt: () => void;
+}
+
+function ProvisionedHeader({
+ isScreenMedium,
+ isGenerating,
+ onDeposit,
+ onGenerateApiKey,
+ onCopyPrompt,
+}: ProvisionedHeaderProps) {
+ if (isScreenMedium) {
+ return (
+
+
+ Agent Wallet
+ Your Solid Wallet is now Agentic
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Agent Wallet
+ Your Solid Wallet is now Agentic
+
+
+ } label="Deposit" onPress={onDeposit} />
+
+ ) : (
+
+ )
+ }
+ label="API key"
+ onPress={onGenerateApiKey}
+ variant="dark"
+ disabled={isGenerating}
+ />
+ }
+ label="Prompt"
+ onPress={onCopyPrompt}
+ variant="dark"
+ />
+
+
+ );
+}
+
+interface CircleActionProps {
+ icon: React.ReactNode;
+ label: string;
+ onPress: () => void;
+ variant?: 'brand' | 'dark';
+ disabled?: boolean;
+}
+
+function CircleAction({ icon, label, onPress, variant = 'brand', disabled }: CircleActionProps) {
+ return (
+
+
+ {icon}
+
+ {label}
+
+ );
+}
+
+interface BalanceCardProps {
+ balance?: bigint;
+ balanceLoading: boolean;
+}
+
+/**
+ * Mirrors /card/details `SpendingBalanceCard` shape — rounded-[20px] base
+ * with a LinearGradient overlay, big balance up top, secondary stat under
+ * it. Blue palette so the agent page reads distinctly from the green card
+ * page, purple savings, and yellow rewards.
+ */
+function BalanceCard({ balance, balanceLoading }: BalanceCardProps) {
+ const formatted = balanceLoading ? null : formatUsdc(balance);
+ return (
+
+
+
+
+ Spendable balance
+ {balanceLoading ? (
+
+ ) : (
+ {formatted}
+ )}
+
+
+ Earning
+ Yield on idle USDC
+
+
+
+ );
+}
+
+interface ApiKeysCardProps {
+ address?: string;
+ apiKeys: Parameters[0]['apiKeys'];
+ isLoading: boolean;
+ onRevoke: (id: string) => void;
+ revokingId?: string;
+}
+
+function ApiKeysCard({ address, apiKeys, isLoading, onRevoke, revokingId }: ApiKeysCardProps) {
+ return (
+
+
+ API Keys
+
+ Authenticate AI tools that pay through your agent wallet.
+
+
+ {address ? (
+
+ Agent wallet address
+
+
+ {eclipseAddress(address, 8, 6)}
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/app/notifications.native.tsx b/app/notifications.native.tsx
index f6e3eb53a..ecf6e97ab 100644
--- a/app/notifications.native.tsx
+++ b/app/notifications.native.tsx
@@ -1,10 +1,10 @@
import { useState } from 'react';
-import { Pressable, View } from 'react-native';
+import { View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
-import { ArrowLeft } from 'lucide-react-native';
import Notification from '@/assets/images/notification';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
@@ -14,10 +14,6 @@ export default function Notifications() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
- const handleBack = () => {
- router.back();
- };
-
const handleContinue = async () => {
setIsLoading(true);
@@ -36,12 +32,7 @@ export default function Notifications() {
{/* Header with back button */}
-
-
-
+
{/* Content - centered */}
diff --git a/app/signup/email.tsx b/app/signup/email.tsx
index 25ae42ba6..3f94958c7 100644
--- a/app/signup/email.tsx
+++ b/app/signup/email.tsx
@@ -5,12 +5,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Image } from 'expo-image';
import { Link, useRouter } from 'expo-router';
import { zodResolver } from '@hookform/resolvers/zod';
-import { ArrowLeft } from 'lucide-react-native';
import { z } from 'zod';
import { useShallow } from 'zustand/react/shallow';
import InfoError from '@/assets/images/info-error';
import { DesktopCarousel } from '@/components/Onboarding';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import Input from '@/components/ui/input';
@@ -207,12 +207,9 @@ export default function SignupEmail() {
{/* Back button - positioned above form on desktop */}
{isDesktop && (
-
-
-
+
+
+
)}
{/* Header */}
@@ -316,12 +313,7 @@ export default function SignupEmail() {
{/* Header with back button */}
-
-
-
+
{/* Content - flex between to push button to bottom */}
diff --git a/app/signup/otp.tsx b/app/signup/otp.tsx
index 53c5d1cc7..ad32d4178 100644
--- a/app/signup/otp.tsx
+++ b/app/signup/otp.tsx
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
-import { ActivityIndicator, Pressable, View } from 'react-native';
+import { ActivityIndicator, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import { zodResolver } from '@hookform/resolvers/zod';
-import { ArrowLeft } from 'lucide-react-native';
import { z } from 'zod';
import { useShallow } from 'zustand/react/shallow';
import InfoError from '@/assets/images/info-error';
import { DesktopCarousel } from '@/components/Onboarding';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { OtpInput } from '@/components/ui/otp-input';
import { Text } from '@/components/ui/text';
@@ -240,12 +240,9 @@ export default function SignupOtp() {
{/* Back button - positioned above form on desktop */}
{isDesktop && (
-
-
-
+
+
+
)}
{/* Header */}
@@ -321,12 +318,7 @@ export default function SignupOtp() {
{/* Header with back button */}
-
-
-
+
{/* Content - positioned at top, centered horizontally */}
diff --git a/app/signup/passkey.tsx b/app/signup/passkey.tsx
index 167e1c72c..b1e134843 100644
--- a/app/signup/passkey.tsx
+++ b/app/signup/passkey.tsx
@@ -1,17 +1,17 @@
import { useEffect, useState } from 'react';
-import { ActivityIndicator, Linking, Platform, Pressable, View } from 'react-native';
+import { ActivityIndicator, Linking, Platform, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import * as Sentry from '@sentry/react-native';
import { useTurnkey } from '@turnkey/react-native-wallet-kit';
-import { ArrowLeft } from 'lucide-react-native';
import { useShallow } from 'zustand/react/shallow';
import LoginKeyIcon from '@/assets/images/login_key_icon';
import PasskeySvg from '@/assets/images/passkey-svg';
import { DesktopCarousel } from '@/components/Onboarding';
+import { BackButton } from '@/components/ui/back-button';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { Underline } from '@/components/ui/underline';
@@ -137,12 +137,9 @@ export default function SignupPasskey() {
{/* Back button - positioned above form on desktop */}
{isDesktop && (
-
-
-
+
+
+
)}
{/* Passkey Icon */}
@@ -192,12 +189,7 @@ export default function SignupPasskey() {
{/* Header with back button */}
-
-
-
+
{/* Content - positioned at top, centered horizontally */}
diff --git a/app/welcome.tsx b/app/welcome.tsx
index 24118c09e..fbe09d496 100644
--- a/app/welcome.tsx
+++ b/app/welcome.tsx
@@ -1,9 +1,10 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import { Image } from 'expo-image';
import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useTurnkey } from '@turnkey/react-native-wallet-kit';
import { useShallow } from 'zustand/react/shallow';
import LoginKeyIcon from '@/assets/images/login_key_icon';
@@ -19,17 +20,24 @@ import { useUserStore } from '@/store/useUserStore';
export default function Welcome() {
const { handleRemoveUsers, handleSelectUserById } = useUser();
- const { users, _hasHydrated } = useUserStore(
- useShallow(state => ({
- users: state.users,
- _hasHydrated: state._hasHydrated,
- })),
- );
+ const { users, _hasHydrated, pendingAuthUserId, selectUserById, unselectUser, setPendingAuthUserId } =
+ useUserStore(
+ useShallow(state => ({
+ users: state.users,
+ _hasHydrated: state._hasHydrated,
+ pendingAuthUserId: state.pendingAuthUserId,
+ selectUserById: state.selectUserById,
+ unselectUser: state.unselectUser,
+ setPendingAuthUserId: state.setPendingAuthUserId,
+ })),
+ );
+ const { httpClient } = useTurnkey();
const router = useRouter();
const { isDesktop } = useDimension();
- const [loadingUserId, setLoadingUserId] = useState(null);
const { session } = useLocalSearchParams<{ session: string }>();
const passkeyUsers = users.filter(user => user.hasPasskey !== false);
+ const selectedUserId = users.find(u => u.selected)?.userId;
+ const isAuthInFlight = useRef(false);
// Redirect to onboarding if no users exist (e.g., after session expired with empty user list)
useEffect(() => {
@@ -51,13 +59,26 @@ export default function Welcome() {
}
}, [session]);
- const handleSelectUser = useCallback(
- async (userId: string) => {
- setLoadingUserId(userId);
- try {
- await handleSelectUserById(userId);
- } catch (error: any) {
- // Show error toast if passkey authentication fails
+ // After a user is clicked, the store's selected user changes which causes
+ // TurnkeyProvider to re-mount with that user's credentialId in
+ // `passkeyConfig.allowCredentials`. This effect waits for the new
+ // httpClient to be ready and only then triggers the passkey prompt — so the
+ // authenticator sees the filtered credential list and only the selected
+ // user's passkey is offered.
+ useEffect(() => {
+ if (!pendingAuthUserId) return;
+ if (!httpClient) return;
+ if (selectedUserId !== pendingAuthUserId) return;
+ if (isAuthInFlight.current) return;
+
+ const userId = pendingAuthUserId;
+ isAuthInFlight.current = true;
+
+ handleSelectUserById(userId)
+ .catch((error: any) => {
+ // Revert the pre-selection so the welcome screen reflects the
+ // un-authenticated state and TurnkeyProvider drops the filter.
+ unselectUser();
Toast.show({
type: 'error',
text1: 'Authentication failed',
@@ -66,11 +87,34 @@ export default function Welcome() {
badgeText: '',
},
});
- } finally {
- setLoadingUserId(null);
- }
+ })
+ .finally(() => {
+ // Keep `pendingAuthUserId` set while auth is in flight so every
+ // button stays disabled, then clear it once we settle.
+ setPendingAuthUserId(null);
+ isAuthInFlight.current = false;
+ });
+ }, [
+ pendingAuthUserId,
+ httpClient,
+ selectedUserId,
+ handleSelectUserById,
+ unselectUser,
+ setPendingAuthUserId,
+ ]);
+
+ const handleSelectUser = useCallback(
+ (userId: string) => {
+ if (pendingAuthUserId || isAuthInFlight.current) return;
+
+ // Marking the user as selected flips TurnkeyProvider's credentialId and
+ // triggers a re-mount of TurnkeyProviderKit with
+ // `allowCredentials: [{ id: user.credentialId, ... }]`. The effect above
+ // picks up once the remount finishes and fires the passkey prompt.
+ selectUserById(userId);
+ setPendingAuthUserId(userId);
},
- [handleSelectUserById],
+ [pendingAuthUserId, selectUserById, setPendingAuthUserId],
);
const handleUseAnotherAccount = useCallback(() => {
@@ -101,7 +145,7 @@ export default function Welcome() {
variant="brand"
className="h-14 justify-between rounded-xl border-0 px-6"
onPress={() => handleSelectUser(user.userId)}
- disabled={loadingUserId !== null}
+ disabled={pendingAuthUserId !== null}
>
diff --git a/assets/images/cards-desktop.png b/assets/images/cards-desktop.png
index 657127657..79e1bdb86 100644
Binary files a/assets/images/cards-desktop.png and b/assets/images/cards-desktop.png differ
diff --git a/assets/images/dollar-green.png b/assets/images/dollar-green.png
new file mode 100644
index 000000000..3995be390
Binary files /dev/null and b/assets/images/dollar-green.png differ
diff --git a/assets/images/globe-green.png b/assets/images/globe-green.png
new file mode 100644
index 000000000..f67c5bea6
Binary files /dev/null and b/assets/images/globe-green.png differ
diff --git a/assets/images/star-green.png b/assets/images/star-green.png
new file mode 100644
index 000000000..f016cbdc8
Binary files /dev/null and b/assets/images/star-green.png differ
diff --git a/components/Activity/CardTransactions.tsx b/components/Activity/CardTransactions.tsx
index f43959814..3002db0b0 100644
--- a/components/Activity/CardTransactions.tsx
+++ b/components/Activity/CardTransactions.tsx
@@ -161,6 +161,7 @@ export default function CardTransactions() {
.join(' ') || undefined;
const initials = getInitials(merchantName);
const isPurchase = transaction.category === CardTransactionCategory.PURCHASE;
+ const isDeclined = transaction.status === 'declined';
const color = getColorForTransaction(merchantName);
const cashbackInfo = getCashbackAmount(transaction.id, cashbacks);
@@ -207,14 +208,23 @@ export default function CardTransactions() {
- {cashbackInfo.isPending ? 'Cashback (Pending)' : 'Cashback'}
+ {cashbackInfo.isEscrowed
+ ? 'Cashback (Escrowed)'
+ : cashbackInfo.isPending
+ ? 'Cashback (Pending)'
+ : 'Cashback'}
)}
-
+
{formatCardAmount(transaction.amount, provider)}
{cashbackInfo && cashbackInfo.amount !== 'Pending' && (
diff --git a/components/Agent/AgentDepositBorrowForm.tsx b/components/Agent/AgentDepositBorrowForm.tsx
new file mode 100644
index 000000000..c1c9bd38b
--- /dev/null
+++ b/components/Agent/AgentDepositBorrowForm.tsx
@@ -0,0 +1,154 @@
+import { useMemo, useState } from 'react';
+import { ActivityIndicator, Linking, View } from 'react-native';
+import Toast from 'react-native-toast-message';
+
+import { BorrowSlider } from '@/components/Card/BorrowSlider';
+import TokenDetails from '@/components/TokenCard/TokenDetails';
+import { Button } from '@/components/ui/button';
+import Skeleton from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useAaveBorrowPosition } from '@/hooks/useAaveBorrowPosition';
+import useBorrowAndDepositToAgent from '@/hooks/useBorrowAndDepositToAgent';
+import { isProduction } from '@/lib/config';
+import { Status } from '@/lib/types';
+import { formatNumber } from '@/lib/utils';
+
+const SO_USD_LTV = 70n;
+
+type Props = {
+ agentEoaAddress?: string;
+ onSuccess: () => void;
+};
+
+const AgentDepositBorrowForm = ({ agentEoaAddress, onSuccess }: Props) => {
+ const [sliderValue, setSliderValue] = useState(0);
+ const {
+ totalSupplied,
+ totalBorrowed,
+ borrowAPY,
+ isLoading: positionLoading,
+ } = useAaveBorrowPosition();
+ const { borrowAndDeposit, bridgeStatus } = useBorrowAndDepositToAgent(agentEoaAddress);
+
+ const maxBorrowAmount = useMemo(
+ () => Math.max(0, totalSupplied * 0.69 - totalBorrowed),
+ [totalSupplied, totalBorrowed],
+ );
+
+ const collateralRequired = useMemo(() => {
+ if (sliderValue <= 0) return 0;
+ return (sliderValue * 100) / Number(SO_USD_LTV);
+ }, [sliderValue]);
+
+ const submitting = bridgeStatus === Status.PENDING;
+ const amountValid = sliderValue > 0 && sliderValue <= maxBorrowAmount;
+
+ const handleSubmit = async () => {
+ if (!amountValid || !agentEoaAddress) return;
+ try {
+ await borrowAndDeposit(sliderValue.toString());
+ Toast.show({
+ type: 'success',
+ text1: 'Deposit submitted',
+ text2:
+ 'Borrowed against soUSD on Fuse and sent via Stargate. Funds arrive on Base in ~1–5 min.',
+ props: { badgeText: 'Success' },
+ });
+ onSuccess();
+ } catch (err) {
+ Toast.show({
+ type: 'error',
+ text1: 'Deposit failed',
+ text2: err instanceof Error ? err.message : 'Unknown error',
+ props: { badgeText: 'Error' },
+ });
+ }
+ };
+
+ return (
+
+ {!isProduction && (
+
+
+ Borrow contract available only in production
+
+
+ soUSD and the Aave borrow market aren't deployed in this environment. The borrow
+ flow will fail — use the external-wallet option to fund the agent on Base for testing.
+
+
+ )}
+
+
+
+
+
+
+
+ Borrow rate
+
+ {positionLoading ? (
+
+ ) : (
+
+ {formatNumber(borrowAPY, 2)}%
+
+ )}
+
+
+
+
+
+ Collateral Required
+
+
+
+ {!sliderValue ? (
+
+ ) : (
+
+ {formatNumber(collateralRequired)} soUSD
+
+ )}
+
+
+
+
+ Use your soUSD as collateral to borrow USDC and fund your agent wallet on Base while
+ earning yield.{' '}
+ {
+ Linking.openURL(
+ 'https://support.solid.xyz/en/articles/13545322-borrow-against-your-savings',
+ );
+ }}
+ className="text-base font-medium leading-5 text-[#94F27F] web:hover:opacity-70"
+ >
+ Learn more.
+
+
+
+
+
+
+
+ );
+};
+
+export default AgentDepositBorrowForm;
diff --git a/components/Agent/AgentDepositExternalForm.tsx b/components/Agent/AgentDepositExternalForm.tsx
new file mode 100644
index 000000000..cc93ddee3
--- /dev/null
+++ b/components/Agent/AgentDepositExternalForm.tsx
@@ -0,0 +1,54 @@
+import { View } from 'react-native';
+import { Image } from 'expo-image';
+import { Info } from 'lucide-react-native';
+
+import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
+import { Text } from '@/components/ui/text';
+
+const baseIcon = require('@/assets/images/base.png');
+
+type Props = {
+ agentEoaAddress: string;
+};
+
+const AgentDepositExternalForm = ({ agentEoaAddress }: Props) => {
+ return (
+
+
+
+
+ Base
+
+
+ Send only USDC on Base to this address. Other tokens or chains may result in permanent
+ loss of funds.
+
+ >
+ }
+ />
+
+
+
+
+ Funds sent here arrive at your agent wallet immediately and do not earn yield. To keep
+ earning yield on the principal, use the Borrow against savings option instead.
+
+
+
+ );
+};
+
+export default AgentDepositExternalForm;
diff --git a/components/Agent/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx
new file mode 100644
index 000000000..45b08b966
--- /dev/null
+++ b/components/Agent/AgentDepositModal.tsx
@@ -0,0 +1,190 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Platform, Pressable, View } from 'react-native';
+import { ChevronDown, Leaf, Wallet as WalletIcon } from 'lucide-react-native';
+
+import AgentDepositBorrowForm from '@/components/Agent/AgentDepositBorrowForm';
+import AgentDepositExternalForm from '@/components/Agent/AgentDepositExternalForm';
+import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Text } from '@/components/ui/text';
+
+const MODAL_OPEN: ModalState = { name: 'agent-deposit', number: 1 };
+const CLOSE_STATE: ModalState = { name: 'close', number: 0 };
+
+type AgentDepositSource = 'borrow' | 'external';
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ agentEoaAddress?: string;
+};
+
+const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => {
+ const [source, setSource] = useState('borrow');
+
+ useEffect(() => {
+ if (!open) setSource('borrow');
+ }, [open]);
+
+ return (
+ {
+ if (!value) onClose();
+ }}
+ trigger={null}
+ title="Deposit to Agent Wallet"
+ contentKey="agent-deposit"
+ containerClassName="min-h-[42rem] overflow-y-auto flex-1"
+ >
+
+
+ From
+
+
+
+ {source === 'external' && agentEoaAddress ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const SOURCE_LABEL: Record = {
+ borrow: 'Borrow against Savings',
+ external: 'External Wallet',
+};
+
+const SOURCE_TOKEN: Record = {
+ borrow: '',
+ external: 'USDC',
+};
+
+const SourceIcon = ({ value }: { value: AgentDepositSource }) =>
+ value === 'borrow' ? (
+
+ ) : (
+
+ );
+
+const SourceSelector = ({
+ value,
+ onChange,
+}: {
+ value: AgentDepositSource;
+ onChange: (next: AgentDepositSource) => void;
+}) =>
+ Platform.OS === 'web' ? (
+
+ ) : (
+
+ );
+
+const SourceSelectorWeb = ({
+ value,
+ onChange,
+}: {
+ value: AgentDepositSource;
+ onChange: (next: AgentDepositSource) => void;
+}) => (
+
+
+
+
+
+ {SOURCE_LABEL[value]}
+
+
+ {SOURCE_TOKEN[value] ? (
+ {SOURCE_TOKEN[value]}
+ ) : null}
+
+
+
+
+
+ onChange('borrow')}
+ className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
+ >
+
+ Borrow against Savings
+
+ onChange('external')}
+ className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
+ >
+
+ External Wallet
+
+
+
+);
+
+const SourceSelectorNative = ({
+ value,
+ onChange,
+}: {
+ value: AgentDepositSource;
+ onChange: (next: AgentDepositSource) => void;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const select = useCallback(
+ (next: AgentDepositSource) => {
+ onChange(next);
+ setIsOpen(false);
+ },
+ [onChange],
+ );
+
+ return (
+
+ setIsOpen(open => !open)}
+ >
+
+
+ {SOURCE_LABEL[value]}
+
+
+ {SOURCE_TOKEN[value] ? (
+ {SOURCE_TOKEN[value]}
+ ) : null}
+
+
+
+ {isOpen && (
+
+ select('borrow')}
+ >
+
+ Borrow against Savings
+
+ select('external')}
+ >
+
+ External Wallet
+
+
+ )}
+
+ );
+};
+
+export default AgentDepositModal;
diff --git a/components/Agent/ApiKeyList.tsx b/components/Agent/ApiKeyList.tsx
new file mode 100644
index 000000000..04f19ac4d
--- /dev/null
+++ b/components/Agent/ApiKeyList.tsx
@@ -0,0 +1,65 @@
+import { ActivityIndicator, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { AgentApiKeySummary } from '@/lib/types';
+
+type Props = {
+ apiKeys: AgentApiKeySummary[] | undefined;
+ isLoading: boolean;
+ onRevoke: (id: string) => void;
+ revokingId?: string;
+};
+
+const formatDate = (iso?: string) => {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleDateString();
+};
+
+const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => {
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const active = (apiKeys ?? []).filter(k => !k.revokedAt);
+ if (active.length === 0) {
+ return (
+
+ No API keys yet. Generate one to start integrating with your AI tool.
+
+ );
+ }
+
+ return (
+
+ {active.map(k => (
+
+
+ sk_solid_live_•••••{k.prefix}
+
+ {k.name ? `${k.name} · ` : ''}created {formatDate(k.createdAt)}
+ {k.lastUsedAt ? ` · last used ${formatDate(k.lastUsedAt)}` : ''}
+
+
+
+
+ ))}
+
+ );
+};
+
+export default ApiKeyList;
diff --git a/components/Agent/ApiKeyRevealModal.tsx b/components/Agent/ApiKeyRevealModal.tsx
new file mode 100644
index 000000000..78b868c42
--- /dev/null
+++ b/components/Agent/ApiKeyRevealModal.tsx
@@ -0,0 +1,50 @@
+import { View } from 'react-native';
+
+import CopyToClipboard from '@/components/CopyToClipboard';
+import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ apiKey: string | null;
+};
+
+const MODAL_STATE: ModalState = { name: 'agent-api-key-reveal', number: 1 };
+const CLOSE_STATE: ModalState = { name: 'close', number: 0 };
+
+const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => {
+ return (
+ !isOpen && onClose()}
+ trigger={null}
+ title="Your new API key"
+ contentKey="agent-api-key-reveal"
+ shouldAnimate={false}
+ >
+
+
+ This is the only time you'll see the full key. Copy it now and store it securely. If
+ you lose it, generate a new one.
+
+ {apiKey ? (
+
+
+ {apiKey}
+
+
+
+ ) : null}
+
+
+
+ );
+};
+
+export default ApiKeyRevealModal;
diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx
new file mode 100644
index 000000000..6b0426d7a
--- /dev/null
+++ b/components/Agent/IntegrationSnippet.tsx
@@ -0,0 +1,28 @@
+import { View } from 'react-native';
+
+import CopyToClipboard from '@/components/CopyToClipboard';
+import { Text } from '@/components/ui/text';
+import { buildAgentIntegrationCurl } from '@/constants/agentPromptTemplate';
+import { EXPO_PUBLIC_FLASH_API_BASE_URL } from '@/lib/config';
+
+const IntegrationSnippet = () => {
+ const snippet = buildAgentIntegrationCurl({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL });
+ return (
+
+
+ Paste this curl example into a script or n8n node, or copy the AI prompt template from the
+ page header to wire up Claude Desktop / ChatGPT instructions.
+
+
+
+
+ {snippet}
+
+
+
+
+
+ );
+};
+
+export default IntegrationSnippet;
diff --git a/components/Card/ActivateCard/ActivateCardHeader.tsx b/components/Card/ActivateCard/ActivateCardHeader.tsx
index 58ee6975f..26c0e7609 100644
--- a/components/Card/ActivateCard/ActivateCardHeader.tsx
+++ b/components/Card/ActivateCard/ActivateCardHeader.tsx
@@ -1,6 +1,6 @@
-import { Pressable, View } from 'react-native';
-import { ArrowLeft } from 'lucide-react-native';
+import { View } from 'react-native';
+import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
interface ActivateCardHeaderProps {
@@ -10,9 +10,7 @@ interface ActivateCardHeaderProps {
export function ActivateCardHeader({ onBack }: ActivateCardHeaderProps) {
return (
-
-
-
+
Solid card
diff --git a/components/Card/CardDepositExternalForm.tsx b/components/Card/CardDepositExternalForm.tsx
index 7c8a82b66..82ff6896e 100644
--- a/components/Card/CardDepositExternalForm.tsx
+++ b/components/Card/CardDepositExternalForm.tsx
@@ -158,6 +158,7 @@ export default function CardDepositExternalForm() {
type: 'error',
text1: 'External deposits not available',
text2: 'This card does not support deposits from external wallet',
+ props: { badgeText: '' },
});
return;
}
diff --git a/components/Card/CardDepositInternalForm.tsx b/components/Card/CardDepositInternalForm.tsx
index d3bea95cb..0a37eb4e3 100644
--- a/components/Card/CardDepositInternalForm.tsx
+++ b/components/Card/CardDepositInternalForm.tsx
@@ -10,13 +10,21 @@ import {
import { ActivityIndicator, Linking, Platform, Pressable, TextInput, View } from 'react-native';
import Toast from 'react-native-toast-message';
import { Image } from 'expo-image';
-import { ChevronDown, Info, Leaf, Wallet as WalletIcon } from 'lucide-react-native';
+import {
+ ChevronDown,
+ ChevronRight,
+ Fuel,
+ Info,
+ Leaf,
+ Wallet as WalletIcon,
+} from 'lucide-react-native';
import { Address, erc20Abi, formatUnits, parseUnits, TransactionReceipt } from 'viem';
import { fuse, mainnet } from 'viem/chains';
import { useReadContract } from 'wagmi';
import { z } from 'zod';
import { useShallow } from 'zustand/react/shallow';
+import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
import Max from '@/components/Max';
import TokenDetails from '@/components/TokenCard/TokenDetails';
import { Button } from '@/components/ui/button';
@@ -38,15 +46,32 @@ import useBorrowAndDepositToCard from '@/hooks/useBorrowAndDepositToCard';
import useBridgeToCard from '@/hooks/useBridgeToCard';
import { useCardContracts } from '@/hooks/useCardContracts';
import useCardDeposit from '@/hooks/useCardDeposit';
+import useDepositFromSolidUsdc from '@/hooks/useDepositFromSolidUsdc';
import { useCardDetails } from '@/hooks/useCardDetails';
import { useCardProvider } from '@/hooks/useCardProvider';
import { usePreviewDepositToCard } from '@/hooks/usePreviewDepositToCard';
import useSwapAndBridgeToCard from '@/hooks/useSwapAndBridgeToCard';
import useUser from '@/hooks/useUser';
+import { WalletTokenButton } from '@/components/WalletTokenSelector';
+import { BRIDGE_TOKENS } from '@/constants/bridge';
+import { useDepositStore } from '@/store/useDepositStore';
import { track } from '@/lib/analytics';
import { getAsset } from '@/lib/assets';
-import { ADDRESSES, EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, isProduction } from '@/lib/config';
-import { CardProvider, Status, TransactionStatus, TransactionType } from '@/lib/types';
+import {
+ ADDRESSES,
+ EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID,
+ EXPO_PUBLIC_MINIMUM_SPONSOR_AMOUNT,
+ isProduction,
+} from '@/lib/config';
+import {
+ CardProvider,
+ DepositCategory,
+ Status,
+ TokenBalance,
+ TokenType,
+ TransactionStatus,
+ TransactionType,
+} from '@/lib/types';
import {
cn,
formatNumber,
@@ -59,6 +84,8 @@ import { CardDepositSource, useCardDepositStore } from '@/store/useCardDepositSt
import { BorrowSlider } from './BorrowSlider';
+const BASE_USDC_TOKEN_URL = `https://basescan.org/token/${ADDRESSES.base.usdc}`;
+
type FormData = { amount: string; from: CardDepositSource };
type SourceSelectorProps = {
@@ -91,12 +118,14 @@ function SourceSelectorNative({
const getDisplayText = useCallback(() => {
if (value === CardDepositSource.WALLET) return 'Wallet';
if (value === CardDepositSource.SAVINGS) return 'Savings';
+ if (value === CardDepositSource.EXTERNAL) return 'External Wallet';
return 'Borrow against Savings';
}, [value]);
const getTokenSymbol = useCallback(() => {
if (from === CardDepositSource.WALLET) return walletTokenSymbol;
if (from === CardDepositSource.SAVINGS) return 'soUSD';
+ if (from === CardDepositSource.EXTERNAL) return 'USDC';
return '';
}, [from, walletTokenSymbol]);
@@ -107,7 +136,7 @@ function SourceSelectorNative({
onPress={() => setIsOpen(!isOpen)}
>
- {value === CardDepositSource.WALLET ? (
+ {value === CardDepositSource.WALLET || value === CardDepositSource.EXTERNAL ? (
) : value === CardDepositSource.SAVINGS ? (
@@ -159,6 +188,16 @@ function SourceSelectorNative({
Wallet
+ {
+ onChange(CardDepositSource.EXTERNAL);
+ setIsOpen(false);
+ }}
+ >
+
+ External Wallet
+
)}
@@ -183,12 +222,14 @@ function SourceSelectorWeb({
const getDisplayText = useCallback(() => {
if (value === CardDepositSource.WALLET) return 'Wallet';
if (value === CardDepositSource.SAVINGS) return 'Savings';
+ if (value === CardDepositSource.EXTERNAL) return 'External Wallet';
return 'Borrow against Savings';
}, [value]);
const getTokenSymbol = useCallback(() => {
if (from === CardDepositSource.WALLET) return walletTokenSymbol;
if (from === CardDepositSource.SAVINGS) return 'soUSD';
+ if (from === CardDepositSource.EXTERNAL) return 'USDC';
return '';
}, [from, walletTokenSymbol]);
@@ -197,7 +238,7 @@ function SourceSelectorWeb({
- {value === CardDepositSource.WALLET ? (
+ {value === CardDepositSource.WALLET || value === CardDepositSource.EXTERNAL ? (
) : value === CardDepositSource.SAVINGS ? (
@@ -238,6 +279,13 @@ function SourceSelectorWeb({
Wallet
+ onChange(CardDepositSource.EXTERNAL)}
+ className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
+ >
+
+ External Wallet
+
);
@@ -289,6 +337,8 @@ type AmountInputProps = {
onAmountEntry?: () => void;
/** Symbol for Wallet source (e.g. USDC.e or rUSD). */
walletTokenSymbol: string;
+ /** Optional override for the right-hand token cell (e.g. WalletTokenButton). */
+ rightSlot?: React.ReactNode;
};
function AmountInput({
@@ -297,6 +347,7 @@ function AmountInput({
from,
onAmountEntry,
walletTokenSymbol,
+ rightSlot,
}: AmountInputProps) {
const getTokenImage = () => {
if (from === CardDepositSource.WALLET) return getAsset('images/usdc-4x.png');
@@ -341,14 +392,18 @@ function AmountInput({
/>
)}
/>
-
-
- {getTokenSymbol()}
-
+ {rightSlot ? (
+ rightSlot
+ ) : (
+
+
+ {getTokenSymbol()}
+
+ )}
);
@@ -526,13 +581,27 @@ export default function CardDepositInternalForm() {
// Get all token balances including soUSD
const { tokens, isLoading: isBalancesLoading } = useBalances();
- // Get Fuse USDC.e balance (production Wallet)
- const { data: fuseUsdcBalance, isLoading: isUsdcBalanceLoading } = useReadContract({
+ // Production "From Wallet" card deposit: read USDC balance from the Solid
+ // Safe AA on the chain the user picked in the token selector. Falls back to
+ // the Fuse-stargate legacy behaviour when the user hasn't picked anything
+ // yet (cardDepositSrcChainId is 0 / unsupported).
+ const cardDepositSrcChainId = useDepositStore(state => state.srcChainId);
+ const selectedWalletUsdcAddress =
+ (BRIDGE_TOKENS[cardDepositSrcChainId]?.tokens?.USDC?.address as
+ | Address
+ | undefined) ?? undefined;
+ const hasSelectedWalletUsdc =
+ !!cardDepositSrcChainId && !!selectedWalletUsdcAddress;
+ const walletBalanceChainId = hasSelectedWalletUsdc ? cardDepositSrcChainId : fuse.id;
+ const walletBalanceTokenAddress = hasSelectedWalletUsdc
+ ? (selectedWalletUsdcAddress as Address)
+ : USDC_STARGATE;
+ const { data: walletUsdcBalance, isLoading: isUsdcBalanceLoading } = useReadContract({
abi: erc20Abi,
- address: USDC_STARGATE,
+ address: walletBalanceTokenAddress,
functionName: 'balanceOf',
args: [user?.safeAddress as Address],
- chainId: fuse.id,
+ chainId: walletBalanceChainId,
query: { enabled: !!user?.safeAddress && isProduction },
});
@@ -599,7 +668,7 @@ export default function CardDepositInternalForm() {
// Get borrow APY from Aave
const { borrowAPY, isLoading: isBorrowAPYLoading } = useAaveBorrowPosition();
- const usdcBalanceAmount = fuseUsdcBalance ? Number(fuseUsdcBalance) / 1e6 : 0;
+ const usdcBalanceAmount = walletUsdcBalance ? Number(walletUsdcBalance) / 1e6 : 0;
const soUsdBalanceAmount = soUsdToken
? Number(soUsdToken.balance) / Math.pow(10, soUsdToken.contractDecimals)
: 0;
@@ -618,7 +687,11 @@ export default function CardDepositInternalForm() {
? isUsdcBalanceLoading
: isTestnetBalanceLoading
: isBalancesLoading;
- const walletTokenSymbol = isProduction ? 'USDC.e' : getCardDepositTokenSymbol(provider);
+ const walletTokenSymbol = isProduction
+ ? cardDepositSrcChainId === fuse.id
+ ? 'USDC.e'
+ : 'USDC'
+ : getCardDepositTokenSymbol(provider);
const tokenSymbol =
watchedFrom === CardDepositSource.WALLET
? walletTokenSymbol
@@ -663,6 +736,9 @@ export default function CardDepositInternalForm() {
ADDRESSES.fuse.stargateOftUSDC,
);
+ const isWalletSourceGaslessGated =
+ isProduction && watchedFrom === CardDepositSource.WALLET;
+
const schema = useMemo(() => {
return z.object({
amount: z
@@ -699,6 +775,38 @@ export default function CardDepositInternalForm() {
const { borrowAndDeposit, bridgeStatus: borrowAndDepositStatus } = useBorrowAndDepositToCard();
const { deposit, depositStatus, error: depositError } = useCardDeposit();
+ const hasSelectedWalletToken =
+ watchedFrom === CardDepositSource.WALLET &&
+ isProduction &&
+ hasSelectedWalletUsdc;
+ const selectedCardWalletToken: TokenBalance | null = useMemo(() => {
+ if (!hasSelectedWalletToken || !selectedWalletUsdcAddress) return null;
+ return {
+ contractTickerSymbol: walletTokenSymbol,
+ contractName: 'USD Coin',
+ contractAddress: selectedWalletUsdcAddress,
+ balance: '0',
+ contractDecimals: 6,
+ type: TokenType.ERC20,
+ chainId: cardDepositSrcChainId,
+ };
+ }, [
+ hasSelectedWalletToken,
+ selectedWalletUsdcAddress,
+ walletTokenSymbol,
+ cardDepositSrcChainId,
+ ]);
+ const {
+ deposit: walletCardDeposit,
+ depositStatus: walletCardDepositStatus,
+ error: walletCardDepositError,
+ } = useDepositFromSolidUsdc(
+ (selectedWalletUsdcAddress ?? '') as Address,
+ 'USDC',
+ EXPO_PUBLIC_MINIMUM_SPONSOR_AMOUNT,
+ DepositCategory.CARD,
+ );
+
// Track form viewed (once on mount)
useEffect(() => {
if (!hasTrackedFormViewedRef.current) {
@@ -802,6 +910,7 @@ export default function CardDepositInternalForm() {
type: 'error',
text1: 'Deposits not available',
text2: 'This card does not support deposits to the funding chain',
+ props: { badgeText: '' },
});
return;
}
@@ -900,6 +1009,14 @@ export default function CardDepositInternalForm() {
return;
}
+ if (watchedFrom === CardDepositSource.WALLET && isProduction) {
+ await walletCardDeposit(data.amount);
+ setTransaction({ amount: Number(data.amount) });
+ setModal(CARD_DEPOSIT_MODAL.OPEN_TRANSACTION_STATUS);
+ reset();
+ return;
+ }
+
// Check for funding address
if (!cardDetails) {
Toast.show({
@@ -920,6 +1037,7 @@ export default function CardDepositInternalForm() {
type: 'error',
text1: 'Deposits not available',
text2: 'This card does not support deposits to the funding chain',
+ props: { badgeText: '' },
});
return;
}
@@ -985,6 +1103,7 @@ export default function CardDepositInternalForm() {
[
watchedFrom,
deposit,
+ walletCardDeposit,
estimatedUSDC,
exchangeRate,
cardDetails,
@@ -1014,13 +1133,19 @@ export default function CardDepositInternalForm() {
const isFundingAddressLoading = provider === CardProvider.RAIN && contractsLoading;
const isWalletDepositPending =
!isProduction && watchedFrom === CardDepositSource.WALLET && depositStatus === Status.PENDING;
+ const isWalletCardDepositPending =
+ isProduction &&
+ watchedFrom === CardDepositSource.WALLET &&
+ walletCardDepositStatus.status === Status.PENDING;
const disabled =
bridgeStatus === Status.PENDING ||
swapAndBridgeStatus === Status.PENDING ||
isWalletDepositPending ||
+ isWalletCardDepositPending ||
(watchedFrom !== CardDepositSource.BORROW && isEstimatedUSDCLoading) ||
(watchedFrom === CardDepositSource.BORROW && isRateLoading) ||
isFundingAddressLoading ||
+ (isWalletSourceGaslessGated && !hasSelectedWalletToken) ||
!isValid ||
!watchedAmount;
@@ -1030,8 +1155,9 @@ export default function CardDepositInternalForm() {
try {
schema.parse({ amount: watchedAmount });
return null;
- } catch (error: any) {
- return error.errors?.[0]?.message || null;
+ } catch (error: unknown) {
+ const err = error as { issues?: { message?: string }[] };
+ return err.issues?.[0]?.message ?? null;
}
}, [watchedAmount, schema]);
@@ -1104,6 +1230,31 @@ export default function CardDepositInternalForm() {
}
}, [showBorrowOption, watchedFrom, setValue]);
+ const fundingAddress = useMemo(
+ () => getCardFundingAddress(cardDetails, provider, contracts ?? undefined),
+ [cardDetails, provider, contracts],
+ );
+
+ const externalWalletDescription = useMemo(
+ () => (
+
+
+ Transfer USDC on Base chain.
+
+ Linking.openURL(BASE_USDC_TOKEN_URL)}
+ className="web:hover:opacity-50"
+ >
+
+ See token address
+
+
+
+
+ ),
+ [],
+ );
+
return (
- {watchedFrom === CardDepositSource.BORROW ? (
+ {watchedFrom === CardDepositSource.EXTERNAL ? (
+
+ {isFundingAddressLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ ) : watchedFrom === CardDepositSource.BORROW ? (
setModal(CARD_DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR)}
+ />
+ ) : undefined
+ }
/>
)}
-
+ {watchedFrom !== CardDepositSource.EXTERNAL && }
- {watchedFrom !== CardDepositSource.BORROW && (
-
+ {watchedFrom !== CardDepositSource.BORROW &&
+ watchedFrom !== CardDepositSource.EXTERNAL && (
+
+ )}
+
+ {isWalletSourceGaslessGated && (
+
+
+
+ Gasless deposit
+
+
)}
-
+ {watchedFrom !== CardDepositSource.EXTERNAL && (
+
+ )}
- {watchedFrom === CardDepositSource.BORROW ? (
+ {watchedFrom === CardDepositSource.EXTERNAL ? null : watchedFrom ===
+ CardDepositSource.BORROW ? (
{
const isOptions = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_OPTIONS.name;
const isInternal = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM.name;
const isExternal = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_EXTERNAL_FORM.name;
+ const isTokenSelector = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR.name;
const isTransactionStatus = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_TRANSACTION_STATUS.name;
const shouldAnimate = previousModal.name !== CARD_DEPOSIT_MODAL.CLOSE.name;
const isForward = currentModal.number > previousModal.number;
@@ -90,16 +94,18 @@ const CardDepositModalProvider = () => {
const getTitle = useCallback(() => {
if (isTransactionStatus) return undefined;
+ if (isTokenSelector) return 'Select token';
return 'Deposit to Card';
- }, [isTransactionStatus]);
+ }, [isTransactionStatus, isTokenSelector]);
const getContentKey = useCallback(() => {
if (isTransactionStatus) return 'transaction-status';
if (isOptions) return 'options';
if (isInternal) return 'internal';
if (isExternal) return 'external';
+ if (isTokenSelector) return 'token-selector';
return 'options';
- }, [isTransactionStatus, isOptions, isInternal, isExternal]);
+ }, [isTransactionStatus, isOptions, isInternal, isExternal, isTokenSelector]);
const getContent = useCallback(() => {
if (isTransactionStatus) {
@@ -118,12 +124,14 @@ const CardDepositModalProvider = () => {
if (isOptions) return ;
if (isInternal) return ;
if (isExternal) return ;
+ if (isTokenSelector) return ;
return ;
}, [
isTransactionStatus,
isOptions,
isInternal,
isExternal,
+ isTokenSelector,
transaction.amount,
handleTransactionStatusPress,
]);
@@ -144,8 +152,15 @@ const CardDepositModalProvider = () => {
);
const handleBackPress = useCallback(() => {
+ if (isTokenSelector) {
+ // Preserve the Wallet source so the internal form re-mounts on the
+ // wallet option (the only path that opens the token selector).
+ setSource(CardDepositSource.WALLET);
+ setModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM);
+ return;
+ }
setModal(CARD_DEPOSIT_MODAL.CLOSE);
- }, [setModal]);
+ }, [isTokenSelector, setSource, setModal]);
return (
{
title={getTitle()}
containerClassName="min-h-[42rem] overflow-y-auto flex-1"
contentKey={getContentKey()}
- showBackButton={isInternal && !isTransactionStatus}
+ showBackButton={(isInternal || isTokenSelector) && !isTransactionStatus}
onBackPress={handleBackPress}
shouldAnimate={shouldAnimate}
isForward={isForward}
diff --git a/components/Card/CardDepositTokenSelector.tsx b/components/Card/CardDepositTokenSelector.tsx
new file mode 100644
index 000000000..8add14d63
--- /dev/null
+++ b/components/Card/CardDepositTokenSelector.tsx
@@ -0,0 +1,58 @@
+import React, { useCallback, useMemo } from 'react';
+import { arbitrum, base, fuse, mainnet, polygon } from 'viem/chains';
+import { useShallow } from 'zustand/react/shallow';
+
+import { WalletTokenSelectorScreen } from '@/components/WalletTokenSelector';
+import { CARD_DEPOSIT_MODAL } from '@/constants/modals';
+import { TokenBalance } from '@/lib/types';
+import { useCardDepositStore, CardDepositSource } from '@/store/useCardDepositStore';
+import { useDepositStore } from '@/store/useDepositStore';
+
+const SUPPORTED_CHAIN_IDS = [mainnet.id, polygon.id, base.id, arbitrum.id, fuse.id];
+const SUPPORTED_TOKEN_SYMBOLS = ['USDC'];
+
+/**
+ * Token selector for the Card deposit "from wallet" flow. Reuses the
+ * generalized WalletTokenSelectorScreen (same screen as Savings) filtered
+ * to USDC across the five supported chains, and navigates back to the card
+ * deposit internal form on selection.
+ */
+const CardDepositTokenSelector: React.FC = () => {
+ const { setSrcChainId, setPrincipalToken } = useDepositStore(
+ useShallow(state => ({
+ setSrcChainId: state.setSrcChainId,
+ setPrincipalToken: state.setPrincipalToken,
+ })),
+ );
+ const { setModal, setSource } = useCardDepositStore(
+ useShallow(state => ({
+ setModal: state.setModal,
+ setSource: state.setSource,
+ })),
+ );
+
+ const handleTokenSelect = useCallback(
+ (token: TokenBalance) => {
+ setSrcChainId(token.chainId);
+ setPrincipalToken(token.contractTickerSymbol?.toUpperCase() || 'USDC');
+ setSource(CardDepositSource.WALLET);
+ setModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM);
+ },
+ [setSrcChainId, setPrincipalToken, setSource, setModal],
+ );
+
+ const supportedChainIds = useMemo(() => SUPPORTED_CHAIN_IDS, []);
+ const supportedTokenSymbols = useMemo(() => SUPPORTED_TOKEN_SYMBOLS, []);
+
+ return (
+
+ );
+};
+
+export default CardDepositTokenSelector;
diff --git a/components/Card/CardStatusPage.tsx b/components/Card/CardStatusPage.tsx
index 7affc752b..e85182b5f 100644
--- a/components/Card/CardStatusPage.tsx
+++ b/components/Card/CardStatusPage.tsx
@@ -9,7 +9,7 @@ import { getAsset } from '@/lib/assets';
interface CardStatusPageProps {
title: string;
- description: string;
+ description?: string;
children?: ReactNode;
}
@@ -37,9 +37,11 @@ export function CardStatusPage({ title, description, children }: CardStatusPageP
{title}
-
- {description}
-
+ {description ? (
+
+ {description}
+
+ ) : null}
{children}
diff --git a/components/CardWaitlist/CardFeesModal.tsx b/components/CardWaitlist/CardFeesModal.tsx
new file mode 100644
index 000000000..336ff340a
--- /dev/null
+++ b/components/CardWaitlist/CardFeesModal.tsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import { View } from 'react-native';
+import { Image } from 'expo-image';
+import { LinearGradient } from 'expo-linear-gradient';
+
+import AuthButton from '@/components/AuthButton';
+import GetCardButton from '@/components/CardWaitlist/GetCardButton';
+import SolidCardSummary from '@/components/CardWaitlist/SolidCardSummary';
+import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
+import { Text } from '@/components/ui/text';
+import { getAsset } from '@/lib/assets';
+
+const MODAL_STATE: ModalState = { name: 'card-fees', number: 1 };
+const CLOSE_STATE: ModalState = { name: 'close', number: 0 };
+
+interface CardFeesModalProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+type DetailItemProps = {
+ icon: ReturnType;
+ title: string;
+ description: React.ReactNode;
+};
+
+const DetailItem = ({ icon, title, description }: DetailItemProps) => (
+
+
+
+ {title}
+ {typeof description === 'string' ? (
+ {description}
+ ) : (
+ description
+ )}
+
+
+);
+
+const CardFeesModal = ({ isOpen, onOpenChange }: CardFeesModalProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ More details
+
+
+
+ FX fee of just 1% on non-USD transactions
+
+
+ No cross-border fees
+
+
+ No international transaction fees
+
+
+ }
+ />
+
+
+
+
+
+ Start using instantly.
+
+
+
+ support
+
+
+ }
+ />
+
+
+
+
+ onOpenChange(false)} />
+
+
+
+ );
+};
+
+export default CardFeesModal;
diff --git a/components/CardWaitlist/CardWaitlistContainer.tsx b/components/CardWaitlist/CardWaitlistContainer.tsx
index be6d4af9b..6bd64cd87 100644
--- a/components/CardWaitlist/CardWaitlistContainer.tsx
+++ b/components/CardWaitlist/CardWaitlistContainer.tsx
@@ -17,7 +17,7 @@ const CardWaitlistContainer = ({ children }: CardWaitlistContainerProps) => {
start={isScreenMedium ? { x: 0.5, y: 0 } : { x: 0, y: 0.5 }}
end={isScreenMedium ? { x: 0.6, y: 1 } : { x: 1, y: 0.7 }}
className="overflow-hidden rounded-twice web:md:flex web:md:flex-row"
- style={{ minHeight: 500, ...(Platform.OS === 'web' ? {} : { borderRadius: 20 }) }}
+ style={{ minHeight: 470, ...(Platform.OS === 'web' ? {} : { borderRadius: 20 }) }}
>
{isScreenMedium ? (
{
- const { isScreenMedium } = useDimension();
-
return (
-
- Card
-
- {isScreenMedium ? (
-
-
- Spend with Visa and earn 3% cashback on every purchase.
-
-
- Non-custodial, secure by design, and ready to use with Apple or Google Pay.
-
-
- ) : (
-
-
- Spend with Visa and earn 3% cashback on every purchase. Non-custodial, secure by design,
- and ready to use with Apple or Google Pay.
-
-
- )}
+
+ Free Visa Card
);
};
diff --git a/components/CardWaitlist/CardWaitlistPage.tsx b/components/CardWaitlist/CardWaitlistPage.tsx
index c7f18c73f..a2aa353bf 100644
--- a/components/CardWaitlist/CardWaitlistPage.tsx
+++ b/components/CardWaitlist/CardWaitlistPage.tsx
@@ -1,182 +1,11 @@
-import React, { useEffect, useState } from 'react';
-import { ActivityIndicator, View } from 'react-native';
-import { Image } from 'expo-image';
-
-import AuthButton from '@/components/AuthButton';
-import CardWaitlistContainer from '@/components/CardWaitlist/CardWaitlistContainer';
-import CardWaitlistHeader from '@/components/CardWaitlist/CardWaitlistHeader';
-import CardWaitlistHeaderButtons from '@/components/CardWaitlist/CardWaitlistHeaderButtons';
-import CardWaitlistHeaderTitle from '@/components/CardWaitlist/CardWaitlistHeaderTitle';
-import { CashbackIcon } from '@/components/CardWaitlist/CashbackIcon';
-import GetCardButton from '@/components/CardWaitlist/GetCardButton';
-import { Text } from '@/components/ui/text';
+import CardWaitlistPageDesktop from '@/components/CardWaitlist/CardWaitlistPageDesktop';
+import CardWaitlistPageMobile from '@/components/CardWaitlist/CardWaitlistPageMobile';
import { useDimension } from '@/hooks/useDimension';
-import useUser from '@/hooks/useUser';
-import { getCashbackPercentage } from '@/lib/api';
-import { getAsset } from '@/lib/assets';
-import { cn } from '@/lib/utils';
-
-type ClassNames = {
- container?: string;
- title?: string;
- description?: string;
-};
-
-type FeatureProps = {
- icon: number | React.ReactElement;
- title: string;
- description: string | React.ReactNode;
- classNames?: ClassNames;
-};
-
-const Feature = ({ icon, title, description, classNames }: FeatureProps) => {
- return (
-
- {React.isValidElement(icon) ? (
- icon
- ) : (
-
- )}
-
- {title}
- {typeof description === 'string' ? (
-
- {description}
-
- ) : (
- description
- )}
-
-
- );
-};
-
-const getFeatures = (cashbackPercentage: number) => [
- {
- icon: getAsset('images/card-global.png'),
- title: 'Global acceptance',
- description: '200M+ Visa merchants',
- classNames: {
- container: 'items-center',
- },
- },
- {
- icon: ,
- title: 'Earn while you spend',
- description: `${Math.round(cashbackPercentage * 100)}% cashback for every purchase`,
- classNames: {
- container: 'items-center',
- description: 'max-w-full md:max-w-full',
- },
- },
- {
- icon: getAsset('images/card-safe.png'),
- title: 'Secure by design',
- description: 'Non-custodial, secured by passkeys',
- },
- {
- icon: getAsset('images/card-effortless.png'),
- title: 'Effortless setup',
- description: (
-
- Start using instantly
-
-
- support
-
-
- ),
- },
-];
const CardWaitlistPage = () => {
- const { user } = useUser();
- const [loading, setLoading] = useState(true);
- const [cashbackPercentage, setCashbackPercentage] = useState(0.03); // Default to 3%
const { isScreenMedium } = useDimension();
- useEffect(() => {
- const checkWaitlistStatus = async () => {
- if (user?.email) {
- try {
- const [cashbackResponse] = await Promise.all([getCashbackPercentage()]);
- setCashbackPercentage(cashbackResponse.percentage);
- } catch (error) {
- console.error('Error fetching cashback:', error);
- }
- }
- setLoading(false);
- };
-
- checkWaitlistStatus();
- }, [user?.email]);
-
- if (loading) {
- return (
-
-
- {isScreenMedium && }
-
- }
- >
-
-
-
-
-
-
- );
- }
-
- return (
-
-
- {isScreenMedium && }
-
- }
- >
-
-
-
-
- Introducing the Solid Card
-
-
-
-
- {getFeatures(cashbackPercentage).map(feature => (
-
- ))}
-
-
- {!isScreenMedium && (
-
- )}
-
-
-
-
-
-
-
-
-
- );
+ return isScreenMedium ? : ;
};
export default CardWaitlistPage;
diff --git a/components/CardWaitlist/CardWaitlistPageDesktop.tsx b/components/CardWaitlist/CardWaitlistPageDesktop.tsx
new file mode 100644
index 000000000..5baac124c
--- /dev/null
+++ b/components/CardWaitlist/CardWaitlistPageDesktop.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { Pressable, View } from 'react-native';
+import { ChevronRight } from 'lucide-react-native';
+
+import AuthButton from '@/components/AuthButton';
+import CardFeesModal from '@/components/CardWaitlist/CardFeesModal';
+import CardWaitlistContainer from '@/components/CardWaitlist/CardWaitlistContainer';
+import CardWaitlistHeader from '@/components/CardWaitlist/CardWaitlistHeader';
+import CardWaitlistHeaderButtons from '@/components/CardWaitlist/CardWaitlistHeaderButtons';
+import CardWaitlistHeaderTitle from '@/components/CardWaitlist/CardWaitlistHeaderTitle';
+import GetCardButton from '@/components/CardWaitlist/GetCardButton';
+import SolidCardSummary from '@/components/CardWaitlist/SolidCardSummary';
+import { Text } from '@/components/ui/text';
+
+const CardWaitlistPageDesktop = () => {
+ const [feesOpen, setFeesOpen] = useState(false);
+
+ return (
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+ setFeesOpen(true)}
+ className="flex-row items-center gap-1 web:hover:opacity-70"
+ >
+ Fees and charges
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CardWaitlistPageDesktop;
diff --git a/components/CardWaitlist/CardWaitlistPageMobile.tsx b/components/CardWaitlist/CardWaitlistPageMobile.tsx
new file mode 100644
index 000000000..b0e434a6c
--- /dev/null
+++ b/components/CardWaitlist/CardWaitlistPageMobile.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react';
+import { Pressable, View } from 'react-native';
+import { Image } from 'expo-image';
+import { ChevronRight } from 'lucide-react-native';
+
+import AuthButton from '@/components/AuthButton';
+import CardFeesModal from '@/components/CardWaitlist/CardFeesModal';
+import GetCardButton from '@/components/CardWaitlist/GetCardButton';
+import PageLayout from '@/components/PageLayout';
+import { Text } from '@/components/ui/text';
+import { getAsset } from '@/lib/assets';
+
+const CardWaitlistPageMobile = () => {
+ const [feesOpen, setFeesOpen] = useState(false);
+
+ return (
+ }
+ >
+
+
+
+
+
+
+ Free Visa Card
+
+ 3% cashback on all purchases. No monthly charge or hidden fees
+
+
+
+
+ setFeesOpen(true)}
+ className="flex-row items-center gap-1 web:hover:opacity-70"
+ >
+ Fees and charges
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CardWaitlistPageMobile;
diff --git a/components/CardWaitlist/GetCardButton.tsx b/components/CardWaitlist/GetCardButton.tsx
index 524dfae26..71b7d5bac 100644
--- a/components/CardWaitlist/GetCardButton.tsx
+++ b/components/CardWaitlist/GetCardButton.tsx
@@ -5,11 +5,18 @@ import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { track } from '@/lib/analytics';
+import { cn } from '@/lib/utils';
-const GetCardButton = () => {
+interface GetCardButtonProps {
+ className?: string;
+ onPress?: () => void;
+}
+
+const GetCardButton = ({ className, onPress }: GetCardButtonProps) => {
const router = useRouter();
const handleGetCard = async () => {
+ onPress?.();
track(TRACKING_EVENTS.CARD_GET_CARD_PRESSED, {
source: 'card_waitlist',
});
@@ -18,7 +25,7 @@ const GetCardButton = () => {
};
return (
-