diff --git a/.changeset/cool-rockets-raise.md b/.changeset/cool-rockets-raise.md new file mode 100644 index 0000000000..dcc0898ec7 --- /dev/null +++ b/.changeset/cool-rockets-raise.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 drop unfreeze card label diff --git a/.changeset/frank-snails-move.md b/.changeset/frank-snails-move.md new file mode 100644 index 0000000000..9990d3a8c3 --- /dev/null +++ b/.changeset/frank-snails-move.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add card freeze confirmation sheet diff --git a/.changeset/six-breads-shout.md b/.changeset/six-breads-shout.md new file mode 100644 index 0000000000..6919c6f5f5 --- /dev/null +++ b/.changeset/six-breads-shout.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ show frozen overlay on card diff --git a/.changeset/social-icons-retire.md b/.changeset/social-icons-retire.md new file mode 100644 index 0000000000..525e8132d7 --- /dev/null +++ b/.changeset/social-icons-retire.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add freeze controls to home card diff --git a/.changeset/spotty-rules-camp.md b/.changeset/spotty-rules-camp.md new file mode 100644 index 0000000000..621703b90d --- /dev/null +++ b/.changeset/spotty-rules-camp.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ create shared switch component diff --git a/.changeset/tangy-deer-design.md b/.changeset/tangy-deer-design.md new file mode 100644 index 0000000000..2913ab7b21 --- /dev/null +++ b/.changeset/tangy-deer-design.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 add haptic feedback to card actions diff --git a/.maestro/subflows/activateCard.yaml b/.maestro/subflows/activateCard.yaml index 0462ded097..e2ee781597 100644 --- a/.maestro/subflows/activateCard.yaml +++ b/.maestro/subflows/activateCard.yaml @@ -11,7 +11,11 @@ appId: ${APP_ID ?? "app.exactly"} - assertVisible: Manually add your card to Apple Pay & Google Pay to make contactless payments. - tapOn: Close - tapOn: Freeze card -- tapOn: Unfreeze card +- assertVisible: Freeze your card? +- tapOn: Freeze card +- assertNotVisible: Freeze your card? +- waitForAnimationToEnd +- tapOn: Freeze card - tapOn: View PIN number - tapOn: Close - tapOn: Weekly spending limit diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index 188cf42e3f..334dca0531 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -2,11 +2,12 @@ import React, { useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { RefreshControl } from "react-native"; +import { selectionAsync } from "expo-haptics"; import { useRouter } from "expo-router"; import { ChevronRight, CircleHelp, CreditCard, DollarSign, Eye, EyeOff, Hash, Snowflake } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; -import { ScrollView, Separator, Spinner, Square, Switch, XStack, YStack } from "tamagui"; +import { ScrollView, Separator, Spinner, Square, XStack, YStack } from "tamagui"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -16,6 +17,7 @@ import { useReadUpgradeableModularAccountGetInstalledPlugins } from "@exactly/co import CardDetails from "./CardDetails"; import CardDisclaimer from "./CardDisclaimer"; +import CardFreezeSheet from "./CardFreezeSheet"; import CardPIN from "./CardPIN"; import ExaCard from "./exa-card/ExaCard"; import SpendingLimits from "./SpendingLimits"; @@ -43,6 +45,7 @@ import LatestActivity from "../shared/LatestActivity"; import PluginUpgrade from "../shared/PluginUpgrade"; import SafeView from "../shared/SafeView"; import Skeleton from "../shared/Skeleton"; +import Switch from "../shared/Switch"; import Text from "../shared/Text"; import View from "../shared/View"; @@ -58,6 +61,7 @@ export default function Card() { } = useTranslation(); const [disclaimerShown, setDisclaimerShown] = useState(false); const [verificationFailureShown, setVerificationFailureShown] = useState(false); + const [freezeConfirmOpen, setFreezeConfirmOpen] = useState(false); const { data: cardDetailsOpen } = useQuery({ queryKey: ["card-details-open"] }); const [spendingLimitsOpen, setSpendingLimitsOpen] = useState(false); @@ -301,7 +305,7 @@ export default function Card() { { if (isRevealing || isGeneratingCard || beginKYC.isPending) return; revealCard().catch(reportError); @@ -320,6 +324,7 @@ export default function Card() { justifyContent="space-between" cursor="pointer" onPress={() => { + selectionAsync().catch(reportError); revealCard().catch(reportError); }} > @@ -343,7 +348,12 @@ export default function Card() { cursor="pointer" onPress={() => { if (isFetchingCard || isSettingCardStatus) return; - changeCardStatus(cardDetails.status === "FROZEN" ? "ACTIVE" : "FROZEN").catch(reportError); + selectionAsync().catch(reportError); + if (cardDetails.status === "FROZEN") { + changeCardStatus("ACTIVE").catch(reportError); + return; + } + setFreezeConfirmOpen(true); }} > @@ -355,31 +365,12 @@ export default function Card() { )} - {displayStatus === "FROZEN" ? t("Unfreeze card") : t("Freeze card")} + {t("Freeze card")} - - - - - + + + @@ -393,6 +384,7 @@ export default function Card() { justifyContent="space-between" cursor="pointer" onPress={() => { + selectionAsync().catch(reportError); setDisplayPIN(true); }} > @@ -416,6 +408,7 @@ export default function Card() { gap="$s3" onPress={() => { if (!limit) return; + selectionAsync().catch(reportError); setSpendingLimitsOpen(true); }} > @@ -526,6 +519,16 @@ export default function Card() { setVerificationFailureShown(false); }} /> + { + setFreezeConfirmOpen(false); + }} + onConfirm={() => { + setFreezeConfirmOpen(false); + changeCardStatus("FROZEN").catch(reportError); + }} + /> ); diff --git a/src/components/card/CardFreezeSheet.tsx b/src/components/card/CardFreezeSheet.tsx new file mode 100644 index 0000000000..61eeefe80d --- /dev/null +++ b/src/components/card/CardFreezeSheet.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { Snowflake } from "@tamagui/lucide-icons"; +import { YStack } from "tamagui"; + +import ModalSheet from "../shared/ModalSheet"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; + +export default function CardFreezeSheet({ + onClose, + onConfirm, + open, +}: { + onClose: () => void; + onConfirm: () => void; + open: boolean; +}) { + const { t } = useTranslation(); + return ( + + + + + {t("Freeze your card?")} + + + {t("Your card will be temporarily paused. You can unfreeze it anytime.")} + + + + + + + {t("Cancel")} + + + + + + ); +} diff --git a/src/components/card/exa-card/CardContents.tsx b/src/components/card/exa-card/CardContents.tsx index aa7b5797ad..6c84ef779c 100644 --- a/src/components/card/exa-card/CardContents.tsx +++ b/src/components/card/exa-card/CardContents.tsx @@ -54,62 +54,55 @@ export default function CardContents({ > - <> - {disabled ? ( - - ) : revealing ? ( - - - - ) : frozen ? ( - - ) : isCredit ? ( - - - {`$${(markets ? Number(borrowLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, { - style: "decimal", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`} + {disabled ? ( + + ) : revealing ? ( + + + + ) : frozen ? null : isCredit ? ( + + + {`$${(markets ? Number(borrowLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, { + style: "decimal", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`} + + + + {t("Available balance")} - - - {t("Available balance")} - - - ) : ( - - - {`$${(markets ? Number(withdrawLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString( - language, - { - style: "decimal", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - )}`} + + ) : ( + + + {`$${(markets ? Number(withdrawLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, { + style: "decimal", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`} + + + + {t("Available balance")} - - - {t("Available balance")} - - - )} - + + )} @@ -129,6 +122,43 @@ export default function CardContents({ /> )} + + {frozen && !disabled && !revealing && ( + + )} + {frozen && !disabled && !revealing && ( + + + + )} + ); } diff --git a/src/components/home/CardStatus.tsx b/src/components/home/CardStatus.tsx index 1de6957439..49631b01da 100644 --- a/src/components/home/CardStatus.tsx +++ b/src/components/home/CardStatus.tsx @@ -14,14 +14,17 @@ import { scheduleOnRN } from "react-native-worklets"; import { selectionAsync } from "expo-haptics"; -import { CalendarDays, ChevronRight, CreditCard, Info, Wallet, Zap } from "@tamagui/lucide-icons"; -import { useTheme, View, XStack, YStack } from "tamagui"; +import { CalendarDays, ChevronRight, CreditCard, Info, Snowflake, Wallet, Zap } from "@tamagui/lucide-icons"; +import { AnimatePresence, Spinner, Square, useTheme, View, XStack, YStack } from "tamagui"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import CardBg from "../../assets/images/card-bg.svg"; import Exa from "../../assets/images/exa.svg"; +import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; +import { setCardStatus, type CardDetails } from "../../utils/server"; +import Switch from "../shared/Switch"; import Text from "../shared/Text"; export default function CardStatus({ @@ -52,6 +55,19 @@ export default function CardStatus({ spotlightRef?: React.RefObject; }) { const { t } = useTranslation(); + const { data: card } = useQuery({ queryKey: ["card", "details"] }); + const { + mutateAsync: changeCardStatus, + isPending: isSettingCardStatus, + variables: optimisticCardStatus, + } = useMutation({ + mutationKey: ["card", "status"], + mutationFn: setCardStatus, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: ["card", "details"] }); + }, + }); + const frozen = (isSettingCardStatus ? optimisticCardStatus : card?.status) === "FROZEN"; return ( - + + {frozen ? null : ( + + )} + { event.stopPropagation(); selectionAsync().catch(reportError); @@ -123,23 +144,123 @@ export default function CardStatus({ {t("Details")} + + {frozen && ( + + )} + {frozen && ( + + + + )} + - + + {frozen && ( + + { + if (isSettingCardStatus) return; + selectionAsync().catch(reportError); + changeCardStatus("ACTIVE").catch(reportError); + }} + > + + + {isSettingCardStatus ? ( + + ) : ( + + )} + + + {t("Freeze card")} + + + + + + + + )} + {!frozen && ( + + + + )} + - + + {!frozen && ( + + + + )} + ); } diff --git a/src/components/shared/Switch.tsx b/src/components/shared/Switch.tsx new file mode 100644 index 0000000000..0f9d2fc417 --- /dev/null +++ b/src/components/shared/Switch.tsx @@ -0,0 +1,38 @@ +import { createStyledContext, styled, View, withStaticProperties } from "tamagui"; + +const SwitchContext = createStyledContext<{ checked: boolean }>({ checked: false }); + +const SwitchFrame = styled(View, { + name: "Switch", + context: SwitchContext, + width: "$s8", + height: "$s5", + borderRadius: "$r_0", + padding: "$s1", + animation: "default", + animateOnly: ["backgroundColor"], + variants: { + checked: { + true: { backgroundColor: "$backgroundBrandMild" }, + false: { backgroundColor: "$backgroundStrong" }, + }, + } as const, +}); + +const SwitchThumb = styled(View, { + name: "SwitchThumb", + context: SwitchContext, + width: "$s4_5", + height: "$s4_5", + borderRadius: "$r_0", + animation: "default", + animateOnly: ["transform", "backgroundColor"], + variants: { + checked: { + true: { backgroundColor: "$backgroundBrand", x: "$s5" }, + false: { backgroundColor: "$backgroundSoft", x: 0 }, + }, + } as const, +}); + +export default withStaticProperties(SwitchFrame, { Thumb: SwitchThumb }); diff --git a/src/i18n/es.json b/src/i18n/es.json index de1b55ce5a..01f232d2e5 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -109,6 +109,7 @@ "By continuing, I accept the Terms & Conditions": "Al continuar, acepto los Términos y Condiciones", "By continuing, you accept both the notice below and the Terms and Conditions of the Exa Card.": "Al continuar, aceptas tanto el aviso a continuación como los Términos y Condiciones de la Exa Card.", "Camera access is currently disabled for Exa App. In order to continue, enable camera access for Exa App from your device settings.": "El acceso a la cámara está actualmente deshabilitado para Exa App. Para continuar, habilita el acceso a la cámara para Exa App desde la configuración de tu dispositivo.", + "Cancel": "Cancelar", "Cannot proceed": "No se puede continuar", "Card activated!": "¡Tarjeta activada!", "Card details": "Detalles de la tarjeta", @@ -245,6 +246,7 @@ "Free": "Gratis", "FREE": "GRATIS", "Freeze card": "Congelar tarjeta", + "Freeze your card?": "¿Congelar tu tarjeta?", "From a bank account": "Desde una cuenta bancaria", "From another wallet": "Desde otra billetera", "From any account in your name": "Desde cualquier cuenta a tu nombre", @@ -593,7 +595,6 @@ "Turn {{currency}} transfers to onchain USDC": "Convierte transferencias de {{currency}} a USDC on-chain", "Unable to fetch a bridge quote right now. Please adjust the amount or try again later.": "No es posible obtener una cotización de bridge en este momento. Ajusta el monto o inténtalo más tarde.", "Unable to simulate a transfer right now. Please adjust the amount or try again later.": "No es posible simular una transferencia en este momento. Ajusta el monto o inténtalo más tarde.", - "Unfreeze card": "Descongelar tarjeta", "Unknown": "Desconocido", "Unlike monthly payments, our installments are due every 4 weeks, which means payments are aligned with a 28-day cycle rather than the calendar month.": "A diferencia de los pagos mensuales, nuestras cuotas vencen cada 4 semanas, lo que significa que los pagos se alinean con un ciclo de 28 días en lugar del mes calendario.", "Upcoming payments": "Pagos próximos", @@ -659,6 +660,7 @@ "Your {{chain}} address": "Tu dirección en {{chain}}", "Your address needs to be verified": "Tu dirección necesita ser verificada", "Your card is awaiting activation. Follow the steps to enable it.": "Tu tarjeta está a la espera de activación. Sigue los pasos para habilitarla.", + "Your card will be temporarily paused. You can unfreeze it anytime.": "Tu tarjeta se pausará temporalmente. Puedes descongelarla en cualquier momento.", "Your card’s PIN may be required to confirm transactions and ensure security.": "El PIN de tu tarjeta puede ser necesario para confirmar transacciones y garantizar la seguridad.", "Your Exa account": "Tu cuenta Exa", "Your Exa Card is now upgraded to Visa Signature.": "Tu Exa Card ha sido actualizada a Visa Signature.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e5e7fa71cd..6610cbdd6e 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -109,6 +109,7 @@ "By continuing, I accept the Terms & Conditions": "Ao continuar, aceito os Termos e Condições", "By continuing, you accept both the notice below and the Terms and Conditions of the Exa Card.": "Ao continuar, você aceita tanto o aviso abaixo quanto os Termos e Condições do Exa Card.", "Camera access is currently disabled for Exa App. In order to continue, enable camera access for Exa App from your device settings.": "O acesso à câmera está desativado para o Exa App. Para continuar, ative o acesso à câmera para o Exa App nas configurações do seu dispositivo.", + "Cancel": "Cancelar", "Cannot proceed": "Não é possível continuar", "Card activated!": "Cartão ativado!", "Card details": "Detalhes do cartão", @@ -245,6 +246,7 @@ "Free": "Grátis", "FREE": "GRÁTIS", "Freeze card": "Congelar cartão", + "Freeze your card?": "Congelar seu cartão?", "From a bank account": "De uma conta bancária", "From another wallet": "De outra carteira", "From any account in your name": "De qualquer conta em seu nome", @@ -593,7 +595,6 @@ "Turn {{currency}} transfers to onchain USDC": "Converta transferências de {{currency}} em USDC on-chain", "Unable to fetch a bridge quote right now. Please adjust the amount or try again later.": "Não é possível obter uma cotação de bridge no momento. Ajuste o valor ou tente novamente mais tarde.", "Unable to simulate a transfer right now. Please adjust the amount or try again later.": "Não é possível simular uma transferência no momento. Ajuste o valor ou tente novamente mais tarde.", - "Unfreeze card": "Descongelar cartão", "Unknown": "Desconhecido", "Unlike monthly payments, our installments are due every 4 weeks, which means payments are aligned with a 28-day cycle rather than the calendar month.": "Diferentemente dos pagamentos mensais, nossas parcelas vencem a cada 4 semanas, o que significa que os pagamentos são alinhados a um ciclo de 28 dias em vez do mês do calendário.", "Upcoming payments": "Próximos pagamentos", @@ -659,6 +660,7 @@ "Your {{chain}} address": "Seu endereço na {{chain}}", "Your address needs to be verified": "Seu endereço precisa ser verificado", "Your card is awaiting activation. Follow the steps to enable it.": "Seu cartão está aguardando ativação. Siga os passos para ativá-lo.", + "Your card will be temporarily paused. You can unfreeze it anytime.": "Seu cartão será pausado temporariamente. Você pode descongelá-lo a qualquer momento.", "Your card’s PIN may be required to confirm transactions and ensure security.": "O PIN do seu cartão pode ser necessário para confirmar transações e garantir a segurança.", "Your Exa account": "Sua conta Exa", "Your Exa Card is now upgraded to Visa Signature.": "Seu Exa Card foi atualizado para Visa Signature.",