diff --git a/app/(protected)/(tabs)/settings/_layout.tsx b/app/(protected)/(tabs)/settings/_layout.tsx index c832755df..df41bdbff 100644 --- a/app/(protected)/(tabs)/settings/_layout.tsx +++ b/app/(protected)/(tabs)/settings/_layout.tsx @@ -57,6 +57,14 @@ export default function SettingsLayout() { headerShown: false, }} /> + { - setShowDeleteModal(true); - }; + // Email-first signups get a generated `user_…` handle nobody chose or uses. + const username = user?.username && !user.username.startsWith('user_') ? user.username : undefined; - const confirmDelete = async () => { - setIsDeleting(true); - try { - await handleDeleteAccount(); - } catch (_error) { - Alert.alert('Error', 'Failed to delete account. Please try again.'); - } finally { - setIsDeleting(false); - setShowDeleteModal(false); - } - }; + const verification = + cardStatus?.kycStatus === KycStatus.APPROVED + ? { badge: 'Verified' } + : isKycAwaitingDecision(cardStatus) + ? { value: 'In review' } + : { value: 'Not verified' }; const mobileHeader = ( @@ -61,136 +66,57 @@ export default function Account() { ); - const deleteModal = ( - !isDeleting && setShowDeleteModal(false)} - > - - - - Delete Account - !isDeleting && setShowDeleteModal(false)}> - - - - - - Are you sure you want to delete your account? This action cannot be undone and will: - - - - • Remove all your data - • Cancel any active cards - • Delete your transaction history - • Remove access to your wallet - - - - setShowDeleteModal(false)} - className="mr-2 flex-1 rounded-xl bg-gray-700 py-4" - disabled={isDeleting} - > - Cancel - - - - {isDeleting ? ( - - ) : ( - Delete Account - )} - - - - - - ); - return ( + } scrollable={false} > - {/* Top Content */} - {/* Email Section - shown for all users who have email */} - {/* {user?.email && ( - <> - Email - - } - isDesktop={isDesktop} - hideIconBackground - titleStyle="font-medium" - /> - - - )} */} - - {/* User Name Section - shown for legacy users or users with custom username */} - {user?.username && !user.username.startsWith('user_') && ( - <> - User Name - - } - isDesktop={isDesktop} - hideIconBackground - titleStyle="font-medium" - /> - - - )} - - {/* Fallback for users with neither email nor username (should not happen) */} - {!user?.email && (!user?.username || user.username.startsWith('user_')) && ( - <> - Account - - } - isDesktop={isDesktop} - hideIconBackground - titleStyle="font-medium" - /> - - - )} - - {/* Wallet Address Section */} - Wallet address - - } - isDesktop={isDesktop} - hideIconBackground - titleStyle="font-medium" - customAction={ + Profile + + {username ? ( + } + title="Username" + subtitle={`@${username}`} + accessory={ + + } + /> + ) : null} + } + title="Email" + subtitle={user?.email || 'Not set'} + onPress={() => router.push('/settings/email' as Href)} + accessibilityLabel={user?.email ? 'Change email' : 'Add an email'} + /> + } + title="Identity verification" + isLoading={isCardStatusLoading} + {...verification} + /> + + + Wallet + + } + title="Wallet address" + subtitle={user?.safeAddress ? eclipseAddress(user.safeAddress as Address) : '—'} + accessory={ user?.safeAddress ? ( - + + + This is your self-custodial wallet. Only you can move funds out of it. + - {/* Spacer */} - {/* Delete Account Section - at bottom */} - - - + + } title="Delete account" - icon={ - - } - isDesktop={isDesktop} - customAction={} - titleStyle="text-[#FF7D7D]" - hideIconBackground + tone="danger" + onPress={() => setIsDeleteSheetOpen(true)} /> - + + + {DELETE_ACCOUNT_FOOTNOTE} + diff --git a/app/(protected)/(tabs)/settings/index.tsx b/app/(protected)/(tabs)/settings/index.tsx index 545c7e065..acf0517c4 100644 --- a/app/(protected)/(tabs)/settings/index.tsx +++ b/app/(protected)/(tabs)/settings/index.tsx @@ -1,44 +1,60 @@ -import React from 'react'; +import React, { ReactNode, useCallback, useState } from 'react'; import { Linking, Platform, Pressable, View } from 'react-native'; -import Svg, { Path } from 'react-native-svg'; import * as Application from 'expo-application'; -import { Image } from 'expo-image'; -import * as IntentLauncher from 'expo-intent-launcher'; -import { Href, router } from 'expo-router'; -import { Bell, UsersRound } from 'lucide-react-native'; - -import Navbar from '@/components/Navbar'; +import { Href, router, useFocusEffect } from 'expo-router'; +import { + Bell, + ChevronRight, + CircleCheck, + CreditCard, + FileText, + LifeBuoy, + LogOut, + Pencil, + ShieldCheck, + Sparkle, + Star, + UserRound, + UsersRound, +} from 'lucide-react-native'; + +import { SPEND_MODE_COPY } from '@/components/Card/NewCardDetails/SpendMode/spendModes'; import WhatsNewButton from '@/components/Navbar/WhatsNewButton'; import PageLayout from '@/components/PageLayout'; -import { SettingsCard } from '@/components/Settings'; +import EditAvatarSheet from '@/components/Profile/EditAvatarSheet'; +import ProfileAvatar from '@/components/Profile/ProfileAvatar'; +import { ProfileRow, ProfileRowGroup, ProfileSectionLabel } from '@/components/Profile/ProfileRow'; import TrustpilotReviewCard from '@/components/Trustpilot/TrustpilotReviewCard'; import { BackButton } from '@/components/ui/back-button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; import { getTierDisplayName } from '@/constants/rewards'; -import { useDimension } from '@/hooks/useDimension'; +import { useCardSpendRegistration } from '@/hooks/useCardSpendRegistration'; +import { useCardStatus } from '@/hooks/useCardStatus'; import useNotificationPermissionStatus from '@/hooks/useNotificationPermissionStatus'; -import { useRewardsUserData } from '@/hooks/useRewards'; +import { useReferralSummary, useRewardsUserData } from '@/hooks/useRewards'; +import { useTotp } from '@/hooks/useTotp'; import useUser from '@/hooks/useUser'; -import { getAsset } from '@/lib/assets'; -import { RewardsTier } from '@/lib/types'; -import { cn, getUserDisplayName } from '@/lib/utils'; +import { KycStatus, RewardsTier } from '@/lib/types'; +import { + formatWholeDollars, + getUserDisplayName, + hasCard, + hasCardStatusWithRainApplication, + hasPendingCard, +} from '@/lib/utils'; +import { isKycAwaitingDecision } from '@/lib/utils/kyc/verificationProgress'; +import { useProfileAvatarColorId } from '@/store/useProfileAvatarStore'; import { openSupportDrawer } from '@/store/useSupportDrawerStore'; -const AccountDetailsIcon = getAsset('images/settings_account_details.png'); -const SecurityIcon = getAsset('images/settings_security.png'); -const HelpSupportIcon = getAsset('images/settings_help_and_support.png'); -const LogoutIcon = getAsset('images/settings_logout.png'); +const LEGAL_URL = + 'https://support.solid.xyz/en/articles/13184959-legal-privacy-policy-terms-conditions#h_5cf45398ce'; -type MobileSettingsRow = { - title: string; - icon: React.ReactNode; - href?: Href; - onPress?: () => void; - /** Right-aligned status text, e.g. the notification permission state. */ - description?: string; - descriptionClassName?: string; -}; +const ICON_SIZE = 18; +const ICON_COLOR = '#FFFFFF'; + +const formatPoints = (points: number) => + new Intl.NumberFormat('en-us', { notation: 'compact', maximumFractionDigits: 1 }).format(points); const mobileHeader = ( @@ -47,227 +63,86 @@ const mobileHeader = ( ); -const IconImage = ({ - source, - width, - height, -}: { - source: ReturnType; - width: number; - height: number; -}) => ; - -const ProfileAvatarIcon = () => ( - - - - -); - -const RewardsIcon = () => ( - - - - -); - -const RowChevron = () => ( - - - -); - -const MembershipBadge = ({ tier }: { tier: RewardsTier }) => { - return ( - - - - - {getTierDisplayName(tier)} member - - ); +const openLegal = () => { + if (Platform.OS === 'web') { + window.open(LEGAL_URL, '_blank'); + } else { + void Linking.openURL(LEGAL_URL); + } }; -const SettingsRow = ({ - title, +/** One of the two tiles under the profile: Rewards and Refer & earn. */ +const ProfileTile = ({ icon, - href, + title, + subtitle, + subtitleClassName = 'text-[#8E8E8E]', onPress, - description, - descriptionClassName, - showDivider = false, -}: MobileSettingsRow & { showDivider?: boolean }) => { - const handlePress = () => { - if (href) { - router.push(href); - return; - } - - onPress?.(); - }; - - return ( - - - {icon} - {title} - - {description ? ( - - {description} - - ) : null} - - - ); -}; - -const SettingsRowGroup = ({ rows }: { rows: MobileSettingsRow[] }) => { - return ( - - {rows.map((row, index) => ( - 0} /> - ))} +}: { + icon: ReactNode; + title: string; + subtitle?: string; + subtitleClassName?: string; + onPress: () => void; +}) => ( + + + {icon} + - ); -}; + {title} + + {subtitle ?? ' '} + + +); -const MobileSettings = () => { +/** + * Settings → Profile (Figma "Profile"). + * + * Who you are at the top — avatar, name, tier and verification — then the two + * things worth coming back for (rewards, referrals), and the account, security + * and support rows below. Statements are not in this release. + */ +export default function Settings() { const { user, handleLogout } = useUser(); - const { data: rewardsData } = useRewardsUserData(); - const currentTier = rewardsData?.currentTier ?? RewardsTier.CORE; - const displayName = getUserDisplayName(user, 18); - const { status: notificationStatus, request: requestNotificationPermission } = - useNotificationPermissionStatus(); - - /** - * The way back into push notifications. - * - * Onboarding asks once, over the wallet screen, and marks itself seen on any - * dismissal — including a swipe that never reached the OS prompt. After that - * the sheet is unreachable and nothing else in the app ever asks, so without a - * row here a cardholder who swiped it away has no route to notifications at - * all. That is not hypothetical: it is why card-payment pushes go missing, and - * it bites iOS hardest, since Android below 13 grants the permission at - * install and registers a token whether or not the sheet was ever seen. - */ - const handleNotificationsPress = () => { - // Never asked: the OS prompt is the only thing that can change this, and - // iOS lists an app under Settings → Notifications only once it has asked, - // so sending them to Settings would show them a page with nothing on it. - if (notificationStatus === 'Undetermined') { - void requestNotificationPermission(); - return; - } + const [isAvatarSheetOpen, setIsAvatarSheetOpen] = useState(false); + const avatarColorId = useProfileAvatarColorId(user?.userId); - // Already answered — only the OS can change that answer now. - if (Platform.OS === 'android') { - void IntentLauncher.startActivityAsync( - IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS, - { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } }, - ); - return; - } - - void Linking.openSettings(); - }; - - const notificationRows: MobileSettingsRow[] = - Platform.OS === 'web' - ? [] - : [ - { - title: 'Push Notifications', - icon: , - onPress: handleNotificationsPress, - description: notificationStatus, - descriptionClassName: - notificationStatus === 'Authorized' - ? 'text-[#94F27F]' - : notificationStatus === 'Denied' - ? 'text-[#FFB347]' - : 'text-[#ACACAC]', - }, - ]; + const { data: rewardsData } = useRewardsUserData(); + const { data: referralSummary } = useReferralSummary(); + const { data: cardStatus } = useCardStatus(); + const { canChangeMode, mode } = useCardSpendRegistration(); + const { isVerified: isTotpOn, isLoading: isTotpLoading, refetch: refetchTotp } = useTotp(); + const { status: notificationStatus } = useNotificationPermissionStatus(); + + // Setting up 2FA happens a screen away, so "Add 2FA" is re-checked on the way back. + useFocusEffect( + useCallback(() => { + void refetchTotp(); + }, [refetchTotp]), + ); - const rowGroups: MobileSettingsRow[][] = [ - [ - { - title: 'Rewards', - icon: , - href: path.REWARDS, - }, - { - title: 'Refer & Earn', - icon: , - href: path.REFERRAL_PROGRAM, - }, - ], - [ - { - title: 'Account details', - icon: , - href: '/settings/account' as Href, - }, - { - title: 'Security', - icon: , - href: '/settings/security' as Href, - }, - ...notificationRows, - ], - [ - { - title: 'Help & Support', - icon: , - onPress: () => openSupportDrawer(), - }, - ], - [ - { - title: 'Sign out', - icon: , - onPress: handleLogout, - }, - ], - ]; + const displayName = getUserDisplayName(user, 18); + const currentTier = rewardsData?.currentTier ?? RewardsTier.CORE; + const isVerified = cardStatus?.kycStatus === KycStatus.APPROVED; + const userHasCard = hasCard(cardStatus); + const referrerUsd = referralSummary?.rewards.referrerUsd; + + // The card row names its destination rather than going through the `/card` + // shim, which is kept only for old links. + const cardHref: Href = userHasCard + ? path.CARD_INFO + : hasPendingCard(cardStatus) || + isKycAwaitingDecision(cardStatus) || + hasCardStatusWithRainApplication(cardStatus) + ? path.CARD_ACTIVATE + : path.CARD_COUNTRY_SELECTION; return ( { useDesktopBreakpoint className="bg-[#111111]" contentClassName="bg-[#111111]" + additionalContent={ + + } > router.push('/settings/account' as Href)} - className="relative h-[123px] w-[123px] items-center justify-center rounded-full bg-[#1c1c1c] active:opacity-80" - accessibilityLabel="Edit account details" + onPress={() => setIsAvatarSheetOpen(true)} accessibilityRole="button" + accessibilityLabel="Edit avatar" + className="active:opacity-80" > - + + + + - - {displayName} - - - + + {displayName} + {user?.email ? ( + + {user.email} + + ) : null} + + + router.push(path.REWARDS)} + accessibilityRole="button" + className="h-8 flex-row items-center gap-1.5 rounded-full bg-[#1C1C1C] pl-3 pr-2 active:opacity-70" + > + + + {getTierDisplayName(currentTier)} member + + + + {isVerified ? ( + + + Verified + + ) : null} - - {rowGroups.map((rows, index) => ( - - ))} - {/* Web/desktop only — native asks for a rating through the OS sheet. */} - - - - - ); -}; - -const DesktopSettings = () => { - const { handleLogout } = useUser(); - const { isDesktop } = useDimension(); - const { status: notificationStatus, request: requestNotificationPermission } = - useNotificationPermissionStatus(); - - const notificationStatusColor = - notificationStatus === 'Authorized' - ? 'text-[#94F27F]' - : notificationStatus === 'Denied' - ? 'text-[#FFB347]' - : 'text-[#ACACAC]'; - - const handleLegalPress = () => { - const url = - 'https://support.solid.xyz/en/articles/13184959-legal-privacy-policy-terms-conditions#h_5cf45398ce'; - if (Platform.OS === 'web') { - window.open(url, '_blank'); - } else { - Linking.openURL(url); - } - }; - - const desktopHeader = ( - <> - - - - - Settings - + + } + title="Rewards" + subtitle={ + rewardsData ? `${formatPoints(rewardsData.totalPoints ?? 0)} points` : undefined + } + onPress={() => router.push(path.REWARDS)} + /> + } + title="Refer & earn" + subtitle={ + referrerUsd ? `Get ${formatWholeDollars(referrerUsd)} per friend` : 'Invite friends' + } + subtitleClassName="text-[#94F27F]" + onPress={() => router.push(path.REFERRAL_PROGRAM)} + /> - - - ); - - return ( - - - - - Account + + } title="Account details" - icon={} - link="/settings/account" - isDesktop={isDesktop} - hideIconBackground + onPress={() => router.push('/settings/account' as Href)} /> - + } + title="Card" + value={userHasCard && canChangeMode ? `${SPEND_MODE_COPY[mode].label} mode` : undefined} + onPress={() => router.push(cardHref)} + /> + - - Security & alerts + + } title="Security" - icon={} - link="/settings/security" - isDesktop={isDesktop} - hideIconBackground + value={!isTotpLoading && !isTotpOn ? 'Add 2FA' : undefined} + valueTone="warning" + onPress={() => router.push('/settings/security' as Href)} /> - - - {Platform.OS !== 'web' && ( - - } - onPress={() => { - // Nobody has asked this user yet, so there is nothing in the OS - // settings to turn on — iOS does not even list an app under - // Notifications until it has requested authorization once. The - // onboarding sheet is shown a single time and is not reachable - // again, so without this the only way out of `Undetermined` is - // to reinstall the app. Ask here instead. - if (notificationStatus === 'Undetermined') { - void requestNotificationPermission(); - return; - } - - // Already answered: only the OS can change that answer. - if (Platform.OS === 'android') { - IntentLauncher.startActivityAsync( - IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS, - { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } }, - ); - } else { - Linking.openSettings(); - } - }} - isDesktop={isDesktop} - hideIconBackground + {/* Push only exists in the apps; the web has nothing to switch on. */} + {Platform.OS !== 'web' ? ( + } + title="Notifications" + value={notificationStatus === 'Authorized' ? 'On' : 'Off'} + onPress={() => router.push('/settings/notifications' as Href)} /> - - )} - - - } + ) : null} + + + Support + + } + title="Help & support" onPress={() => openSupportDrawer()} - isDesktop={isDesktop} - hideIconBackground /> - - - - } - onPress={handleLogout} - isDesktop={isDesktop} - hideIconBackground + } + title="Legal & privacy" + onPress={openLegal} /> - - - - - Legal - - - - {Platform.OS !== 'web' && ( - - - {Application.applicationName || 'Solid'} v - {Application.nativeApplicationVersion || 'Unknown'} - Build{' '} - {Application.nativeBuildVersion || 'Unknown'} - - - )} + + + {/* Web/desktop only — native asks for a rating through the OS sheet. */} + + + + + Sign out + + + {Platform.OS !== 'web' ? ( + + {Application.applicationName || 'Solid'} v + {Application.nativeApplicationVersion || 'Unknown'} · Build{' '} + {Application.nativeBuildVersion || 'Unknown'} + + ) : null} ); -}; - -export default function Settings() { - // The sidebar's Profile item lands here, and desktop is the same redesigned - // profile screen, stretched inside the sidebar shell — see `SidebarShell` in - // `(protected)/_layout.tsx`. - return ; } diff --git a/app/(protected)/(tabs)/settings/notifications.tsx b/app/(protected)/(tabs)/settings/notifications.tsx new file mode 100644 index 000000000..8ecf9eb55 --- /dev/null +++ b/app/(protected)/(tabs)/settings/notifications.tsx @@ -0,0 +1,256 @@ +import { ReactNode } from 'react'; +import { ActivityIndicator, Linking, Platform, Pressable, View } from 'react-native'; +import * as Application from 'expo-application'; +import * as IntentLauncher from 'expo-intent-launcher'; +import { + ArrowDownLeft, + Bell, + ChevronRight, + CreditCard, + Megaphone, + ShieldCheck, + Sparkle, + Wallet, +} from 'lucide-react-native'; + +import Navbar from '@/components/Navbar'; +import PageLayout from '@/components/PageLayout'; +import { ProfileRow, ProfileRowGroup, ProfileSectionLabel } from '@/components/Profile/ProfileRow'; +import ToggleSwitch from '@/components/Profile/ToggleSwitch'; +import { BackButton } from '@/components/ui/back-button'; +import { Text } from '@/components/ui/text'; +import { useDimension } from '@/hooks/useDimension'; +import useNotificationPermissionStatus from '@/hooks/useNotificationPermissionStatus'; +import { useNotificationPreferences } from '@/hooks/useNotificationPreferences'; +import { NotificationPreferences } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +const ICON_SIZE = 18; +const ICON_COLOR = '#FFFFFF'; +const SETTINGS_APP = Platform.OS === 'android' ? 'Android Settings' : 'iOS Settings'; + +type PreferenceRow = { + key: keyof NotificationPreferences; + icon: ReactNode; + title: string; + subtitle: string; +}; + +const SECTIONS: { title: string; rows: (PreferenceRow | 'payment-approvals')[] }[] = [ + { + title: 'Card', + rows: [ + { + key: 'cardPayments', + icon: , + title: 'Card payments', + subtitle: 'Purchases, refunds and declines', + }, + 'payment-approvals', + ], + }, + { + title: 'Money', + rows: [ + { + key: 'depositsTransfers', + icon: , + title: 'Deposits & transfers', + subtitle: 'When money arrives or leaves', + }, + { + key: 'earn', + icon: , + title: 'Earn', + subtitle: 'Weekly yield summary', + }, + ], + }, + { + title: 'Rewards & news', + rows: [ + { + key: 'cashbackRewards', + icon: , + title: 'Cashback & rewards', + subtitle: 'Cashback earned, tier changes', + }, + { + key: 'productNews', + icon: , + title: 'Product news & offers', + subtitle: 'New features and promotions', + }, + ], + }, +]; + +/** + * The way back into push notifications, and what the OS says about them now. + * + * Onboarding asks once, over the wallet screen, and marks itself seen on any + * dismissal — including a swipe that never reached the OS prompt. After that + * the sheet is unreachable and nothing else in the app ever asks, so without + * this card a cardholder who swiped it away has no route to notifications at + * all. That is not hypothetical: it is why card-payment pushes go missing, and + * it bites iOS hardest, since Android below 13 grants the permission at install + * and registers a token whether or not the sheet was ever seen. + */ +const PushPermissionCard = () => { + const { status, request } = useNotificationPermissionStatus(); + + const handlePress = () => { + // Never asked: the OS prompt is the only thing that can change this, and + // iOS lists an app under Settings → Notifications only once it has asked, + // so sending them to Settings would show them a page with nothing on it. + if (status === 'Undetermined') { + void request(); + return; + } + + // Already answered — only the OS can change that answer now. + if (Platform.OS === 'android') { + void IntentLauncher.startActivityAsync( + IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS, + { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } }, + ); + return; + } + + void Linking.openSettings(); + }; + + const copy = + status === 'Authorized' + ? { title: 'Push notifications are on', detail: `Manage permission in ${SETTINGS_APP}` } + : status === 'Denied' + ? { title: 'Push notifications are off', detail: `Turn them on in ${SETTINGS_APP}` } + : { title: 'Turn on push notifications', detail: 'Get alerts for payments and deposits' }; + const isOn = status === 'Authorized'; + + return ( + + + + + + {copy.title} + {copy.detail} + + + + ); +}; + +/** + * Settings → Notifications (Figma "Notifications"). + * + * The OS permission at the top, then which kinds of push the account wants. + * The categories only appear once the backend stores them — until then there + * is nothing for a switch to change, so the screen shows the permission alone. + */ +export default function Notifications() { + const { isDesktop } = useDimension(); + const { preferences, isLoading, isError, update, isSaveError } = useNotificationPreferences(); + + const mobileHeader = ( + + + + Notifications + + + ); + + const desktopHeader = ( + <> + + + + + Notifications + + + + + ); + + return ( + + + + + {isLoading ? ( + + ) : isError ? ( + + Couldn't load your notification choices. Try again later. + + ) : preferences ? ( + <> + {SECTIONS.map(section => ( + + {section.title} + + {section.rows.map(row => + row === 'payment-approvals' ? ( + } + title="Payment approvals" + subtitle="3D Secure for online payments" + value="Always on" + /> + ) : ( + update({ [row.key]: value })} + accessibilityLabel={row.title} + /> + } + /> + ), + )} + + + ))} + {isSaveError ? ( + + Couldn't save that change. Please try again. + + ) : null} + + ) : null} + + + Security alerts and important account notices are always sent by email. + + + + ); +} diff --git a/components/Navbar/WhatsNewButton.tsx b/components/Navbar/WhatsNewButton.tsx index 0324acfb9..3e50318cd 100644 --- a/components/Navbar/WhatsNewButton.tsx +++ b/components/Navbar/WhatsNewButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Pressable } from 'react-native'; -import { X } from 'lucide-react-native'; +import { Sparkle, X } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; import { useWhatsNew } from '@/hooks/useWhatsNew'; @@ -15,10 +15,11 @@ const WhatsNewButton = ({ className }: { className?: string }) => { + What's new? { diff --git a/components/Profile/DeleteAccountSheet.tsx b/components/Profile/DeleteAccountSheet.tsx new file mode 100644 index 000000000..cb90357df --- /dev/null +++ b/components/Profile/DeleteAccountSheet.tsx @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, TextStyle, View } from 'react-native'; +import { router } from 'expo-router'; +import { Check, CircleAlert, Info, Trash2 } from 'lucide-react-native'; + +import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet'; +import { + CardSheetBody, + sheetBodyInset, +} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; +import SheetTextInput from '@/components/Card/NewCardDetails/SpendMode/SheetTextInput'; +import { Text } from '@/components/ui/text'; +import { path } from '@/constants/path'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { useCardTransactions } from '@/hooks/useCardTransactions'; +import { usePortfolio } from '@/hooks/usePortfolio'; +import useUser from '@/hooks/useUser'; +import { cn, hasCard } from '@/lib/utils'; + +import { + DELETE_CONFIRMATION_PHRASE, + DeleteAccountCheck, + getDeleteAccountChecks, + getDeleteSheetSubtitle, +} from './deleteAccount'; + +const DANGER = '#FF7D7D'; +const WARNING = '#F2B84B'; +const POSITIVE = '#94F27F'; +const MUTED = '#8E8E8E'; + +const INPUT_STYLE: TextStyle = { + height: 52, + width: '100%', + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + backgroundColor: '#111111', + paddingHorizontal: 16, + fontSize: 16, + color: '#FFFFFF', +}; + +const CheckIcon = ({ state }: { state: DeleteAccountCheck['state'] }) => { + switch (state) { + case 'loading': + return ; + case 'ok': + return ; + case 'info': + return ; + default: + return ; + } +}; + +interface DeleteAccountSheetBodyProps extends CardSheetBody { + onClose: () => void; +} + +/** + * The sheet's contents, mounted only while it is open: the balance read behind + * the checks is the whole portfolio, and Account details has no other use for it. + */ +const DeleteAccountSheetBody = ({ + session, + topPadding, + presentation, + onClose, +}: DeleteAccountSheetBodyProps) => { + const { handleDeleteAccount } = useUser(); + const portfolio = usePortfolio(); + const { data: cardStatus, isLoading: isCardStatusLoading } = useCardStatus(); + const userHasCard = hasCard(cardStatus); + const cardTransactions = useCardTransactions({ enabled: userHasCard }); + const [confirmation, setConfirmation] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setConfirmation(''); + setError(null); + }, [session]); + + // Payments still settling are recent, so the first page holds any there are. + const pendingCardPayments = cardTransactions.isError + ? undefined + : cardTransactions.data?.pages[0]?.data.filter( + transaction => transaction.status?.toLowerCase() === 'pending', + ).length; + + const { checks, canDelete } = useMemo( + () => + getDeleteAccountChecks({ + balanceUsd: portfolio.totalAssets, + isBalanceLoading: portfolio.isLoading, + isBalanceComplete: portfolio.isComplete, + hasCard: userHasCard, + pendingCardPayments, + isCardPaymentsLoading: isCardStatusLoading || (userHasCard && cardTransactions.isLoading), + }), + [ + portfolio.totalAssets, + portfolio.isLoading, + portfolio.isComplete, + userHasCard, + pendingCardPayments, + isCardStatusLoading, + cardTransactions.isLoading, + ], + ); + + const isArmed = + canDelete && confirmation.trim().toUpperCase() === DELETE_CONFIRMATION_PHRASE && !isDeleting; + + const confirmDelete = async () => { + if (!isArmed) return; + setIsDeleting(true); + setError(null); + try { + // Signs out and leaves for onboarding on success, so there is nothing to close here. + await handleDeleteAccount(); + } catch { + setError("Couldn't delete your account. Please try again."); + setIsDeleting(false); + } + }; + + const openAssets = () => { + onClose(); + router.push(path.ASSETS); + }; + + return ( + + + + + + Delete your Solid account? + + + {getDeleteSheetSubtitle(userHasCard)} + + + + {checks.map(check => ( + + + + + + {check.title} + {check.detail ? ( + {check.detail} + ) : null} + + {check.hasAction ? ( + + Withdraw + + ) : null} + + ))} + + + + Type {DELETE_CONFIRMATION_PHRASE} to confirm + + + {error ? {error} : null} + + + {isDeleting ? ( + + ) : ( + Delete account + )} + + + Keep my account + + + ); +}; + +interface DeleteAccountSheetProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Delete account (Figma "Delete your Solid account?"). + * + * Lists what has to happen first — balance withdrawn, card payments settled — + * and asks for the word DELETE before the button arms. + */ +const DeleteAccountSheet = ({ isOpen, onOpenChange }: DeleteAccountSheetProps) => ( + + {body => onOpenChange(false)} />} + +); + +export default DeleteAccountSheet; diff --git a/components/Profile/EditAvatarSheet.tsx b/components/Profile/EditAvatarSheet.tsx new file mode 100644 index 000000000..e369daff2 --- /dev/null +++ b/components/Profile/EditAvatarSheet.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet'; +import { sheetBodyInset } from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { useProfileAvatarStore } from '@/store/useProfileAvatarStore'; + +import { + AVATAR_COLORS, + AvatarColorId, + DEFAULT_AVATAR_COLOR_ID, + getAvatarInitial, +} from './avatarColors'; +import ProfileAvatar from './ProfileAvatar'; + +interface EditAvatarSheetProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + userId: string | undefined; + name: string; +} + +const SWATCH_SIZE = 36; + +/** + * Edit avatar: pick the colour behind the initial. + * + * Colour only for now. Choosing or taking a photo needs somewhere to keep the + * image, and the backend has no avatar storage yet, so those rows are left out + * rather than offered and saved to this device alone. + */ +const EditAvatarSheet = ({ isOpen, onOpenChange, userId, name }: EditAvatarSheetProps) => { + const savedColor = useProfileAvatarStore(state => + userId ? state.colorByUserId[userId] : undefined, + ); + const setColor = useProfileAvatarStore(state => state.setColor); + const [draft, setDraft] = useState(savedColor ?? DEFAULT_AVATAR_COLOR_ID); + + // Every visit starts from what is saved, so Cancel really does throw the draft away. + useEffect(() => { + if (isOpen) setDraft(savedColor ?? DEFAULT_AVATAR_COLOR_ID); + }, [isOpen, savedColor]); + + const save = () => { + if (userId) setColor(userId, draft); + onOpenChange(false); + }; + + const initial = getAvatarInitial(name); + + return ( + + {({ topPadding, presentation }) => ( + + Edit avatar + + + + + + + Color + + {AVATAR_COLORS.map(color => { + const isSelected = color.id === draft; + return ( + setDraft(color.id)} + accessibilityRole="radio" + accessibilityState={{ selected: isSelected }} + accessibilityLabel={`${color.id} avatar`} + hitSlop={4} + className={cn( + 'items-center justify-center rounded-full border-2 web:hover:opacity-80', + isSelected ? 'border-white' : 'border-transparent', + )} + style={{ width: SWATCH_SIZE + 6, height: SWATCH_SIZE + 6 }} + > + + + {initial} + + + + ); + })} + + + + + onOpenChange(false)} + accessibilityRole="button" + className="mt-2 h-12 w-full items-center justify-center active:opacity-70" + > + Cancel + + + )} + + ); +}; + +export default EditAvatarSheet; diff --git a/components/Profile/ProfileAvatar.tsx b/components/Profile/ProfileAvatar.tsx new file mode 100644 index 000000000..2215fcbf2 --- /dev/null +++ b/components/Profile/ProfileAvatar.tsx @@ -0,0 +1,32 @@ +import { View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +import { AvatarColorId, getAvatarColor, getAvatarInitial } from './avatarColors'; + +interface ProfileAvatarProps { + name: string | null | undefined; + colorId?: AvatarColorId | null; + size: number; +} + +/** The account's initial on a tinted disc. */ +const ProfileAvatar = ({ name, colorId, size }: ProfileAvatarProps) => { + const color = getAvatarColor(colorId); + + return ( + + + {getAvatarInitial(name)} + + + ); +}; + +export default ProfileAvatar; diff --git a/components/Profile/ProfileRow.tsx b/components/Profile/ProfileRow.tsx new file mode 100644 index 000000000..ed18341c7 --- /dev/null +++ b/components/Profile/ProfileRow.tsx @@ -0,0 +1,126 @@ +import { Children, Fragment, isValidElement, ReactNode } from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import { ChevronRight } from 'lucide-react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +export type ProfileRowTone = 'muted' | 'warning' | 'positive'; + +interface ProfileRowProps { + icon: ReactNode; + title: string; + /** A second line under the title, e.g. the username on Account details. */ + subtitle?: string; + /** Right-aligned status text before the chevron ("Credit mode", "Add 2FA"). */ + value?: string; + valueTone?: ProfileRowTone; + /** A pill on the right in place of `value`, e.g. "Verified". */ + badge?: string; + /** Replaces the chevron, e.g. a copy button. */ + accessory?: ReactNode; + /** `danger` draws the destructive row: red title on a red-tinted icon. */ + tone?: 'default' | 'danger'; + isLoading?: boolean; + onPress?: () => void; + accessibilityLabel?: string; +} + +const VALUE_TONE: Record = { + muted: 'text-[#8E8E8E]', + warning: 'text-[#F2B84B]', + positive: 'text-[#94F27F]', +}; + +/** + * One row of a Profile group: an icon on a disc, the title (and optionally a + * line under it), and on the right a status, a pill or a custom accessory, then + * a chevron when the row leads somewhere. + */ +export const ProfileRow = ({ + icon, + title, + subtitle, + value, + valueTone = 'muted', + badge, + accessory, + tone = 'default', + isLoading, + onPress, + accessibilityLabel, +}: ProfileRowProps) => { + const isDanger = tone === 'danger'; + + const content = ( + + + {icon} + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {isLoading ? ( + + ) : badge ? ( + + {badge} + + ) : value ? ( + + {value} + + ) : null} + {accessory ?? (onPress ? : null)} + + ); + + if (!onPress) return content; + + return ( + + {content} + + ); +}; + +/** A rounded card of rows with hairlines between them, inset to the titles. */ +export const ProfileRowGroup = ({ children }: { children: ReactNode }) => { + // Conditional rows arrive as `false`/`null`; dividers only go between real ones. + const rows = Children.toArray(children).filter(isValidElement); + + return ( + + {rows.map((row, index) => ( + + {index > 0 ? : null} + {row} + + ))} + + ); +}; + +export const ProfileSectionLabel = ({ children }: { children: string }) => ( + {children} +); diff --git a/components/Profile/ToggleSwitch.tsx b/components/Profile/ToggleSwitch.tsx new file mode 100644 index 000000000..8f9ba2e0b --- /dev/null +++ b/components/Profile/ToggleSwitch.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { Pressable } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +const TRACK_WIDTH = 51; +const TRACK_HEIGHT = 31; +const THUMB_SIZE = 27; +const THUMB_INSET = 2; +const TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_INSET * 2; + +interface ToggleSwitchProps { + value: boolean; + onValueChange: (value: boolean) => void; + accessibilityLabel: string; + disabled?: boolean; +} + +/** + * The Notifications screen's switch: brand-green track with a dark thumb when + * on, a grey track with a white thumb when off — the iOS proportions, drawn the + * same on every platform. + */ +const ToggleSwitch = ({ + value, + onValueChange, + accessibilityLabel, + disabled, +}: ToggleSwitchProps) => { + const progress = useSharedValue(value ? 1 : 0); + + useEffect(() => { + progress.value = withTiming(value ? 1 : 0, { duration: 180 }); + }, [value, progress]); + + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: progress.value * TRAVEL }], + })); + + return ( + onValueChange(!value)} + disabled={disabled} + accessibilityRole="switch" + accessibilityLabel={accessibilityLabel} + accessibilityState={{ checked: value, disabled }} + hitSlop={6} + style={{ + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + padding: THUMB_INSET, + backgroundColor: value ? '#94F27F' : '#3A3A3A', + opacity: disabled ? 0.5 : 1, + }} + > + + + ); +}; + +export default ToggleSwitch; diff --git a/components/Profile/__tests__/avatarColors.test.ts b/components/Profile/__tests__/avatarColors.test.ts new file mode 100644 index 000000000..962a63fa2 --- /dev/null +++ b/components/Profile/__tests__/avatarColors.test.ts @@ -0,0 +1,33 @@ +import { + AVATAR_COLORS, + DEFAULT_AVATAR_COLOR_ID, + getAvatarColor, + getAvatarInitial, +} from '@/components/Profile/avatarColors'; + +describe('getAvatarColor', () => { + it('returns the swatch that was picked', () => { + expect(getAvatarColor('green').id).toBe('green'); + }); + + it('falls back to the default for nothing picked or a retired swatch', () => { + expect(getAvatarColor(undefined).id).toBe(DEFAULT_AVATAR_COLOR_ID); + expect(getAvatarColor('teal').id).toBe(DEFAULT_AVATAR_COLOR_ID); + }); + + it('offers eight distinct swatches', () => { + expect(new Set(AVATAR_COLORS.map(color => color.id)).size).toBe(8); + }); +}); + +describe('getAvatarInitial', () => { + it('uses the first character, capitalised', () => { + expect(getAvatarInitial('smargon4')).toBe('S'); + expect(getAvatarInitial(' eli@fuse.io')).toBe('E'); + }); + + it('shows a placeholder when there is no name', () => { + expect(getAvatarInitial('')).toBe('?'); + expect(getAvatarInitial(undefined)).toBe('?'); + }); +}); diff --git a/components/Profile/__tests__/deleteAccount.test.ts b/components/Profile/__tests__/deleteAccount.test.ts new file mode 100644 index 000000000..b1c719dc2 --- /dev/null +++ b/components/Profile/__tests__/deleteAccount.test.ts @@ -0,0 +1,104 @@ +import { + DELETE_ACCOUNT_FOOTNOTE, + DeleteAccountCheckInput, + getDeleteAccountChecks, + getDeleteSheetSubtitle, +} from '@/components/Profile/deleteAccount'; + +// The real barrel pulls in wagmi, which jest cannot parse; only the formatter is used. +jest.mock('@/lib/utils', () => ({ + formatBalanceUSD: (value: number) => + `$${new Intl.NumberFormat('en-us', { minimumFractionDigits: 2 }).format(value)}`, +})); + +const settled: DeleteAccountCheckInput = { + balanceUsd: 0, + isBalanceLoading: false, + isBalanceComplete: true, + hasCard: true, + pendingCardPayments: 0, + isCardPaymentsLoading: false, +}; + +const stateOf = (input: DeleteAccountCheckInput, id: string) => + getDeleteAccountChecks(input).checks.find(check => check.id === id)?.state; + +describe('getDeleteAccountChecks', () => { + it('allows deletion once the balance is gone and nothing is pending', () => { + const { checks, canDelete } = getDeleteAccountChecks(settled); + + expect(canDelete).toBe(true); + expect(checks.map(check => [check.id, check.state])).toEqual([ + ['balance', 'ok'], + ['card-payments', 'ok'], + ['card', 'info'], + ]); + }); + + it('blocks while money is left and says how much', () => { + const { checks, canDelete } = getDeleteAccountChecks({ ...settled, balanceUsd: 1284.5 }); + + expect(canDelete).toBe(false); + expect(checks[0]).toMatchObject({ + state: 'blocked', + title: 'Withdraw your balance', + detail: '$1,284.50 is still in your account', + hasAction: true, + }); + }); + + it('treats dust as nothing left', () => { + expect(stateOf({ ...settled, balanceUsd: 0.42 }, 'balance')).toBe('ok'); + expect(getDeleteAccountChecks({ ...settled, balanceUsd: 0.42 }).canDelete).toBe(true); + }); + + it('still blocks on a known balance when some of it could not be read', () => { + expect(stateOf({ ...settled, balanceUsd: 50, isBalanceComplete: false }, 'balance')).toBe( + 'blocked', + ); + }); + + it('warns but does not block when the balance cannot be checked', () => { + const { checks, canDelete } = getDeleteAccountChecks({ ...settled, balanceUsd: undefined }); + + expect(checks[0].state).toBe('unknown'); + expect(canDelete).toBe(true); + }); + + it('waits for both checks to load', () => { + expect(getDeleteAccountChecks({ ...settled, isBalanceLoading: true }).canDelete).toBe(false); + expect(getDeleteAccountChecks({ ...settled, isCardPaymentsLoading: true }).canDelete).toBe( + false, + ); + }); + + it('blocks while card payments are pending', () => { + const { checks, canDelete } = getDeleteAccountChecks({ ...settled, pendingCardPayments: 2 }); + + expect(canDelete).toBe(false); + expect(checks[1]).toMatchObject({ state: 'blocked', detail: '2 payments still pending' }); + }); + + it('leaves the card checks out for someone without a card', () => { + const { checks } = getDeleteAccountChecks({ + ...settled, + hasCard: false, + pendingCardPayments: undefined, + }); + + expect(checks.map(check => check.id)).toEqual(['balance']); + }); +}); + +describe('deletion copy', () => { + // Deletion is immediate on the backend today; promising a window would be false. + it('does not promise a grace period', () => { + expect(getDeleteSheetSubtitle(true)).toBe( + "This closes your account and cancels your card. It happens straight away and can't be undone.", + ); + expect(getDeleteSheetSubtitle(false)).toBe( + "This closes your account. It happens straight away and can't be undone.", + ); + expect(DELETE_ACCOUNT_FOOTNOTE).not.toMatch(/days/); + }); +}); diff --git a/components/Profile/avatarColors.ts b/components/Profile/avatarColors.ts new file mode 100644 index 000000000..8039fc3be --- /dev/null +++ b/components/Profile/avatarColors.ts @@ -0,0 +1,33 @@ +/** + * The swatches the Edit avatar sheet offers, in the order it lays them out. + * + * Each is a tinted disc with the initial drawn in the full-strength colour, the + * same recipe `getColorForTransaction` uses for merchant avatars so a profile + * avatar reads as one of the family rather than a new style. + */ +export const AVATAR_COLORS = [ + { id: 'purple', bg: 'rgba(165,127,242,0.25)', text: '#A57FF2' }, + { id: 'blue', bg: 'rgba(127,200,242,0.25)', text: '#7FC8F2' }, + { id: 'green', bg: 'rgba(127,242,158,0.25)', text: '#7FF29E' }, + { id: 'gold', bg: 'rgba(242,194,127,0.25)', text: '#F2C27F' }, + { id: 'pink', bg: 'rgba(242,127,215,0.25)', text: '#F27FD7' }, + { id: 'red', bg: 'rgba(242,127,129,0.25)', text: '#F27F81' }, + { id: 'lime', bg: 'rgba(180,242,127,0.25)', text: '#B4F27F' }, + { id: 'gray', bg: 'rgba(189,189,189,0.25)', text: '#BDBDBD' }, +] as const; + +export type AvatarColorId = (typeof AVATAR_COLORS)[number]['id']; +export type AvatarColor = (typeof AVATAR_COLORS)[number]; + +export const DEFAULT_AVATAR_COLOR_ID: AvatarColorId = 'purple'; + +/** The swatch for `id`, or the default when it is missing or no longer offered. */ +export const getAvatarColor = (id: string | null | undefined): AvatarColor => + AVATAR_COLORS.find(color => color.id === id) ?? + AVATAR_COLORS.find(color => color.id === DEFAULT_AVATAR_COLOR_ID)!; + +/** The letter on the avatar: the name's first character, `?` when there is none. */ +export const getAvatarInitial = (name: string | null | undefined): string => { + const first = name?.trim().charAt(0); + return first ? first.toUpperCase() : '?'; +}; diff --git a/components/Profile/deleteAccount.ts b/components/Profile/deleteAccount.ts new file mode 100644 index 000000000..f09bb2bcc --- /dev/null +++ b/components/Profile/deleteAccount.ts @@ -0,0 +1,133 @@ +import { formatBalanceUSD } from '@/lib/utils'; + +/** + * Days between confirming and the account actually closing, or null while + * deletion is immediate. + * + * Today `DELETE /accounts/v1/auths/delete-account` closes the account on the + * spot and every later sign-in is refused, so promising a window to change + * your mind would be false. Set this once the backend schedules deletion and + * lets a sign-in cancel it; the copy on Account details and in the sheet + * follows. + */ +export const DELETION_GRACE_PERIOD_DAYS: number | null = null; + +/** Below this, what is left is dust and does not stand in the way. */ +export const BALANCE_DUST_USD = 1; + +/** The phrase typed to arm the delete button. */ +export const DELETE_CONFIRMATION_PHRASE = 'DELETE'; + +export type DeleteCheckState = 'ok' | 'blocked' | 'unknown' | 'info' | 'loading'; + +export interface DeleteAccountCheck { + id: 'balance' | 'card-payments' | 'card'; + state: DeleteCheckState; + title: string; + detail?: string; + /** Offer the way to clear the check, e.g. "Withdraw". */ + hasAction?: boolean; +} + +export interface DeleteAccountCheckInput { + /** Everything the account holds, card balance included, in USD. */ + balanceUsd: number | undefined; + isBalanceLoading: boolean; + /** False when some balance could not be read, so the figure may be low. */ + isBalanceComplete: boolean; + hasCard: boolean; + pendingCardPayments: number | undefined; + isCardPaymentsLoading: boolean; +} + +const pluralize = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; + +/** + * What has to be true before the account can go, and whether it is. + * + * Money left behind and payments still settling block deletion: once the + * account is closed nobody can sign in to move the one or reconcile the other. + * A check that could not be answered warns rather than blocks — deleting an + * account is something a user is entitled to do, and a balance endpoint that is + * down must not take that away from them. + */ +export const getDeleteAccountChecks = ({ + balanceUsd, + isBalanceLoading, + isBalanceComplete, + hasCard, + pendingCardPayments, + isCardPaymentsLoading, +}: DeleteAccountCheckInput): { checks: DeleteAccountCheck[]; canDelete: boolean } => { + const checks: DeleteAccountCheck[] = []; + + if (isBalanceLoading) { + checks.push({ id: 'balance', state: 'loading', title: 'Checking your balance' }); + } else if (balanceUsd !== undefined && balanceUsd >= BALANCE_DUST_USD) { + checks.push({ + id: 'balance', + state: 'blocked', + title: 'Withdraw your balance', + detail: `${formatBalanceUSD(balanceUsd)} is still in your account`, + hasAction: true, + }); + } else if (balanceUsd === undefined || !isBalanceComplete) { + checks.push({ + id: 'balance', + state: 'unknown', + title: 'Withdraw your balance', + detail: "We couldn't check every balance. Move out anything left first", + hasAction: true, + }); + } else { + checks.push({ id: 'balance', state: 'ok', title: 'No balance left in your account' }); + } + + if (hasCard) { + if (isCardPaymentsLoading) { + checks.push({ id: 'card-payments', state: 'loading', title: 'Checking card payments' }); + } else if (pendingCardPayments === undefined) { + checks.push({ + id: 'card-payments', + state: 'unknown', + title: "Couldn't check card payments", + detail: 'Make sure none are still pending', + }); + } else if (pendingCardPayments > 0) { + checks.push({ + id: 'card-payments', + state: 'blocked', + title: 'Wait for card payments to settle', + detail: `${pluralize(pendingCardPayments, 'payment')} still pending`, + }); + } else { + checks.push({ id: 'card-payments', state: 'ok', title: 'No pending card payments' }); + } + + checks.push({ + id: 'card', + state: 'info', + title: 'Your card will be cancelled', + detail: 'It stops working as soon as your account closes', + }); + } + + const canDelete = checks.every(check => check.state !== 'blocked' && check.state !== 'loading'); + + return { checks, canDelete }; +}; + +/** The line under the sheet's title. */ +export const getDeleteSheetSubtitle = (hasCard: boolean): string => { + const closes = hasCard + ? 'This closes your account and cancels your card.' + : 'This closes your account.'; + return DELETION_GRACE_PERIOD_DAYS + ? `${closes} You have ${DELETION_GRACE_PERIOD_DAYS} days to change your mind.` + : `${closes} It happens straight away and can't be undone.`; +}; + +/** The note under the Delete account row on Account details. */ +export const DELETE_ACCOUNT_FOOTNOTE = DELETION_GRACE_PERIOD_DAYS + ? `You'll need to withdraw your balance first. Deletion completes ${DELETION_GRACE_PERIOD_DAYS} days after you confirm — sign in before then to cancel.` + : "You'll need to withdraw your balance first. Deleting your account is immediate and can't be undone."; diff --git a/hooks/useNotificationPreferences.ts b/hooks/useNotificationPreferences.ts new file mode 100644 index 000000000..bac46c0fb --- /dev/null +++ b/hooks/useNotificationPreferences.ts @@ -0,0 +1,61 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { + getNotificationPreferences, + NotificationPreferences, + updateNotificationPreferences, +} from '@/lib/api'; +import { withRefreshToken } from '@/lib/utils'; +import { useUserStore } from '@/store/useUserStore'; + +export const NOTIFICATION_PREFERENCES_QUERY_KEY = 'notificationPreferences'; + +const useSelectedUserId = () => + useUserStore(state => state.users.find(user => user.selected)?.userId); + +/** + * The account's push preferences, and a setter that applies a change at once + * and puts it back if the save fails. + * + * `preferences` is null when the backend has no preferences endpoint, which + * the Notifications screen takes as "leave the categories out". + */ +export const useNotificationPreferences = () => { + const userId = useSelectedUserId(); + const queryClient = useQueryClient(); + const queryKey = [NOTIFICATION_PREFERENCES_QUERY_KEY, userId]; + + const query = useQuery({ + queryKey, + queryFn: () => withRefreshToken(() => getNotificationPreferences()), + enabled: !!userId, + retry: 1, + }); + + const mutation = useMutation({ + mutationFn: (changes: Partial) => + withRefreshToken(() => updateNotificationPreferences(changes)), + onMutate: async changes => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + if (previous) queryClient.setQueryData(queryKey, { ...previous, ...changes }); + return { previous }; + }, + onError: (_error, _changes, context) => { + if (context?.previous) queryClient.setQueryData(queryKey, context.previous); + }, + onSuccess: saved => { + queryClient.setQueryData(queryKey, saved); + }, + }); + + return { + preferences: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + update: mutation.mutate, + isSaveError: mutation.isError, + }; +}; + +export default useNotificationPreferences; diff --git a/lib/api.ts b/lib/api.ts index b10f97c23..8c19ff532 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -5068,3 +5068,69 @@ export const fetchSavingsSummary = async ( return response.json(); }; + +/** + * Which kinds of push the account wants, by category. + * + * 3D Secure approvals, security alerts and account notices are not in here: + * those always go out. + */ +export interface NotificationPreferences { + cardPayments: boolean; + depositsTransfers: boolean; + earn: boolean; + cashbackRewards: boolean; + productNews: boolean; +} + +/** + * The account's push preferences, or null when this backend does not offer + * them yet (404) — the screen then leaves the categories out rather than + * showing switches that change nothing. + */ +export const getNotificationPreferences = async (): Promise => { + const jwt = getJWTToken(); + + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/users/notification-preferences`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }, + ); + + if (response.status === 404) return null; + if (!response.ok) throw response; + + return response.json(); +}; + +/** Change some push preferences. Returns the full set as saved. */ +export const updateNotificationPreferences = async ( + changes: Partial, +): Promise => { + const jwt = getJWTToken(); + + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/users/notification-preferences`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(changes), + }, + ); + + if (!response.ok) throw response; + + return response.json(); +}; diff --git a/store/useProfileAvatarStore.ts b/store/useProfileAvatarStore.ts new file mode 100644 index 000000000..9422f9a6a --- /dev/null +++ b/store/useProfileAvatarStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import mmkvStorage from '@/lib/mmvkStorage'; + +import type { AvatarColorId } from '@/components/Profile/avatarColors'; + +interface ProfileAvatarState { + /** The colour each account picked for its avatar, by user id. */ + colorByUserId: Record; + setColor: (userId: string, color: AvatarColorId) => void; +} + +const PROFILE_AVATAR_STORAGE_KEY = 'profile-avatar-storage'; + +/** + * The avatar colour, kept on this device only. + * + * The backend has nowhere to store an avatar yet, so the choice does not follow + * the account to another device. Keyed by user id so switching accounts on one + * device shows each its own colour. + */ +export const useProfileAvatarStore = create()( + persist( + set => ({ + colorByUserId: {}, + setColor: (userId, color) => + set(state => ({ colorByUserId: { ...state.colorByUserId, [userId]: color } })), + }), + { + name: PROFILE_AVATAR_STORAGE_KEY, + storage: createJSONStorage(() => mmkvStorage(PROFILE_AVATAR_STORAGE_KEY)), + }, + ), +); + +/** The colour `userId` picked, or undefined when they never picked one. */ +export const useProfileAvatarColorId = (userId: string | undefined) => + useProfileAvatarStore(state => (userId ? state.colorByUserId[userId] : undefined));