From d67a4f56886e5ffd49d8ff51543d1f8b0005a7ca Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Fri, 20 Mar 2026 13:19:18 +0100 Subject: [PATCH 01/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20card=20limit=20k?= =?UTF-8?q?yc=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit co-authored-by: franm co-authored-by: guillermo dieguez --- .changeset/wet-needles-call.md | 5 +++ src/components/card/Card.tsx | 11 ++--- src/components/card/SpendingLimits.tsx | 38 +++++++++++------ src/components/home/Home.tsx | 20 ++++++++- src/components/shared/InfoAlert.tsx | 57 +++++++++++++++----------- src/i18n/es-AR.json | 2 + src/i18n/es.json | 2 + src/i18n/pt.json | 2 + src/utils/persona.ts | 49 ++++++++++++---------- src/utils/server.ts | 9 ++++ src/utils/useCardLimit.ts | 36 ++++++++++++++++ src/utils/weeklySpend.ts | 10 +++++ 12 files changed, 175 insertions(+), 66 deletions(-) create mode 100644 .changeset/wet-needles-call.md create mode 100644 src/utils/useCardLimit.ts create mode 100644 src/utils/weeklySpend.ts diff --git a/.changeset/wet-needles-call.md b/.changeset/wet-needles-call.md new file mode 100644 index 0000000000..0b5ebd578c --- /dev/null +++ b/.changeset/wet-needles-call.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add card limit kyc flow diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index 013449f08e..e383347731 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -40,6 +40,7 @@ import useAsset from "../../utils/useAsset"; import useBeginKYC from "../../utils/useBeginKYC"; import useMarkets from "../../utils/useMarkets"; import useTabPress from "../../utils/useTabPress"; +import weeklySpend from "../../utils/weeklySpend"; import FundingAlert from "../shared/FundingAlert"; import IconButton from "../shared/IconButton"; import InfoAlert from "../shared/InfoAlert"; @@ -83,14 +84,7 @@ export default function Card() { } = useQuery({ queryKey: ["card", "details"], retry: false, gcTime: 0, staleTime: 0 }); const limit = cardDetails?.limit.amount ? cardDetails.limit.amount / 100 : undefined; - const weeklyPurchases = purchases - ? purchases.filter((item): item is Extract => { - if (item.type !== "panda" || item.status === "declined") return false; - const elapsedTime = (Date.now() - new Date(item.timestamp).getTime()) / 1000; - return elapsedTime <= 604_800; - }) - : []; - const totalSpent = weeklyPurchases.reduce((accumulator, item) => accumulator + item.usdAmount, 0); + const totalSpent = weeklySpend(purchases); const { queryKey } = useAsset(marketUSDCAddress); const { address } = useAccount(); @@ -122,6 +116,7 @@ export default function Card() { Promise.all([ refetchCard(), queryClient.invalidateQueries({ queryKey: ["activity", "card"], exact: true }), + queryClient.invalidateQueries({ queryKey: ["kyc", "cardLimit"], exact: true }), queryClient.invalidateQueries({ queryKey: ["kyc", "status"], exact: true }), address ? refetchBytecode() : undefined, address ? refetchMarkets() : undefined, diff --git a/src/components/card/SpendingLimits.tsx b/src/components/card/SpendingLimits.tsx index fadf1e2dc4..f760dd9101 100644 --- a/src/components/card/SpendingLimits.tsx +++ b/src/components/card/SpendingLimits.tsx @@ -6,8 +6,8 @@ import { Plus } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; import SpendingLimit from "./SpendingLimit"; -import { newMessage } from "../../utils/intercom"; -import reportError from "../../utils/reportError"; +import useCardLimit from "../../utils/useCardLimit"; +import InfoAlert from "../shared/InfoAlert"; import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; import Button from "../shared/StyledButton"; @@ -26,6 +26,7 @@ export default function SpendingLimits({ totalSpent: number; }) { const { t } = useTranslation(); + const { increase, pending, processing } = useCardLimit(open && limit != null); return ( @@ -44,17 +45,28 @@ export default function SpendingLimits({ - + {processing ? ( + + ) : ( + + )} diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 48d63e1e47..6402f63179 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -40,10 +40,12 @@ import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import { cardModeMutationOptions } from "../../utils/server"; import useAccount from "../../utils/useAccount"; +import useCardLimit from "../../utils/useCardLimit"; import useMarkets from "../../utils/useMarkets"; import usePendingOperations from "../../utils/usePendingOperations"; import usePortfolio from "../../utils/usePortfolio"; import useTabPress from "../../utils/useTabPress"; +import weeklySpend from "../../utils/weeklySpend"; import BenefitsSection from "../benefits/BenefitsSection"; import CardDetailsSheet from "../card/CardDetails"; import ManualRepaymentSheet from "../pay/ManualRepaymentSheet"; @@ -138,6 +140,12 @@ export default function Home() { kycStatus && "code" in kycStatus && (kycStatus.code === "ok" || kycStatus.code === "legacy kyc"), ); const { data: card } = useQuery({ queryKey: ["card", "details"], enabled: !!account && !!bytecode }); + const spendingLimitReached = !!card?.limit.amount && weeklySpend(activity) / (card.limit.amount / 100) >= 0.9; + const { + increase: increaseLimit, + pending: cardLimitPending, + processing: cardLimitProcessing, + } = useCardLimit(spendingLimitReached); const { data: spotlightShown } = useQuery({ queryKey: ["settings", "installments-spotlight"] }); const { data: lastInstallments } = useQuery({ queryKey: ["settings", "installments"] }); const { data: promoSeen } = useQuery({ queryKey: ["settings", "promo-seen", PROMO.id] }); @@ -212,6 +220,8 @@ export default function Home() { const refresh = () => Promise.all([ queryClient.invalidateQueries({ queryKey: ["activity"], exact: true }), + queryClient.invalidateQueries({ queryKey: ["card", "details"], exact: true }), + queryClient.invalidateQueries({ queryKey: ["kyc", "cardLimit"], exact: true }), queryClient.invalidateQueries({ queryKey: ["kyc", "status"], exact: true }), revalidateUnsupported(), account ? refetchMarkets() : undefined, @@ -245,7 +255,7 @@ export default function Home() { {overdueMaturity !== undefined && ( { @@ -254,6 +264,14 @@ export default function Home() { /> )} {markets && healthFactor(markets) < HEALTH_FACTOR_THRESHOLD && } + {spendingLimitReached && !cardLimitPending && !cardLimitProcessing && ( + + )} {(showKYCMigration || showPluginOutdated) && ( void; title: string; + variant?: keyof typeof variants; }) { - const onBase = error ? "$interactiveOnBaseErrorSoft" : "$interactiveOnBaseInformationSoft"; + const { bg, iconBg, icon: Icon, color, text } = variants[variant]; return ( - - - {error ? ( - - ) : ( - - )} + + + - + {title} {actionText && ( - + {actionText} - {loading ? : } + {loading ? : } )} @@ -61,3 +48,27 @@ export default function InfoAlert({ ); } + +const variants = { + error: { + bg: "$interactiveBaseErrorSoftDefault", + iconBg: "$interactiveBaseErrorDefault", + icon: AlertTriangle, + color: "$interactiveOnBaseErrorDefault", + text: "$interactiveOnBaseErrorSoft", + }, + info: { + bg: "$interactiveBaseInformationSoftDefault", + iconBg: "$interactiveBaseInformationDefault", + icon: Info, + color: "$interactiveOnBaseInformationDefault", + text: "$interactiveOnBaseInformationSoft", + }, + warning: { + bg: "$interactiveBaseWarningSoftDefault", + iconBg: "$interactiveBaseWarningDefault", + icon: AlertTriangle, + color: "$interactiveOnBaseWarningDefault", + text: "$interactiveOnBaseWarningSoft", + }, +} as const; diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index 9ea10217a8..8d2e06cec0 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -170,8 +170,10 @@ "You have an overdue payment. Pay now to avoid additional interest.": "Tenés un pago vencido. Pagá ahora para evitar intereses adicionales.", "You must repay each installment manually before its due date.": "Debés pagar cada cuota manualmente antes de su fecha de vencimiento.", "You send": "Enviás", + "You've reached 90% of your weekly card spending limit.": "Alcanzaste el 90% de tu límite de gasto semanal.", "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Tus activos aún no pueden respaldar tu tarjeta. Intercambialos por un activo compatible para empezar a gastar.", "Your card is awaiting activation. Follow the steps to enable it.": "Tu tarjeta está a la espera de activación. Seguí los pasos para habilitarla.", + "Your limit increase request is under review. We'll let you know once it's been processed.": "Tu solicitud de aumento de límite está en revisión. Te vamos a avisar cuando se haya procesado.", "Your password manager does not support passkey backups. Please try a different one": "Tu gestor de contraseñas no admite copias de seguridad de llaves de acceso. Por favor, probá con otro.", "Your spending limit is the maximum amount you can spend on your Exa Card.": "Tu límite de gasto es el monto máximo que podés gastar con tu Exa Card.", "Your transactions will show up here once you get started. Add funds to begin!": "Tus transacciones aparecerán aquí una vez que comiences. ¡Agregá fondos para comenzar!", diff --git a/src/i18n/es.json b/src/i18n/es.json index 76e21d55db..43e7d9ce84 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -821,6 +821,7 @@ "You’re all caught up! Start using your card in Pay Later mode to see payments listed here.": "¡Estás al día! Empieza a usar tu tarjeta en modo Pagar Después para ver los pagos aquí.", "You’re all set!": "¡Todo listo!", "You’re trying to borrow more than your collateral allows. Please enter a lower amount.": "Estás intentando pedir prestado más de lo que tu garantía permite. Por favor, introduce un monto menor.", + "You've reached 90% of your weekly card spending limit.": "Has alcanzado el 90% de tu límite de gasto semanal.", "Your {{chain}} address": "Tu dirección en {{chain}}", "Your address needs to be verified": "Tu dirección necesita ser verificada", "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Tus activos aún no pueden respaldar tu tarjeta. Intercámbialos por un activo compatible para empezar a gastar.", @@ -834,6 +835,7 @@ "Your funds serve as collateral, increasing your spending limits. The more funds you add, the more you can spend with the Exa Card.": "Tus fondos sirven como garantía, aumentando tus límites de gasto. Cuantos más fondos agregues, más podrás gastar con la Exa Card.", "Your ID needs to be updated": "Tu documento necesita ser actualizado", "Your KYC isn't approved for this currency": "Tu KYC no está aprobado para esta moneda", + "Your limit increase request is under review. We'll let you know once it's been processed.": "Tu solicitud de aumento de límite está en revisión. Te avisaremos cuando se haya procesado.", "Your password manager does not support passkey backups. Please try a different one": "Tu gestor de contraseñas no admite copias de seguridad de llaves de acceso. Por favor, prueba con otro.", "Your portfolio": "Tu cartera", "Your Portfolio": "Tu cartera", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index c994331fab..0f583a0435 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -821,6 +821,7 @@ "You’re all caught up! Start using your card in Pay Later mode to see payments listed here.": "Você está em dia! Comece a usar seu cartão no modo Pagar Depois para ver os pagamentos aqui.", "You’re all set!": "Tudo pronto!", "You’re trying to borrow more than your collateral allows. Please enter a lower amount.": "Você está tentando emprestar mais do que sua garantia permite. Por favor, insira um valor menor.", + "You've reached 90% of your weekly card spending limit.": "Você atingiu 90% do seu limite de gastos semanal.", "Your {{chain}} address": "Seu endereço na {{chain}}", "Your address needs to be verified": "Seu endereço precisa ser verificado", "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Seus ativos ainda não podem servir de garantia para o seu cartão. Troque-os por um ativo compatível para começar a gastar.", @@ -834,6 +835,7 @@ "Your funds serve as collateral, increasing your spending limits. The more funds you add, the more you can spend with the Exa Card.": "Seus fundos servem como garantia, aumentando seus limites de gastos. Quanto mais fundos você adicionar, mais poderá gastar com o Exa Card.", "Your ID needs to be updated": "Seu documento precisa ser atualizado", "Your KYC isn't approved for this currency": "Seu KYC não está aprovado para esta moeda", + "Your limit increase request is under review. We'll let you know once it's been processed.": "Sua solicitação de aumento de limite está em análise. Avisaremos quando for processada.", "Your password manager does not support passkey backups. Please try a different one": "Seu gerenciador de senhas não suporta backup de chaves de acesso. Por favor, tente outro", "Your portfolio": "Seu portfólio", "Your Portfolio": "Seu portfólio", diff --git a/src/utils/persona.ts b/src/utils/persona.ts index 5e7aad06e1..52f8f4fb28 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -14,20 +14,20 @@ import type { UseMutationOptions } from "@tanstack/react-query"; export const environment = (__DEV__ || process.env.EXPO_PUBLIC_ENV === "e2e" ? "sandbox" : "production") as Environment; type KYCResult = { status: "cancel" } | { status: "complete" }; -type RampKYCResult = KYCResult | { status: "error" }; +type InquiryResult = KYCResult | { status: "error" }; let current: | undefined - | { controller: AbortController; promise: Promise; type: "basic" } | { controller: AbortController; - promise: Promise; + promise: Promise; + scope: "bridge" | "cardLimit" | "manteca"; tokens?: { inquiryId: string; sessionToken: string }; - type: "bridge" | "manteca"; - }; + } + | { controller: AbortController; promise: Promise; scope: "basic" }; export function startKYC() { - if (current && !current.controller.signal.aborted && current.type === "basic") return current.promise; + if (current && !current.controller.signal.aborted && current.scope === "basic") return current.promise; current?.controller.abort(new Error("persona inquiry aborted")); const controller = new AbortController(); @@ -115,7 +115,7 @@ export function startKYC() { if (current?.controller === controller) current = undefined; }); - current = { type: "basic", controller, promise }; + current = { scope: "basic", controller, promise }; return promise; } @@ -124,20 +124,27 @@ export function cancelKYC() { } export function startMantecaKYC(tokens?: { inquiryId: string; sessionToken: string }) { - return startRampKYC("manteca", tokens); + return startScopedInquiry("manteca", tokens); } export function startAddressKYC(tokens?: { inquiryId: string; sessionToken: string }) { - return startRampKYC("bridge", tokens); + return startScopedInquiry("bridge", tokens); +} + +export function startCardLimitKYC() { + return startScopedInquiry("cardLimit"); } -function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; sessionToken: string }) { - if (current && !current.controller.signal.aborted && current.type === type && current.tokens === tokens) +function startScopedInquiry( + scope: "bridge" | "cardLimit" | "manteca", + tokens?: { inquiryId: string; sessionToken: string }, +) { + if (current && !current.controller.signal.aborted && current.scope === scope && current.tokens === tokens) return current.promise; current?.controller.abort(new Error("persona inquiry aborted")); const controller = new AbortController(); - const invalidationKey = ["kyc", type]; + const queryKey = ["kyc", scope]; const promise = (async () => { const { signal } = controller; @@ -151,11 +158,11 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; if (Platform.OS === "web") { const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([ import("persona"), - tokens ?? getKYCTokens(type, await getRedirectURI()), + tokens ?? getKYCTokens(scope, await getRedirectURI()), ]); if (signal.aborted) throw signal.reason; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const onAbort = () => { client.destroy(); reject(new Error("persona inquiry aborted", { cause: signal.reason })); @@ -169,14 +176,14 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; signal.removeEventListener("abort", onAbort); globalThis.removeEventListener("pagehide", onPageHide); client.destroy(); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey }).catch(reportError); resolve({ status: "complete" }); }, onCancel: () => { signal.removeEventListener("abort", onAbort); globalThis.removeEventListener("pagehide", onPageHide); client.destroy(); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey }).catch(reportError); resolve({ status: "cancel" }); }, onError: (error) => { @@ -191,23 +198,23 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; }); } - const { inquiryId, sessionToken } = tokens ?? (await getKYCTokens(type, await getRedirectURI())); + const { inquiryId, sessionToken } = tokens ?? (await getKYCTokens(scope, await getRedirectURI())); if (signal.aborted) throw signal.reason; const { Inquiry } = await import("react-native-persona"); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const onAbort = () => reject(new Error("persona inquiry aborted", { cause: signal.reason })); signal.addEventListener("abort", onAbort, { once: true }); Inquiry.fromInquiry(inquiryId) .sessionToken(sessionToken) .onCanceled(() => { signal.removeEventListener("abort", onAbort); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey }).catch(reportError); resolve({ status: "cancel" }); }) .onComplete(() => { signal.removeEventListener("abort", onAbort); - queryClient.invalidateQueries({ queryKey: invalidationKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey }).catch(reportError); resolve({ status: "complete" }); }) .onError((error) => { @@ -222,7 +229,7 @@ function startRampKYC(type: "bridge" | "manteca", tokens?: { inquiryId: string; if (current?.controller === controller) current = undefined; }); - current = { type, controller, promise, tokens }; + current = { scope, controller, promise, tokens }; return promise; } diff --git a/src/utils/server.ts b/src/utils/server.ts index e43deb6b6d..7a56f3a0e7 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -208,6 +208,15 @@ queryClient.setQueryDefaults(["kyc", "status"], { meta: { warnError: (error) => triage(error) === "warn" }, queryFn: () => getKYCStatus("basic", true), }); +queryClient.setQueryDefaults(["kyc", "cardLimit"], { + staleTime: 5 * 60_000, + gcTime: isServer ? Infinity : 60 * 60_000, + queryFn: () => + getKYCStatus("cardLimit").catch((error: unknown) => { + if (error instanceof APIError && error.code === 400) return { code: error.text }; + throw error; + }), +}); export type KYCStatus = Awaited>; export async function getCredential() { diff --git a/src/utils/useCardLimit.ts b/src/utils/useCardLimit.ts new file mode 100644 index 0000000000..612cc07045 --- /dev/null +++ b/src/utils/useCardLimit.ts @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; + +import { useToastController } from "@tamagui/toast"; + +import { useQuery } from "@tanstack/react-query"; + +import { newMessage } from "./intercom"; +import { startCardLimitKYC } from "./persona"; +import reportError from "./reportError"; + +import type { KYCStatus } from "./server"; + +export default function useCardLimit(enabled: boolean) { + const { t } = useTranslation(); + const toast = useToastController(); + const { data: status, isPending } = useQuery({ queryKey: ["kyc", "cardLimit"], enabled }); + function increase() { + if (status?.code !== "not started") { + newMessage(t("I want to increase my spending limit")).catch(reportError); + return; + } + startCardLimitKYC() + .catch((error: unknown) => { + reportError(error); + return { status: "error" } as const; + }) + .then((result) => { + if (result.status === "error") + toast.show(t("Something went wrong. Please try again."), { + burntOptions: { haptic: "error", preset: "error" }, + }); + }) + .catch(reportError); + } + return { increase, pending: isPending, processing: status?.code === "processing" }; +} diff --git a/src/utils/weeklySpend.ts b/src/utils/weeklySpend.ts new file mode 100644 index 0000000000..7a0e46a33a --- /dev/null +++ b/src/utils/weeklySpend.ts @@ -0,0 +1,10 @@ +import type { Activity } from "./server"; + +export default function weeklySpend(activity: Activity | undefined) { + if (!activity) return 0; + return activity.reduce((total, item) => { + if (item.type !== "panda" || item.status === "declined") return total; + const elapsed = Date.now() - new Date(item.timestamp).getTime(); + return elapsed <= 7 * 24 * 60 * 60 * 1000 ? total + item.usdAmount : total; + }, 0); +} From b33f44880b4108d565837edaa2c14febeb76949a Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 12:44:51 -0300 Subject: [PATCH 02/28] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20app:=20remove=20expi?= =?UTF-8?q?red=20crypto=20on-ramps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/add-funds/AddFunds.tsx | 65 +-------------------------- 1 file changed, 2 insertions(+), 63 deletions(-) diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx index db6f37991d..85941837b3 100644 --- a/src/components/add-funds/AddFunds.tsx +++ b/src/components/add-funds/AddFunds.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -8,7 +8,6 @@ import { useToastController } from "@tamagui/toast"; import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; -import { isAfter, parseISO } from "date-fns"; import { isAddress } from "viem"; import { base } from "viem/chains"; @@ -74,42 +73,6 @@ export default function AddFunds() { Object.values(providers).some((p) => p.onramp.currencies.some((item) => typeof item === "object" && "network" in item), ); - const past = useMemo(() => isAfter(new Date(), parseISO("2026-07-01")), []); - - function renderProviders(filter: "crypto" | "fiat") { - if (countryCode && isPending) { - return ( - - - - ); - } - if (!providers) return null; - return ( - - {Object.entries(providers).flatMap(([providerKey, provider]) => - provider.onramp.currencies - .filter((item) => (filter === "crypto") === (typeof item === "object")) - .map((item) => { - const isCrypto = typeof item === "object"; - const currency = isCrypto ? item.currency : item; - const network = isCrypto ? item.network : undefined; - return ( - - ); - }), - )} - - ); - } - return ( @@ -195,35 +158,13 @@ export default function AddFunds() { <> {hasCrypto && ( { openBrowser("https://x.com/exa_app/status/2071690658339770622").catch(reportError); }} /> )} - {!past && !isKYCApproved && chain.id !== base.id && ( - { - beginKYC.mutate(undefined, { - onError(error) { - toast.show(t("Error verifying identity"), { - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); - reportError(error); - }, - }); - }} - loading={beginKYC.isPending} - /> - )} {method === "siwe" && ( } @@ -245,8 +186,6 @@ export default function AddFunds() { router.push("/add-funds/add-crypto"); }} /> - - {!past && renderProviders("crypto")} )} {type === "fiat" && countryCode && isPending && ( From f42305d5525ed09d1a06fc032d4299559cb4c998 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 12:50:50 -0300 Subject: [PATCH 03/28] =?UTF-8?q?=E2=9C=A8=20app:=20restructure=20add=20fu?= =?UTF-8?q?nds=20root=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/brave-otters-wave.md | 5 ++++ src/components/add-funds/AddFunds.tsx | 36 ++++++++++++++++----------- src/i18n/es.json | 5 ++-- src/i18n/pt.json | 5 ++-- 4 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 .changeset/brave-otters-wave.md diff --git a/.changeset/brave-otters-wave.md b/.changeset/brave-otters-wave.md new file mode 100644 index 0000000000..bc5b479d5f --- /dev/null +++ b/.changeset/brave-otters-wave.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ restructure add funds root menu diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx index 85941837b3..07e6f02b46 100644 --- a/src/components/add-funds/AddFunds.tsx +++ b/src/components/add-funds/AddFunds.tsx @@ -22,6 +22,7 @@ import queryClient, { type AuthMethod } from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import { getKYCStatus, getRampProviders } from "../../utils/server"; import useBeginKYC from "../../utils/useBeginKYC"; +import useMarkets from "../../utils/useMarkets"; import RampButton from "../ramp/RampButton"; import ChainLogo from "../shared/ChainLogo"; import IconButton from "../shared/IconButton"; @@ -43,6 +44,7 @@ export default function AddFunds() { const ownerAccount = credential && isAddress(credential.credentialId) ? credential.credentialId : undefined; const { data: method } = useQuery({ queryKey: ["method"] }); + const { supportedAssets } = useMarkets(); const { data: kycStatus } = useQuery({ queryKey: ["kyc", "status"] }); const beginKYC = useBeginKYC(); const isKYCApproved = @@ -109,10 +111,27 @@ export default function AddFunds() { {type !== "crypto" && type !== "fiat" && ( <> + {method === "siwe" && ( + } + title={t("With connected wallet")} + subtitle={ + // TODO add support for ens resolution + ownerAccount ? shortenHex(ownerAccount, 4, 6) : "" + } + onPress={() => { + router.push("/add-funds/bridge"); + }} + /> + )} } title={t("Cryptocurrencies")} - subtitle={t("Multiple networks and wallets")} + subtitle={ + supportedAssets.length > 3 + ? t("{{assets}} and more", { assets: supportedAssets.slice(0, 3).join(", ") }) + : supportedAssets.join(", ") + } onPress={() => { router.push({ pathname: "/add-funds", params: { type: "crypto" } }); }} @@ -121,7 +140,7 @@ export default function AddFunds() { } title={t("Bank transfers")} - subtitle={t("From a bank account")} + subtitle={t("Pesos, dollars, or euros")} disabled={(isKYCApproved && !hasFiat) || beginKYC.isPending} loading={beginKYC.isPending} onPress={() => { @@ -165,19 +184,6 @@ export default function AddFunds() { }} /> )} - {method === "siwe" && ( - } - title={t("From connected wallet")} - subtitle={ - // TODO add support for ens resolution - ownerAccount ? shortenHex(ownerAccount, 4, 6) : "" - } - onPress={() => { - router.push("/add-funds/bridge"); - }} - /> - )} } title={t("From another wallet")} diff --git a/src/i18n/es.json b/src/i18n/es.json index 43e7d9ce84..e9b1621f55 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -1,5 +1,6 @@ { "{{amount}} left": "{{amount}} restante", + "{{assets}} and more": "{{assets}} y más", "{{count}} installments of_one": "{{count}} cuota de", "{{count}} installments of_other": "{{count}} cuotas de", "{{count}} installments_one": "{{count}} cuota", @@ -320,12 +321,10 @@ "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", "From any account": "Desde cualquier cuenta", "From any Argentine bank account in your name": "Desde cualquier cuenta bancaria argentina a tu nombre", - "From connected wallet": "Desde la billetera conectada", "frozen card": "tarjeta bloqueada", "Full repayment selected.": "Pago total seleccionado.", "Funding failed": "El financiamiento falló", @@ -529,6 +528,7 @@ "Pending requests": "Solicitudes pendientes", "Performance is variable, not guaranteed, and powered by Exactly Protocol. Yields depend on protocol performance and network activity. Past performance does not guarantee future results.": "El rendimiento es variable, no está garantizado y es impulsado por Exactly Protocol. Los rendimientos dependen del desempeño del protocolo y de la actividad de la red. El rendimiento pasado no garantiza resultados futuros.", "Pesos": "Pesos", + "Pesos, dollars, or euros": "Pesos, dólares o euros", "PIX key": "Clave PIX", "PIX Key": "Clave PIX", "Please check your internet connection and try again in a moment. If the problem persists, reinstalling the app may help.": "Revisa tu conexión a internet e inténtalo de nuevo en unos momentos. Si el problema persiste, reinstalar la aplicación puede ayudar.", @@ -799,6 +799,7 @@ "When you make a purchase using an installment plan, you must pay each installment manually before the due date. Otherwise, a daily penalty of {{rate}} is added while the payment is late.": "Cuando haces una compra usando un plan de cuotas, debes pagar cada cuota manualmente antes de la fecha de vencimiento. De lo contrario, se agrega una penalidad diaria de {{rate}} mientras el pago esté atrasado.", "WHEN": "CUÁNDO", "with": "con", + "With connected wallet": "Con billetera conectada", "Withdrawal": "Retiro", "Yield": "Rendimiento", "You are accessing a decentralized protocol using your crypto as collateral. The Exa App does not issue funding or provide credit. No credit checks or intermediaries are involved.": "Estás accediendo a un protocolo descentralizado usando tu cripto como garantía. La Exa App no emite financiamiento ni proporciona crédito. No se realizan verificaciones de crédito ni hay intermediarios involucrados.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 0f583a0435..0337cbae5e 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -1,5 +1,6 @@ { "{{amount}} left": "{{amount}} restante", + "{{assets}} and more": "{{assets}} e mais", "{{count}} installments of_one": "{{count}} parcela de", "{{count}} installments of_other": "{{count}} parcelas de", "{{count}} installments_one": "{{count}} parcela", @@ -320,12 +321,10 @@ "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", "From any account": "De qualquer conta", "From any Argentine bank account in your name": "De qualquer conta bancária argentina em seu nome", - "From connected wallet": "Da carteira conectada", "frozen card": "cartão bloqueado", "Full repayment selected.": "Pagamento total selecionado.", "Funding failed": "O financiamento falhou", @@ -529,6 +528,7 @@ "Pending requests": "Solicitações pendentes", "Performance is variable, not guaranteed, and powered by Exactly Protocol. Yields depend on protocol performance and network activity. Past performance does not guarantee future results.": "O rendimento é variável, não é garantido e é impulsionado pelo Exactly Protocol. Os rendimentos dependem do desempenho do protocolo e da atividade da rede. O desempenho passado não garante resultados futuros.", "Pesos": "Pesos", + "Pesos, dollars, or euros": "Pesos, dólares ou euros", "PIX key": "Chave PIX", "PIX Key": "Chave PIX", "Please check your internet connection and try again in a moment. If the problem persists, reinstalling the app may help.": "Verifique sua conexão com a internet e tente novamente em instantes. Se o problema persistir, reinstalar o aplicativo pode ajudar.", @@ -799,6 +799,7 @@ "When you make a purchase using an installment plan, you must pay each installment manually before the due date. Otherwise, a daily penalty of {{rate}} is added while the payment is late.": "Quando você faz uma compra usando um plano de parcelamento, você deve pagar cada parcela manualmente antes da data de vencimento. Caso contrário, uma penalidade diária de {{rate}} é adicionada enquanto o pagamento estiver atrasado.", "WHEN": "QUANDO", "with": "com", + "With connected wallet": "Com carteira conectada", "Withdrawal": "Saque", "Yield": "Rendimento", "You are accessing a decentralized protocol using your crypto as collateral. The Exa App does not issue funding or provide credit. No credit checks or intermediaries are involved.": "Você está acessando um protocolo descentralizado usando sua cripto como garantia. O Exa App não emite financiamento nem fornece crédito. Não há verificações de crédito nem intermediários envolvidos.", From f071aae981688586fef658818488fbe9aa6b2eb9 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 12:54:23 -0300 Subject: [PATCH 04/28] =?UTF-8?q?=E2=9C=A8=20app:=20resolve=20ens=20name?= =?UTF-8?q?=20for=20owner=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/calm-badgers-greet.md | 5 +++++ src/components/add-funds/AddFunds.tsx | 14 +++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-badgers-greet.md diff --git a/.changeset/calm-badgers-greet.md b/.changeset/calm-badgers-greet.md new file mode 100644 index 0000000000..fdde20c744 --- /dev/null +++ b/.changeset/calm-badgers-greet.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ resolve ens name for owner address diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx index 07e6f02b46..fac4b770f5 100644 --- a/src/components/add-funds/AddFunds.tsx +++ b/src/components/add-funds/AddFunds.tsx @@ -9,7 +9,8 @@ import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { isAddress } from "viem"; -import { base } from "viem/chains"; +import { base, mainnet } from "viem/chains"; +import { useEnsName } from "wagmi"; import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; @@ -23,6 +24,7 @@ import reportError from "../../utils/reportError"; import { getKYCStatus, getRampProviders } from "../../utils/server"; import useBeginKYC from "../../utils/useBeginKYC"; import useMarkets from "../../utils/useMarkets"; +import ownerConfig from "../../utils/wagmi/owner"; import RampButton from "../ramp/RampButton"; import ChainLogo from "../shared/ChainLogo"; import IconButton from "../shared/IconButton"; @@ -42,6 +44,12 @@ export default function AddFunds() { const { t } = useTranslation(); const { data: credential } = useQuery({ queryKey: ["credential"] }); const ownerAccount = credential && isAddress(credential.credentialId) ? credential.credentialId : undefined; + const { data: ensName } = useEnsName({ + config: ownerConfig, + chainId: mainnet.id, + address: ownerAccount, + query: { staleTime: 86_400_000, retry: false, meta: { dropError: () => true } }, + }); const { data: method } = useQuery({ queryKey: ["method"] }); const { supportedAssets } = useMarkets(); @@ -116,8 +124,8 @@ export default function AddFunds() { icon={} title={t("With connected wallet")} subtitle={ - // TODO add support for ens resolution - ownerAccount ? shortenHex(ownerAccount, 4, 6) : "" + ownerAccount && + (ensName ? `${ensName} | ${shortenHex(ownerAccount, 4, 6)}` : shortenHex(ownerAccount, 4, 6)) } onPress={() => { router.push("/add-funds/bridge"); From 668dbac6022872eb3e9f4041a3f112a5e2c0ef4f Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 13:01:26 -0300 Subject: [PATCH 05/28] =?UTF-8?q?=E2=9C=A8=20app:=20redesign=20crypto=20re?= =?UTF-8?q?ceive=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/proud-lions-glow.md | 5 + src/components/add-funds/AddCrypto.tsx | 358 ++++++++++++------ src/components/add-funds/EducationSheet.tsx | 65 ++++ .../add-funds/SupportedAssetsSheet.tsx | 117 ------ src/components/shared/SendWarning.tsx | 13 + src/i18n/es-AR.json | 1 - src/i18n/es.json | 4 +- src/i18n/pt.json | 4 +- 8 files changed, 327 insertions(+), 240 deletions(-) create mode 100644 .changeset/proud-lions-glow.md create mode 100644 src/components/add-funds/EducationSheet.tsx delete mode 100644 src/components/add-funds/SupportedAssetsSheet.tsx create mode 100644 src/components/shared/SendWarning.tsx diff --git a/.changeset/proud-lions-glow.md b/.changeset/proud-lions-glow.md new file mode 100644 index 0000000000..4e9fe236c8 --- /dev/null +++ b/.changeset/proud-lions-glow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ redesign crypto receive screen diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index d5100bad3d..ad0bf07851 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { PixelRatio, Pressable, Share } from "react-native"; +import QRCode from "react-native-qrcode-styled"; import { setStringAsync } from "expo-clipboard"; +import { selectionAsync } from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { AlertTriangle, ArrowLeft, Copy, RefreshCw, Share as ShareIcon } from "@tamagui/lucide-icons"; +import { AlertTriangle, ArrowLeft, Copy, QrCode, RefreshCw, Share as ShareIcon } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, XStack, YStack } from "tamagui"; @@ -14,7 +16,7 @@ import { useQuery } from "@tanstack/react-query"; import chain from "@exactly/common/generated/chain"; import BridgeDisclaimer from "./BridgeDisclaimer"; -import SupportedAssetsSheet from "./SupportedAssetsSheet"; +import EducationSheet from "./EducationSheet"; import { presentArticle } from "../../utils/intercom"; import networkLogos from "../../utils/networkLogos"; import reportError from "../../utils/reportError"; @@ -26,7 +28,9 @@ import ChainLogo from "../shared/ChainLogo"; import CopyAddressSheet from "../shared/CopyAddressSheet"; import IconButton from "../shared/IconButton"; import Image from "../shared/Image"; +import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; +import SendWarning from "../shared/SendWarning"; import Skeleton from "../shared/Skeleton"; import Button from "../shared/StyledButton"; import Text from "../shared/Text"; @@ -63,10 +67,12 @@ export default function AddCrypto() { const toast = useToastController(); const [copyAddressShown, setCopyAddressShown] = useState(false); + const [qrShown, setQRShown] = useState(false); const [supportedAssetsShown, setSupportedAssetsShown] = useState(false); const copy = useCallback(() => { if (!address) return; + selectionAsync().catch(reportError); setStringAsync(address) .then(() => { setCopyAddressShown(true); @@ -109,63 +115,54 @@ export default function AddCrypto() { - - - {isBridge - ? t("{{network}} deposit address", { network: networkName }) - : t("Your {{chain}} address", { chain: networkName })} - - - {address ? ( - {address} - ) : isBridge && isError && !isFetching ? ( - {t("Failed to load deposit address.")} - ) : ( - + + + setSupportedAssetsShown(true)} + /> + + + + + {t("Wallet address")} + + + {address ? ( + + {address} + + ) : isBridge && isError && !isFetching ? ( + + {t("Failed to load deposit address.")} + + ) : ( + + )} + + {!!address && !memo && ( + setQRShown(true)}> + + + {t("Show QR")} + + + + )} - - {isBridge && isError && !isFetching ? ( - - ) : ( - - - - - )} + {!!memo && ( @@ -198,6 +195,43 @@ export default function AddCrypto() { )} + {!!address && !memo && ( + { + setQRShown(false); + }} + > + + + + {isBridge + ? t("{{network}} deposit address", { network: networkName }) + : t("Your {{chain}} address", { chain: networkName })} + + + + + { + setQRShown(false); + }} + > + + {t("Close")} + + + + + + )} { @@ -209,83 +243,60 @@ export default function AddCrypto() { assets={isBridge ? assets : undefined} /> {!isBridge && ( - { setSupportedAssetsShown(false); }} - /> - )} - - - {t("Network")} - - - {isBridge ? t("Asset") : t("Supported Assets")} - - - - - {isBridge && typeof network === "string" && network in networkLogos ? ( - - ) : ( - - )} - - {networkName} - - - setSupportedAssetsShown(true)} + title={t("Supported assets")} + article="8950805" > - {!isBridge && isPending - ? Array.from({ length: 5 }, (_, index) => ( - - - - )) - : assets.map((symbol, index) => ( - - - - ))} - - + + {isPending + ? Array.from({ length: 5 }, (_, index) => ( + + )) + : supportedAssets.map((collateral) => ( + + ))} + + + {t( + "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.", + { assets: supportedAssets.join(", "), chain: chain.name }, + )} + + + )} - + {isBridge && } - + - - {isBridge - ? t( - "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.", - { crypto: currency, network: networkName }, - ) - : t("Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.", { - chain: networkName, - })} + + { presentArticle("8950801").catch(reportError); }} @@ -296,8 +307,115 @@ export default function AddCrypto() { + {isBridge && isError && !isFetching ? ( + + ) : ( + + + + + )} ); } + +function AssetChip({ assets, isPending, onPress }: { assets: string[]; isPending: boolean; onPress?: () => void }) { + const { t } = useTranslation(); + return ( + + + {t("Asset")} + + + {isPending ? ( + + ) : assets.length === 1 ? ( + <> + + + {assets[0]} + + + ) : ( + assets.map((symbol, index) => ( + + + + )) + )} + + + ); +} + +function NetworkChip({ logoURI, name }: { logoURI?: string; name: string }) { + const { t } = useTranslation(); + return ( + + + {t("Network")} + + + {logoURI ? ( + + ) : ( + + )} + + {name} + + + + ); +} diff --git a/src/components/add-funds/EducationSheet.tsx b/src/components/add-funds/EducationSheet.tsx new file mode 100644 index 0000000000..b9ee68e153 --- /dev/null +++ b/src/components/add-funds/EducationSheet.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { ThumbsUp } from "@tamagui/lucide-icons"; +import { ScrollView, YStack } from "tamagui"; + +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import ModalSheet from "../shared/ModalSheet"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; + +export default function EducationSheet({ + article, + children, + onClose, + open, + title, +}: { + article: string; + children: React.ReactNode; + onClose: () => void; + open: boolean; + title: string; +}) { + const { t } = useTranslation(); + return ( + + + + + + {title} + + {children} + + { + presentArticle(article).catch(reportError); + }} + > + + {t("Learn more")} + + + + + + + ); +} diff --git a/src/components/add-funds/SupportedAssetsSheet.tsx b/src/components/add-funds/SupportedAssetsSheet.tsx deleted file mode 100644 index 7911d11d7d..0000000000 --- a/src/components/add-funds/SupportedAssetsSheet.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from "react"; -import { Trans, useTranslation } from "react-i18next"; - -import { AlertTriangle, X } from "@tamagui/lucide-icons"; -import { ScrollView, XStack, YStack } from "tamagui"; - -import chain from "@exactly/common/generated/chain"; - -import { presentArticle } from "../../utils/intercom"; -import reportError from "../../utils/reportError"; -import useMarkets from "../../utils/useMarkets"; -import AssetLogo from "../shared/AssetLogo"; -import ModalSheet from "../shared/ModalSheet"; -import SafeView from "../shared/SafeView"; -import Skeleton from "../shared/Skeleton"; -import Button from "../shared/StyledButton"; -import Text from "../shared/Text"; -import View from "../shared/View"; - -export default function SupportedAssetsSheet({ open, onClose }: { onClose: () => void; open: boolean }) { - const { t } = useTranslation(); - const { supportedAssets, isPending } = useMarkets(); - return ( - - - - - - - {t("Supported assets")} - - - - {isPending - ? Array.from({ length: 5 }, (_, index) => ( - - - - - )) - : supportedAssets.map((symbol) => ( - - - - {symbol} - - - ))} - - - - - - - - { - presentArticle("8950801").catch(reportError); - }} - /> - ), - }} - /> - - - - - - - - - ); -} - -function Chip({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/src/components/shared/SendWarning.tsx b/src/components/shared/SendWarning.tsx new file mode 100644 index 0000000000..5c3ee42586 --- /dev/null +++ b/src/components/shared/SendWarning.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from "react-i18next"; + +export default function SendWarning({ asset, network }: { asset?: string; network: string }) { + const { t } = useTranslation(); + return asset + ? t("Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.", { + crypto: asset, + network, + }) + : t("Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.", { + chain: network, + }); +} diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index 8d2e06cec0..0b22dd8796 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -94,7 +94,6 @@ "Maximize earnings, effortlessly": "Maximizá tus ganancias sin esfuerzo", "Move from Visa Platinum to Visa Signature and unlock premium benefits and perks.": "Pasá de Visa Platinum a Visa Signature y desbloqueá beneficios y ventajas premium.", "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.": "Solo enviá activos en {{chain}}. Enviar fondos desde otras redes puede causar pérdida permanente.", - "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss. Learn more about adding funds.": "Solo enviá activos en {{chain}}. Enviar fondos desde otras redes puede causar pérdida permanente. Aprendé más sobre cómo agregar fondos.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Solo enviá {{crypto}} en {{network}}. Enviar otros activos o usar otras redes puede causar una pérdida permanente.", "Open your {{provider}} virtual account": "Abrí tu cuenta virtual de {{provider}}", "PAY NOW AND SAVE {{percent}}": "PAGÁ AHORA Y AHORRÁ {{percent}}", diff --git a/src/i18n/es.json b/src/i18n/es.json index e9b1621f55..224287f399 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -480,9 +480,9 @@ "On {{chain}}": "En {{chain}}", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "El crédito on-chain es ofrecido por Exactly Protocol y está sujeto a Términos y Condiciones separados. Exa App no emite ni garantiza ningún financiamiento.", + "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Solo {{assets}} en {{chain}} sirven como colateral, generan rendimiento mientras los mantienes y aumentan el límite de crédito de tu Exa Card.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Solo envía {{crypto}} en {{network}}. Enviar otros activos o usar otras redes puede causar una pérdida permanente.", "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.": "Solo envía activos en {{chain}}. Enviar fondos desde otras redes puede causar pérdida permanente.", - "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss. Learn more about adding funds.": "Solo envía activos en {{chain}}. Enviar fondos desde otras redes puede causar pérdida permanente. Aprende más sobre cómo agregar fondos.", "Only your USDC balance counts toward your spending limit.": "Solo tu saldo en USDC cuenta para tu límite de gasto.", "Open Exa Discord": "Abrir Exa en Discord", "Open Exa on X": "Abrir Exa en X", @@ -633,6 +633,7 @@ "Share {{chain}} address": "Compartir dirección de {{chain}}", "Share": "Compartir", "Show PIN": "Mostrar PIN", + "Show QR": "Mostrar QR", "Show QR Code": "Mostrar código QR", "Show sensitive": "Mostrar sensibles", "Sign in": "Iniciar sesión", @@ -784,6 +785,7 @@ "Visa Signature benefits": "Beneficios Visa Signature", "Visa Signature Exa Card benefits": "Beneficios de la Exa Card Visa Signature", "Visa": "Visa", + "Wallet address": "Dirección de billetera", "Wallet is busy. Please complete the pending request.": "La billetera está ocupada. Completa la solicitud pendiente.", "We couldn’t complete your verification": "No pudimos completar tu verificación", "We couldn’t verify your identity": "No pudimos verificar tu identidad", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 0337cbae5e..4a8dac5a68 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -480,9 +480,9 @@ "On {{chain}}": "Na {{chain}}", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "O crédito on-chain é oferecido pelo Exactly Protocol e está sujeito a Termos e Condições separados. O Exa App não emite nem garante nenhum financiamento.", + "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Apenas {{assets}} em {{chain}} servem como colateral, geram rendimento enquanto você os mantém e aumentam o limite de crédito do seu Exa Card.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Envie apenas {{crypto}} na rede {{network}}. Enviar outros ativos ou usar outras redes pode causar perda permanente.", "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.": "Envie ativos apenas na {{chain}}. Enviar fundos de outras redes pode causar perda permanente.", - "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss. Learn more about adding funds.": "Envie ativos apenas na {{chain}}. Enviar fundos de outras redes pode causar perda permanente. Saiba mais sobre como adicionar fundos.", "Only your USDC balance counts toward your spending limit.": "Apenas seu saldo em USDC conta para seu limite de gastos.", "Open Exa Discord": "Abrir Exa no Discord", "Open Exa on X": "Abrir Exa no X", @@ -633,6 +633,7 @@ "Share {{chain}} address": "Compartilhar endereço de {{chain}}", "Share": "Compartilhar", "Show PIN": "Mostrar PIN", + "Show QR": "Mostrar QR", "Show QR Code": "Mostrar código QR", "Show sensitive": "Mostrar sensíveis", "Sign in": "Entrar", @@ -784,6 +785,7 @@ "Visa Signature benefits": "Benefícios Visa Signature", "Visa Signature Exa Card benefits": "Benefícios do Exa Card Visa Signature", "Visa": "Visa", + "Wallet address": "Endereço da carteira", "Wallet is busy. Please complete the pending request.": "A carteira está ocupada. Conclua a solicitação pendente.", "We couldn’t complete your verification": "Não foi possível concluir sua verificação", "We couldn’t verify your identity": "Não foi possível verificar sua identidade", From f4a08d6e63d1185144ccc9513d58c027c0bc5219 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 13:33:03 -0300 Subject: [PATCH 06/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20cryptocurrencies?= =?UTF-8?q?=20asset=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/eager-owls-list.md | 5 + src/app/(main)/add-funds/_layout.tsx | 1 + src/app/(main)/add-funds/assets.tsx | 1 + src/components/add-funds/AddCrypto.tsx | 20 ++-- src/components/add-funds/AddFunds.tsx | 38 +------- src/components/add-funds/Assets.tsx | 128 +++++++++++++++++++++++++ src/i18n/es.json | 4 - src/i18n/pt.json | 4 - 8 files changed, 153 insertions(+), 48 deletions(-) create mode 100644 .changeset/eager-owls-list.md create mode 100644 src/app/(main)/add-funds/assets.tsx create mode 100644 src/components/add-funds/Assets.tsx diff --git a/.changeset/eager-owls-list.md b/.changeset/eager-owls-list.md new file mode 100644 index 0000000000..facdf7f085 --- /dev/null +++ b/.changeset/eager-owls-list.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add cryptocurrencies asset list diff --git a/src/app/(main)/add-funds/_layout.tsx b/src/app/(main)/add-funds/_layout.tsx index 7a9d15ba86..c40710962b 100644 --- a/src/app/(main)/add-funds/_layout.tsx +++ b/src/app/(main)/add-funds/_layout.tsx @@ -10,6 +10,7 @@ export default function AddFundsLayout() { + diff --git a/src/app/(main)/add-funds/assets.tsx b/src/app/(main)/add-funds/assets.tsx new file mode 100644 index 0000000000..2baf829beb --- /dev/null +++ b/src/app/(main)/add-funds/assets.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Assets"; diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index ad0bf07851..98060394f5 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -42,9 +42,15 @@ export default function AddCrypto() { const { address: accountAddress } = useAccount(); const { supportedAssets, isPending } = useMarkets(); const { t } = useTranslation(); - const { provider, currency: currencyParameter, network: networkParameter } = useLocalSearchParams(); + const { + provider, + currency: currencyParameter, + network: networkParameter, + asset: assetParameter, + } = useLocalSearchParams(); const currency = typeof currencyParameter === "string" ? currencyParameter : ""; const network = typeof networkParameter === "string" ? networkParameter : ""; + const asset = typeof assetParameter === "string" ? assetParameter : ""; const isBridge = provider === "bridge" && !!currency && !!network; const { data, isError, isFetching, refetch } = useQuery({ @@ -63,7 +69,7 @@ export default function AddCrypto() { const address = isBridge ? depositAddress : accountAddress; const networkName = isBridge && typeof network === "string" ? network : chain.name; - const assets = isBridge && typeof currency === "string" ? [currency] : supportedAssets; + const assets = isBridge ? [currency] : asset ? [asset] : supportedAssets; const toast = useToastController(); const [copyAddressShown, setCopyAddressShown] = useState(false); @@ -119,8 +125,8 @@ export default function AddCrypto() { setSupportedAssetsShown(true)} + isPending={!isBridge && !asset && isPending} + onPress={isBridge || asset ? undefined : () => setSupportedAssetsShown(true)} /> - {!isBridge && ( + {!isBridge && !asset && ( { @@ -292,7 +298,7 @@ export default function AddCrypto() { - + p.onramp.currencies.some((item) => typeof item === "string")); - const hasCrypto = - providers && - Object.values(providers).some((p) => - p.onramp.currencies.some((item) => typeof item === "object" && "network" in item), - ); + if (type === "crypto") return ; return ( @@ -92,7 +85,7 @@ export default function AddFunds() { icon={ArrowLeft} aria-label={t("Back")} onPress={() => { - if (type === "crypto" || type === "fiat") { + if (type === "fiat") { if (router.canGoBack()) { router.back(); } else { @@ -104,7 +97,7 @@ export default function AddFunds() { }} /> - {t(type === "crypto" ? "Cryptocurrencies" : type === "fiat" ? "Bank transfers" : "Add Funds")} + {t(type === "fiat" ? "Bank transfers" : "Add Funds")} { - router.push({ pathname: "/add-funds", params: { type: "crypto" } }); + router.push("/add-funds/assets"); }} /> {hasFiat !== false && chain.id !== base.id && ( @@ -181,27 +174,6 @@ export default function AddFunds() { )} )} - {type === "crypto" && ( - <> - {hasCrypto && ( - { - openBrowser("https://x.com/exa_app/status/2071690658339770622").catch(reportError); - }} - /> - )} - } - title={t("From another wallet")} - subtitle={t("On {{chain}}", { chain: chain.name })} - onPress={() => { - router.push("/add-funds/add-crypto"); - }} - /> - - )} {type === "fiat" && countryCode && isPending && ( diff --git a/src/components/add-funds/Assets.tsx b/src/components/add-funds/Assets.tsx new file mode 100644 index 0000000000..8755da064a --- /dev/null +++ b/src/components/add-funds/Assets.tsx @@ -0,0 +1,128 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { useRouter } from "expo-router"; + +import { ArrowLeft, CircleHelp, Info } from "@tamagui/lucide-icons"; +import { ScrollView, XStack, YStack } from "tamagui"; + +import chain from "@exactly/common/generated/chain"; + +import AddFundsOption from "./AddFundsOption"; +import EducationSheet from "./EducationSheet"; +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import useMarkets from "../../utils/useMarkets"; +import AssetLogo from "../shared/AssetLogo"; +import IconButton from "../shared/IconButton"; +import SafeView from "../shared/SafeView"; +import Skeleton from "../shared/Skeleton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function Assets() { + const router = useRouter(); + const { t } = useTranslation(); + const { markets, supportedAssets, isPending } = useMarkets(); + const [collateralShown, setCollateralShown] = useState(false); + const assets = useMemo(() => { + if (!markets) return []; + const excluded = new Set(["USDC.e", "DAI"]); + const available = markets + .filter((market) => !excluded.has(market.symbol.slice(3))) + .map((market) => + market.symbol.slice(3) === "WETH" + ? { symbol: "ETH", name: "Ether" } + : { symbol: market.symbol.slice(3), name: market.assetName }, + ); + const pinned = ["USDC", "ETH", "WBTC", "wstETH", "OP"]; + return [ + ...pinned.flatMap((symbol) => available.find((asset) => asset.symbol === symbol) ?? []), + ...available.filter((asset) => !pinned.includes(asset.symbol)), + ]; + }, [markets]); + return ( + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/add-funds"); + } + }} + /> + + {t("Cryptocurrencies")} + + { + presentArticle("8950801").catch(reportError); + }} + /> + + + + + + {t("Supported assets")} + + setCollateralShown(true)}> + + + + + {isPending + ? Array.from({ length: 5 }, (_, index) => ) + : assets.map(({ symbol, name }) => ( + } + title={symbol} + subtitle={name} + onPress={() => { + router.push({ pathname: "/add-funds/add-crypto", params: { asset: symbol } }); + }} + /> + ))} + + + + { + setCollateralShown(false); + }} + title={t("Supported assets")} + article="8950805" + > + + {isPending + ? Array.from({ length: 5 }, (_, index) => ) + : supportedAssets.map((symbol) => )} + + + {t( + "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.", + { assets: supportedAssets.join(", "), chain: chain.name }, + )} + + + + + ); +} diff --git a/src/i18n/es.json b/src/i18n/es.json index 224287f399..bfe3c5ddcb 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -204,8 +204,6 @@ "Credit limit info": "Información del límite de crédito", "Credit limit: {{asset}}": "Límite de crédito: {{asset}}", "Credit limit": "Límite de crédito", - "Crypto on-ramps are no longer available as of July 1st.": "Los on-ramps de cripto dejaron de estar disponibles desde el 1 de Julio.", - "Crypto on-ramps will no longer be available from July 1st.": "Los on-ramps de cripto dejarán de estar disponibles a partir del 1 de Julio.", "Cryptocurrencies": "Criptomonedas", "Current debt": "Deuda actual", "CVV": "CVV", @@ -321,7 +319,6 @@ "FREE": "GRATIS", "Freeze card": "Congelar tarjeta", "Freeze your card?": "¿Congelar tu tarjeta?", - "From another wallet": "Desde otra billetera", "From any account in your name": "Desde cualquier cuenta a tu nombre", "From any account": "Desde cualquier cuenta", "From any Argentine bank account in your name": "Desde cualquier cuenta bancaria argentina a tu nombre", @@ -477,7 +474,6 @@ "Nothing to see here for now. Once you add funds or make a payment, all your account activity will appear in this section.": "Nada que ver por ahora. Una vez que agregues fondos o realices un pago, toda la actividad de tu cuenta aparecerá en esta sección.", "Now": "Ahora", "Numbers only": "Solo números", - "On {{chain}}": "En {{chain}}", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "El crédito on-chain es ofrecido por Exactly Protocol y está sujeto a Términos y Condiciones separados. Exa App no emite ni garantiza ningún financiamiento.", "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Solo {{assets}} en {{chain}} sirven como colateral, generan rendimiento mientras los mantienes y aumentan el límite de crédito de tu Exa Card.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 4a8dac5a68..e57b3ac766 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -204,8 +204,6 @@ "Credit limit info": "Informações do limite de crédito", "Credit limit: {{asset}}": "Limite de crédito: {{asset}}", "Credit limit": "Limite de crédito", - "Crypto on-ramps are no longer available as of July 1st.": "Os on-ramps de cripto não estão mais disponíveis desde 1º de Julho.", - "Crypto on-ramps will no longer be available from July 1st.": "Os on-ramps de cripto não estarão mais disponíveis a partir de 1º de Julho.", "Cryptocurrencies": "Criptomoedas", "Current debt": "Dívida atual", "CVV": "CVV", @@ -321,7 +319,6 @@ "FREE": "GRÁTIS", "Freeze card": "Congelar cartão", "Freeze your card?": "Congelar seu cartão?", - "From another wallet": "De outra carteira", "From any account in your name": "De qualquer conta em seu nome", "From any account": "De qualquer conta", "From any Argentine bank account in your name": "De qualquer conta bancária argentina em seu nome", @@ -477,7 +474,6 @@ "Nothing to see here for now. Once you add funds or make a payment, all your account activity will appear in this section.": "Nada para ver por enquanto. Assim que você adicionar fundos ou fizer um pagamento, toda a atividade da sua conta aparecerá nesta seção.", "Now": "Agora", "Numbers only": "Apenas números", - "On {{chain}}": "Na {{chain}}", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "O crédito on-chain é oferecido pelo Exactly Protocol e está sujeito a Termos e Condições separados. O Exa App não emite nem garante nenhum financiamento.", "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Apenas {{assets}} em {{chain}} servem como colateral, geram rendimento enquanto você os mantém e aumentam o limite de crédito do seu Exa Card.", From afac302d9f9c2b284c18df38a6640eefbecc87e8 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 13:40:58 -0300 Subject: [PATCH 07/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20network=20select?= =?UTF-8?q?ion=20to=20receive=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/witty-geese-roam.md | 5 + src/app/(main)/add-funds/_layout.tsx | 1 + src/app/(main)/add-funds/network.tsx | 1 + src/components/add-funds/AddCrypto.tsx | 35 +++++- src/components/add-funds/AddFundsOption.tsx | 24 +++- src/components/add-funds/Assets.tsx | 2 +- src/components/add-funds/Network.tsx | 119 ++++++++++++++++++++ src/i18n/es.json | 5 + src/i18n/pt.json | 5 + 9 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 .changeset/witty-geese-roam.md create mode 100644 src/app/(main)/add-funds/network.tsx create mode 100644 src/components/add-funds/Network.tsx diff --git a/.changeset/witty-geese-roam.md b/.changeset/witty-geese-roam.md new file mode 100644 index 0000000000..cefbfac03c --- /dev/null +++ b/.changeset/witty-geese-roam.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add network selection to receive flow diff --git a/src/app/(main)/add-funds/_layout.tsx b/src/app/(main)/add-funds/_layout.tsx index c40710962b..5d879484e4 100644 --- a/src/app/(main)/add-funds/_layout.tsx +++ b/src/app/(main)/add-funds/_layout.tsx @@ -14,6 +14,7 @@ export default function AddFundsLayout() { + diff --git a/src/app/(main)/add-funds/network.tsx b/src/app/(main)/add-funds/network.tsx new file mode 100644 index 0000000000..60ac5fc5ad --- /dev/null +++ b/src/app/(main)/add-funds/network.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/add-funds/Network"; diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index 98060394f5..d5b08bd01e 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -17,7 +17,9 @@ import chain from "@exactly/common/generated/chain"; import BridgeDisclaimer from "./BridgeDisclaimer"; import EducationSheet from "./EducationSheet"; +import alchemyChainById from "../../utils/alchemyChains"; import { presentArticle } from "../../utils/intercom"; +import { lifiChainsOptions } from "../../utils/lifi"; import networkLogos from "../../utils/networkLogos"; import reportError from "../../utils/reportError"; import { getRampQuote } from "../../utils/server"; @@ -28,6 +30,7 @@ import ChainLogo from "../shared/ChainLogo"; import CopyAddressSheet from "../shared/CopyAddressSheet"; import IconButton from "../shared/IconButton"; import Image from "../shared/Image"; +import InfoAlert from "../shared/InfoAlert"; import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; import SendWarning from "../shared/SendWarning"; @@ -47,10 +50,16 @@ export default function AddCrypto() { currency: currencyParameter, network: networkParameter, asset: assetParameter, + chainId: chainIdParameter, } = useLocalSearchParams(); const currency = typeof currencyParameter === "string" ? currencyParameter : ""; const network = typeof networkParameter === "string" ? networkParameter : ""; const asset = typeof assetParameter === "string" ? assetParameter : ""; + const parsed = Number(chainIdParameter); + const receiveChainId = + typeof chainIdParameter === "string" && Number.isInteger(parsed) && parsed > 0 && parsed !== chain.id + ? parsed + : undefined; const isBridge = provider === "bridge" && !!currency && !!network; const { data, isError, isFetching, refetch } = useQuery({ @@ -67,8 +76,18 @@ export default function AddCrypto() { const depositAddress = deposit && "address" in deposit ? deposit.address : undefined; const memo = deposit && "memo" in deposit ? deposit.memo : undefined; + const { data: receiveChain } = useQuery({ + ...lifiChainsOptions, + enabled: !!receiveChainId, + select: (chains) => chains.find((c) => c.id === receiveChainId), + }); + const address = isBridge ? depositAddress : accountAddress; - const networkName = isBridge && typeof network === "string" ? network : chain.name; + const networkName = isBridge + ? network + : receiveChainId + ? (receiveChain?.name ?? alchemyChainById.get(receiveChainId)?.name ?? `#${receiveChainId}`) + : chain.name; const assets = isBridge ? [currency] : asset ? [asset] : supportedAssets; const toast = useToastController(); @@ -130,6 +149,7 @@ export default function AddCrypto() { /> @@ -244,8 +264,8 @@ export default function AddCrypto() { setCopyAddressShown(false); }} address={isBridge ? depositAddress : undefined} - network={isBridge && typeof network === "string" ? network : undefined} - networkLogo={isBridge && typeof network === "string" ? networkLogos[network] : undefined} + network={isBridge ? network : receiveChainId ? networkName : undefined} + networkLogo={isBridge ? networkLogos[network] : receiveChain?.logoURI} assets={isBridge || asset ? assets : undefined} /> {!isBridge && !asset && ( @@ -285,6 +305,11 @@ export default function AddCrypto() { {isBridge && } + {!!receiveChainId && !!asset && ( + + )} ) : ( - + )} {name} diff --git a/src/components/add-funds/AddFundsOption.tsx b/src/components/add-funds/AddFundsOption.tsx index 0dba030eb5..2da2f1ed36 100644 --- a/src/components/add-funds/AddFundsOption.tsx +++ b/src/components/add-funds/AddFundsOption.tsx @@ -7,6 +7,7 @@ import Text from "../shared/Text"; import View from "../shared/View"; export default function AddFundsOption({ + badge, icon, title, subtitle, @@ -14,11 +15,12 @@ export default function AddFundsOption({ loading, onPress, }: { + badge?: string; disabled?: boolean; icon: React.ReactElement; loading?: boolean; onPress: () => void; - subtitle: string; + subtitle?: string; title: string; }) { return ( @@ -50,11 +52,25 @@ export default function AddFundsOption({ {title} - - {subtitle} - + {!!subtitle && ( + + {subtitle} + + )} + {!!badge && ( + + + {badge} + + + )} {loading ? ( diff --git a/src/components/add-funds/Assets.tsx b/src/components/add-funds/Assets.tsx index 8755da064a..df58da0f6a 100644 --- a/src/components/add-funds/Assets.tsx +++ b/src/components/add-funds/Assets.tsx @@ -88,7 +88,7 @@ export default function Assets() { title={symbol} subtitle={name} onPress={() => { - router.push({ pathname: "/add-funds/add-crypto", params: { asset: symbol } }); + router.push({ pathname: "/add-funds/network", params: { asset: symbol } }); }} /> ))} diff --git a/src/components/add-funds/Network.tsx b/src/components/add-funds/Network.tsx new file mode 100644 index 0000000000..d554e5973f --- /dev/null +++ b/src/components/add-funds/Network.tsx @@ -0,0 +1,119 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; + +import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons"; +import { ScrollView, XStack, YStack } from "tamagui"; + +import { useQuery } from "@tanstack/react-query"; +import { arbitrum, base, bsc, mainnet, optimism, polygon } from "viem/chains"; + +import chain from "@exactly/common/generated/chain"; + +import AddFundsOption from "./AddFundsOption"; +import alchemyChainById from "../../utils/alchemyChains"; +import { presentArticle } from "../../utils/intercom"; +import { lifiChainsOptions, lifiTokensOptions } from "../../utils/lifi"; +import reportError from "../../utils/reportError"; +import ChainLogo from "../shared/ChainLogo"; +import IconButton from "../shared/IconButton"; +import SafeView from "../shared/SafeView"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function Network() { + const router = useRouter(); + const { t } = useTranslation(); + const { asset: assetParameter } = useLocalSearchParams(); + const asset = typeof assetParameter === "string" ? assetParameter : ""; + const { data: lifiChains } = useQuery(lifiChainsOptions); + const { data: tokens } = useQuery(lifiTokensOptions); + const sorted = useMemo(() => { + const available = new Set( + (tokens ?? []).filter((token) => token.symbol === asset).map((token) => token.chainId), + ); + const others = (lifiChains ?? []).filter( + (c) => + c.id !== chain.id && + c.mainnet && + available.has(c.id) && + alchemyChainById.has(c.id) && + !alchemyChainById.get(c.id)?.testnet, + ); + const pinned: number[] = [mainnet.id, base.id, arbitrum.id, polygon.id, bsc.id].filter((id) => id !== chain.id); + return [ + ...pinned.flatMap((id) => others.find((c) => c.id === id) ?? []), + ...others.filter((c) => !pinned.includes(c.id)).sort((a, b) => a.name.localeCompare(b.name)), + ]; + }, [tokens, lifiChains, asset]); + if (!asset) return ; + function selectNetwork(chainId: number) { + router.push({ + pathname: "/add-funds/add-crypto", + params: chainId === chain.id ? { asset } : { asset, chainId: String(chainId) }, + }); + } + return ( + + + + { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/add-funds/assets"); + } + }} + /> + + {t("Select network")} + + { + presentArticle("8950801").catch(reportError); + }} + /> + + + + + + {t("Native network")} + + } + title={chain.id === optimism.id ? "Optimism" : chain.name} + subtitle={chain.id === optimism.id ? optimism.name : undefined} + badge={t("Recommended")} + onPress={() => selectNetwork(chain.id)} + /> + + {sorted.length > 0 && ( + + + {t("Other networks")} + + + {sorted.map((c) => ( + } + title={c.name} + onPress={() => selectNetwork(c.id)} + /> + ))} + + + )} + + + + + ); +} diff --git a/src/i18n/es.json b/src/i18n/es.json index bfe3c5ddcb..b8ff6e40f6 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -453,6 +453,7 @@ "Must be at least 4 characters": "Debe tener al menos 4 caracteres", "My Exa Card": "Mi Exa Card", "N/A": "N/D", + "Native network": "Red nativa", "Network fee": "Comisión de red", "Network reminder": "Recordatorio de red", "Network": "Red", @@ -476,6 +477,7 @@ "Numbers only": "Solo números", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "El crédito on-chain es ofrecido por Exactly Protocol y está sujeto a Términos y Condiciones separados. Exa App no emite ni garantiza ningún financiamiento.", + "Once received, you'll need to bridge to {{asset}} on {{chain}}.": "Una vez recibido, deberás hacer bridge a {{asset}} en {{chain}}.", "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Solo {{assets}} en {{chain}} sirven como colateral, generan rendimiento mientras los mantienes y aumentan el límite de crédito de tu Exa Card.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Solo envía {{crypto}} en {{network}}. Enviar otros activos o usar otras redes puede causar una pérdida permanente.", "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.": "Solo envía activos en {{chain}}. Enviar fondos desde otras redes puede causar pérdida permanente.", @@ -487,6 +489,7 @@ "optional": "opcional", "Optional second line": "Segunda línea opcional", "or": "o", + "Other networks": "Otras redes", "Overdue payment {{date}}, {{amount}}": "Pago vencido {{date}}, {{amount}}", "Overdue payments": "Pagos vencidos", "Paid": "Pagado", @@ -553,6 +556,7 @@ "Received": "Recibido", "Receiving address": "Dirección de recepción", "Recent": "Recientes", + "Recommended": "Recomendada", "beneficiary": "beneficiario", "Beneficiary's account details": "Datos de la cuenta del beneficiario", "Recovery from this network is not yet available. Your funds are safe and will be recoverable once we add support.": "La recuperación desde esta red aún no está disponible. Tus fondos están seguros y podrán recuperarse cuando agreguemos soporte.", @@ -596,6 +600,7 @@ "Select asset": "Seleccionar activo", "Select country": "Seleccionar país", "Select first due date": "Selecciona la primera fecha de vencimiento", + "Select network": "Selecciona la red", "Select source asset": "Selecciona el activo de origen", "Select state": "Selecciona un estado", "Select the asset to fund": "Selecciona el activo para financiar", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e57b3ac766..2e14e8e7e7 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -453,6 +453,7 @@ "Must be at least 4 characters": "Deve ter pelo menos 4 caracteres", "My Exa Card": "Meu Exa Card", "N/A": "N/D", + "Native network": "Rede nativa", "Network fee": "Taxa de rede", "Network reminder": "Lembrete de rede", "Network": "Rede", @@ -476,6 +477,7 @@ "Numbers only": "Apenas números", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "O crédito on-chain é oferecido pelo Exactly Protocol e está sujeito a Termos e Condições separados. O Exa App não emite nem garante nenhum financiamento.", + "Once received, you'll need to bridge to {{asset}} on {{chain}}.": "Após o recebimento, você precisará fazer bridge para {{asset}} em {{chain}}.", "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Apenas {{assets}} em {{chain}} servem como colateral, geram rendimento enquanto você os mantém e aumentam o limite de crédito do seu Exa Card.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Envie apenas {{crypto}} na rede {{network}}. Enviar outros ativos ou usar outras redes pode causar perda permanente.", "Only send assets on {{chain}}. Sending funds from other networks may cause permanent loss.": "Envie ativos apenas na {{chain}}. Enviar fundos de outras redes pode causar perda permanente.", @@ -487,6 +489,7 @@ "optional": "opcional", "Optional second line": "Segunda linha opcional", "or": "ou", + "Other networks": "Outras redes", "Overdue payment {{date}}, {{amount}}": "Pagamento atrasado {{date}}, {{amount}}", "Overdue payments": "Pagamentos atrasados", "Paid": "Pago", @@ -553,6 +556,7 @@ "Received": "Recebido", "Receiving address": "Endereço de recebimento", "Recent": "Recentes", + "Recommended": "Recomendada", "beneficiary": "beneficiário", "Beneficiary's account details": "Dados da conta do beneficiário", "Recovery from this network is not yet available. Your funds are safe and will be recoverable once we add support.": "A recuperação desta rede ainda não está disponível. Seus fundos estão seguros e poderão ser recuperados assim que adicionarmos suporte.", @@ -596,6 +600,7 @@ "Select asset": "Selecionar ativo", "Select country": "Selecionar país", "Select first due date": "Selecione a primeira data de vencimento", + "Select network": "Selecione a rede", "Select source asset": "Selecione o ativo de origem", "Select state": "Selecione um estado", "Select the asset to fund": "Selecione o ativo para financiar", From 93b88999d6196c4ff3ab4d3ea0499c71fa441306 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 13:49:46 -0300 Subject: [PATCH 08/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20bridge=20educati?= =?UTF-8?q?on=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/quiet-cranes-teach.md | 5 + .../add-funds/BridgeNeededSheet.tsx | 168 ++++++++++++++++++ src/components/add-funds/Network.tsx | 25 ++- src/i18n/es.json | 6 + src/i18n/pt.json | 6 + src/utils/queryClient.ts | 7 + 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-cranes-teach.md create mode 100644 src/components/add-funds/BridgeNeededSheet.tsx diff --git a/.changeset/quiet-cranes-teach.md b/.changeset/quiet-cranes-teach.md new file mode 100644 index 0000000000..7a935bad0a --- /dev/null +++ b/.changeset/quiet-cranes-teach.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add bridge education sheet diff --git a/src/components/add-funds/BridgeNeededSheet.tsx b/src/components/add-funds/BridgeNeededSheet.tsx new file mode 100644 index 0000000000..4e9ee5524b --- /dev/null +++ b/src/components/add-funds/BridgeNeededSheet.tsx @@ -0,0 +1,168 @@ +import React, { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { ArrowRight, Check, Info } from "@tamagui/lucide-icons"; +import { Checkbox, ScrollView, Separator, XStack, YStack } from "tamagui"; + +import chain from "@exactly/common/generated/chain"; + +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; +import ModalSheet from "../shared/ModalSheet"; +import SafeView from "../shared/SafeView"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function BridgeNeededSheet({ + asset, + chainId, + network, + onClose, + onContinue, + open, +}: { + asset: string; + chainId?: number; + network: string; + onClose: () => void; + onContinue: (hide: boolean) => void; + open: boolean; +}) { + const { t } = useTranslation(); + const [hide, setHide] = useState(false); + return ( + { + setHide(false); + onClose(); + }} + disableDrag + > + + + + + {t("Bridge needed after receiving")} + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + presentArticle("8950805").catch(reportError); + }} + /> + ), + }} + /> + + + + setHide(!hide)}> + + + + + + + + {t("Don't show again")} + + + + + + + + ); +} + +function Step({ index, text }: { index: number; text: string }) { + return ( + + + + {index} + + + + {text} + + + ); +} diff --git a/src/components/add-funds/Network.tsx b/src/components/add-funds/Network.tsx index d554e5973f..5013be32aa 100644 --- a/src/components/add-funds/Network.tsx +++ b/src/components/add-funds/Network.tsx @@ -12,9 +12,11 @@ import { arbitrum, base, bsc, mainnet, optimism, polygon } from "viem/chains"; import chain from "@exactly/common/generated/chain"; import AddFundsOption from "./AddFundsOption"; +import BridgeNeededSheet from "./BridgeNeededSheet"; import alchemyChainById from "../../utils/alchemyChains"; import { presentArticle } from "../../utils/intercom"; import { lifiChainsOptions, lifiTokensOptions } from "../../utils/lifi"; +import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import ChainLogo from "../shared/ChainLogo"; import IconButton from "../shared/IconButton"; @@ -27,8 +29,10 @@ export default function Network() { const { t } = useTranslation(); const { asset: assetParameter } = useLocalSearchParams(); const asset = typeof assetParameter === "string" ? assetParameter : ""; + const [pendingChainId, setPendingChainId] = useState(); const { data: lifiChains } = useQuery(lifiChainsOptions); const { data: tokens } = useQuery(lifiTokensOptions); + const { data: bridgeAcknowledged } = useQuery({ queryKey: ["settings", "bridge-needed-shown"] }); const sorted = useMemo(() => { const available = new Set( (tokens ?? []).filter((token) => token.symbol === asset).map((token) => token.chainId), @@ -48,12 +52,19 @@ export default function Network() { ]; }, [tokens, lifiChains, asset]); if (!asset) return ; - function selectNetwork(chainId: number) { + function navigate(chainId: number) { router.push({ pathname: "/add-funds/add-crypto", params: chainId === chain.id ? { asset } : { asset, chainId: String(chainId) }, }); } + function selectNetwork(chainId: number) { + if (chainId !== chain.id && !bridgeAcknowledged) { + setPendingChainId(chainId); + return; + } + navigate(chainId); + } return ( @@ -113,6 +124,18 @@ export default function Network() { )} + c.id === pendingChainId)?.name ?? ""} + onClose={() => setPendingChainId(undefined)} + onContinue={(hide) => { + if (hide) queryClient.setQueryData(["settings", "bridge-needed-shown"], true); + if (pendingChainId !== undefined) navigate(pendingChainId); + setPendingChainId(undefined); + }} + /> ); diff --git a/src/i18n/es.json b/src/i18n/es.json index b8ff6e40f6..657c25950a 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -1,5 +1,6 @@ { "{{amount}} left": "{{amount}} restante", + "{{asset}} on {{network}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to bridge it to {{asset}} on {{chain}}. Learn more.": "{{asset}} en {{network}} no es un activo de colateral soportado. Para generar rendimiento y aumentar el límite de crédito de tu Exa Card, deberás hacer bridge a {{asset}} en {{chain}}. Aprende más.", "{{assets}} and more": "{{assets}} y más", "{{count}} installments of_one": "{{count}} cuota de", "{{count}} installments of_other": "{{count}} cuotas de", @@ -123,6 +124,8 @@ "Bridge {{symbol}}": "Hacer bridge de {{symbol}}", "Bridge failed. Please try again.": "El bridge falló. Inténtalo de nuevo.", "Bridge failed": "El bridge falló", + "Bridge it to {{asset}} on {{chain}}.": "Haz bridge a {{asset}} en {{chain}}.", + "Bridge needed after receiving": "Bridge necesario después de recibir", "Bridge needs a few more details before creating your account.": "Bridge necesita algunos datos más antes de crear tu cuenta.", "Bridge needs more information": "Bridge necesita más información", "Bridge provides a United States virtual account, converts your {{currency}} to USDC, and sends the funds to Exa App.": "Bridge proporciona una cuenta virtual de Estados Unidos, convierte tus {{currency}} a USDC y envía los fondos a Exa App.", @@ -235,6 +238,7 @@ "Discounts on travel insurance": "Descuentos en seguros de viaje", "Document number": "Número de documento", "Dollars": "Dólares", + "Don't show again": "No mostrar de nuevo", "Double-check your address before sending funds to avoid losing them.": "Verifica tu dirección antes de enviar fondos para evitar perderlos.", "due {{date}}": "vence {{date}}", "Due {{date}}": "Vence {{date}}", @@ -307,6 +311,7 @@ "Fees": "Comisiones", "Fees and transfer times": "Comisiones y tiempos de transferencia", "Fetching best route...": "Buscando la mejor ruta...", + "Find {{asset}} on {{network}} and select it.": "Busca {{asset}} en {{network}} y selecciónalo.", "Finished": "Finalizado", "First due date: {{date}} - then every 28 days.": "Primer vencimiento: {{date}} - luego cada 28 días.", "First installment due": "Primera cuota a pagar", @@ -477,6 +482,7 @@ "Numbers only": "Solo números", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "El crédito on-chain es ofrecido por Exactly Protocol y está sujeto a Términos y Condiciones separados. Exa App no emite ni garantiza ningún financiamiento.", + "Once received, go to your Portfolio.": "Una vez recibido, ve a tu Cartera.", "Once received, you'll need to bridge to {{asset}} on {{chain}}.": "Una vez recibido, deberás hacer bridge a {{asset}} en {{chain}}.", "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Solo {{assets}} en {{chain}} sirven como colateral, generan rendimiento mientras los mantienes y aumentan el límite de crédito de tu Exa Card.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Solo envía {{crypto}} en {{network}}. Enviar otros activos o usar otras redes puede causar una pérdida permanente.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 2e14e8e7e7..3ba587cae1 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -1,5 +1,6 @@ { "{{amount}} left": "{{amount}} restante", + "{{asset}} on {{network}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to bridge it to {{asset}} on {{chain}}. Learn more.": "{{asset}} em {{network}} não é um ativo de colateral suportado. Para gerar rendimento e aumentar o limite de crédito do seu Exa Card, você precisará fazer bridge para {{asset}} em {{chain}}. Saiba mais.", "{{assets}} and more": "{{assets}} e mais", "{{count}} installments of_one": "{{count}} parcela de", "{{count}} installments of_other": "{{count}} parcelas de", @@ -123,6 +124,8 @@ "Bridge {{symbol}}": "Fazer bridge de {{symbol}}", "Bridge failed. Please try again.": "O bridge falhou. Tente novamente.", "Bridge failed": "O bridge falhou", + "Bridge it to {{asset}} on {{chain}}.": "Faça bridge para {{asset}} em {{chain}}.", + "Bridge needed after receiving": "Bridge necessário após o recebimento", "Bridge needs a few more details before creating your account.": "Bridge precisa de mais alguns dados antes de criar sua conta.", "Bridge needs more information": "Bridge precisa de mais informações", "Bridge provides a United States virtual account, converts your {{currency}} to USDC, and sends the funds to Exa App.": "A Bridge fornece uma conta virtual dos Estados Unidos, converte seus {{currency}} em USDC e envia os fundos para o Exa App.", @@ -235,6 +238,7 @@ "Discounts on travel insurance": "Descontos em seguros de viagem", "Document number": "Número do documento", "Dollars": "Dólares", + "Don't show again": "Não mostrar novamente", "Double-check your address before sending funds to avoid losing them.": "Verifique seu endereço antes de enviar fundos para evitar perdê-los.", "due {{date}}": "vence {{date}}", "Due {{date}}": "Vence {{date}}", @@ -307,6 +311,7 @@ "Fees": "Taxas", "Fees and transfer times": "Taxas e tempos de transferência", "Fetching best route...": "Buscando a melhor rota...", + "Find {{asset}} on {{network}} and select it.": "Encontre {{asset}} em {{network}} e selecione-o.", "Finished": "Finalizado", "First due date: {{date}} - then every 28 days.": "Primeiro vencimento: {{date}} - depois a cada 28 dias.", "First installment due": "Primeira parcela a pagar", @@ -477,6 +482,7 @@ "Numbers only": "Apenas números", "On chain": "On-chain", "Onchain credit is powered by Exactly Protocol and is subject to separate Terms and conditions. The Exa App does not issue or guarantee any funding.": "O crédito on-chain é oferecido pelo Exactly Protocol e está sujeito a Termos e Condições separados. O Exa App não emite nem garante nenhum financiamento.", + "Once received, go to your Portfolio.": "Após o recebimento, vá para o seu Portfólio.", "Once received, you'll need to bridge to {{asset}} on {{chain}}.": "Após o recebimento, você precisará fazer bridge para {{asset}} em {{chain}}.", "Only {{assets}} on {{chain}} serve as collateral, earn yield while held, and increase your Exa Card credit limit.": "Apenas {{assets}} em {{chain}} servem como colateral, geram rendimento enquanto você os mantém e aumentam o limite de crédito do seu Exa Card.", "Only send {{crypto}} on {{network}}. Sending other assets or using other networks may cause permanent loss.": "Envie apenas {{crypto}} na rede {{network}}. Enviar outros ativos ou usar outras redes pode causar perda permanente.", diff --git a/src/utils/queryClient.ts b/src/utils/queryClient.ts index 6018f709e5..6dbecec471 100644 --- a/src/utils/queryClient.ts +++ b/src/utils/queryClient.ts @@ -227,6 +227,13 @@ queryClient.setQueryDefaults(["settings", "defi-intro-shown"], { gcTime: Infinity, queryFn: () => queryClient.getQueryData(["settings", "defi-intro-shown"]), }); +queryClient.setQueryDefaults(["settings", "bridge-needed-shown"], { + initialData: false, + retry: false, + staleTime: Infinity, + gcTime: Infinity, + queryFn: () => queryClient.getQueryData(["settings", "bridge-needed-shown"]), +}); queryClient.setQueryDefaults(["defi", "usdc-funding-connected"], { initialData: false, retry: false, From 0a33527927f77a7b940c36a7ac7e28ae9d1ea0d7 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 14:00:59 -0300 Subject: [PATCH 09/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20network=20filter?= =?UTF-8?q?=20to=20asset=20select=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/sly-foxes-sift.md | 5 + src/components/add-funds/AssetSelectSheet.tsx | 150 ++++++++++++++---- src/i18n/es.json | 2 + src/i18n/pt.json | 2 + 4 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 .changeset/sly-foxes-sift.md diff --git a/.changeset/sly-foxes-sift.md b/.changeset/sly-foxes-sift.md new file mode 100644 index 0000000000..755c7dc91a --- /dev/null +++ b/.changeset/sly-foxes-sift.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add network filter to asset select sheet diff --git a/src/components/add-funds/AssetSelectSheet.tsx b/src/components/add-funds/AssetSelectSheet.tsx index 3d62c8162b..f8d76eb00a 100644 --- a/src/components/add-funds/AssetSelectSheet.tsx +++ b/src/components/add-funds/AssetSelectSheet.tsx @@ -2,12 +2,13 @@ import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; -import { Search } from "@tamagui/lucide-icons"; +import { Check, ChevronDown, Network, Search } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; import { formatUnits } from "viem"; import AssetLogo from "../shared/AssetLogo"; +import ChainLogo from "../shared/ChainLogo"; import Input from "../shared/Input"; import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; @@ -41,11 +42,15 @@ export default function AssetSelectSheet({ i18n: { language }, } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); + const [chainFilter, setChainFilter] = useState(); + const [filterOpen, setFilterOpen] = useState(false); const displayLabel = label ?? t("Select asset"); const filteredGroups = useMemo(() => { const normalizedQuery = searchQuery.trim().toLowerCase(); + const activeChain = groups.some((group) => group.chain.id === chainFilter) ? chainFilter : undefined; return groups + .filter((group) => activeChain === undefined || group.chain.id === activeChain) .map((group) => { if (!normalizedQuery) return group; const assets = group.assets.filter(({ token }) => { @@ -59,10 +64,12 @@ export default function AssetSelectSheet({ return { ...group, assets }; }) .filter((group) => group.assets.length > 0); - }, [groups, searchQuery]); + }, [groups, searchQuery, chainFilter]); const handleClose = useCallback(() => { setSearchQuery(""); + setChainFilter(undefined); + setFilterOpen(false); onClose(); }, [onClose]); @@ -73,35 +80,87 @@ export default function AssetSelectSheet({ {displayLabel} - - - - + + + + + + + {groups.length > 1 && ( + setFilterOpen(!filterOpen)}> + + {chainFilter === undefined ? ( + + ) : ( + + )} + + + + )} - + {filterOpen && ( + + + } + label={t("All networks")} + onPress={() => { + setChainFilter(undefined); + setFilterOpen(false); + }} + /> + {groups.map((group) => ( + } + label={group.chain.name} + onPress={() => { + setChainFilter(group.chain.id); + setFilterOpen(false); + }} + /> + ))} + + + )} + {filteredGroups.map((group) => ( @@ -171,7 +230,7 @@ export default function AssetSelectSheet({ {filteredGroups.length === 0 && ( - {searchQuery + {searchQuery || chainFilter !== undefined ? t("No assets match your filters.") : t("No assets with balance available to bridge.")} @@ -184,3 +243,32 @@ export default function AssetSelectSheet({ ); } + +function FilterRow({ + active, + icon, + label, + onPress, +}: { + active: boolean; + icon: React.ReactElement; + label: string; + onPress: () => void; +}) { + return ( + + + {icon} + + {label} + + {active && } + + + ); +} diff --git a/src/i18n/es.json b/src/i18n/es.json index 657c25950a..94c8e5946f 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -67,6 +67,7 @@ "Airalo": "Airalo", "All Activity": "Actividad", "All deposits must be from bank accounts under your name.": "Todos los depósitos deben ser desde cuentas bancarias a tu nombre.", + "All networks": "Todas las redes", "All supported assets count toward your spending limit.": "Todos los activos compatibles cuentan para tu límite de gasto.", "Almost there! Activate your Exa Card to start spending your onchain assets instantly.": "¡Ya casi! Activa tu Exa Card para empezar a gastar tus activos on-chain al instante.", "Almost there!": "¡Ya casi!", @@ -311,6 +312,7 @@ "Fees": "Comisiones", "Fees and transfer times": "Comisiones y tiempos de transferencia", "Fetching best route...": "Buscando la mejor ruta...", + "Filter by network": "Filtrar por red", "Find {{asset}} on {{network}} and select it.": "Busca {{asset}} en {{network}} y selecciónalo.", "Finished": "Finalizado", "First due date: {{date}} - then every 28 days.": "Primer vencimiento: {{date}} - luego cada 28 días.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 3ba587cae1..ffad6b47c7 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -67,6 +67,7 @@ "Airalo": "Airalo", "All Activity": "Atividade", "All deposits must be from bank accounts under your name.": "Todos os depósitos devem ser de contas bancárias em seu nome.", + "All networks": "Todas as redes", "All supported assets count toward your spending limit.": "Todos os ativos compatíveis contam para seu limite de gastos.", "Almost there! Activate your Exa Card to start spending your onchain assets instantly.": "Quase lá! Ative seu Exa Card para começar a gastar seus ativos on-chain instantaneamente.", "Almost there!": "Quase lá!", @@ -311,6 +312,7 @@ "Fees": "Taxas", "Fees and transfer times": "Taxas e tempos de transferência", "Fetching best route...": "Buscando a melhor rota...", + "Filter by network": "Filtrar por rede", "Find {{asset}} on {{network}} and select it.": "Encontre {{asset}} em {{network}} e selecione-o.", "Finished": "Finalizado", "First due date: {{date}} - then every 28 days.": "Primeiro vencimento: {{date}} - depois a cada 28 dias.", From 8e1d771114ccc9882c828ec62db5a91c506f8361 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 13:56:52 -0300 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=92=84=20app:=20redesign=20bridge?= =?UTF-8?q?=20quote=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/round-moles-count.md | 5 + src/components/add-funds/Bridge.tsx | 349 +++++++++++++--------------- src/i18n/es.json | 17 +- src/i18n/pt.json | 17 +- src/utils/lifi.ts | 4 +- 5 files changed, 179 insertions(+), 213 deletions(-) create mode 100644 .changeset/round-moles-count.md diff --git a/.changeset/round-moles-count.md b/.changeset/round-moles-count.md new file mode 100644 index 0000000000..a4e059f959 --- /dev/null +++ b/.changeset/round-moles-count.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 redesign bridge quote screen diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index 7fcdd2b044..1bfe2a2087 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -4,7 +4,7 @@ import { Pressable } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { ArrowLeft, Check, CircleHelp, Clock, Repeat, X } from "@tamagui/lucide-icons"; +import { ArrowLeft, Check, CircleHelp, Clock, Repeat, Wallet, X } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, Square, XStack, YStack } from "tamagui"; @@ -22,7 +22,15 @@ import { zeroAddress, type Hex, } from "viem"; -import { useReadContract, useSendCalls, useSendTransaction, useSimulateContract, useWriteContract } from "wagmi"; +import { mainnet } from "viem/chains"; +import { + useEnsName, + useReadContract, + useSendCalls, + useSendTransaction, + useSimulateContract, + useWriteContract, +} from "wagmi"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; import alchemyGasPolicyId from "@exactly/common/alchemyGasPolicyId"; @@ -35,6 +43,7 @@ import { callsStatus } from "../../utils/accountClient"; import alchemyChainById from "../../utils/alchemyChains"; import { balancesOptions, + bridgeSlippage, bridgeSourcesOptions, getRouteFrom, lifiTokensOptions, @@ -102,6 +111,12 @@ export default function Bridge() { const isExaSender = params.sender === "exa"; const senderConfig = isExaSender ? exaConfig : ownerConfig; const { address: senderAddress } = useAccount({ config: senderConfig }); + const { data: senderEnsName } = useEnsName({ + config: ownerConfig, + chainId: mainnet.id, + address: isExaSender ? undefined : senderAddress, + query: { staleTime: 86_400_000, retry: false, meta: { dropError: () => true } }, + }); const { mutateAsync: sendTx } = useSendTransaction({ config: senderConfig }); const { mutateAsync: sendCallsTx } = useSendCalls({ config: senderConfig }); const { mutateAsync: transfer } = useWriteContract({ config: senderConfig }); @@ -319,6 +334,10 @@ export default function Bridge() { }); const approvalRequired = canReadAllowance && (allowanceData ?? 0n) < sourceAmount; + const lifiFeeUSD = (quote?.estimate.feeCosts ?? []).reduce((sum, { amountUSD }) => sum + (Number(amountUSD) || 0), 0); + const transactionFeeUSD = (quote?.estimate.gasCosts ?? []) + .filter(({ type }) => type !== "APPROVE" || approvalRequired) + .reduce((sum, { amountUSD }) => sum + (Number(amountUSD) || 0), 0); const nativeGasReserve = useMemo(() => { if (!quote?.estimate.gasCosts || !nativeAddress) return 0n; @@ -759,9 +778,8 @@ export default function Bridge() { : undefined; if (processing) { - const isPending = isBridging || isTransferring; - const isSuccess = isBridgeSuccess || isTransferSuccess; - const isError = isBridgeError || isTransferError; + const status = + isBridgeError || isTransferError ? "error" : isBridgeSuccess || isTransferSuccess ? "success" : "pending"; const labels = { bridge: { error: t("Bridge failed"), @@ -784,83 +802,25 @@ export default function Bridge() { const price = Number(bridgePreview.sourceToken.priceUSD); const usdValue = Number.isNaN(amount) || Number.isNaN(price) ? 0 : amount * price; return ( - - - - { - if (!isPending) { - setSourceAmount(0n); - setBridgePreview(undefined); - resetBridgeMutation(); - resetTransferMutation(); - } - router.dismissTo("/activity"); - }} - /> - - - {isPending && } - {isSuccess && } - {isError && } - - - - {isError ? labels.error : isSuccess ? labels.success : labels.processing} - - - - - - {`${Number( - formatUnits(bridgePreview.sourceAmount, bridgePreview.sourceToken.decimals), - ).toLocaleString(language, { - maximumFractionDigits: Math.min(6, bridgePreview.sourceToken.decimals), - })} ${bridgePreview.sourceToken.symbol}`} - - - - {`$${usdValue.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {!isPending && ( - - { - setSourceAmount(0n); - setBridgePreview(undefined); - resetBridgeMutation(); - resetTransferMutation(); - router.dismissTo("/activity"); - }} - > - - {t("Close")} - - - - )} - + { + if (status !== "pending") { + setSourceAmount(0n); + setBridgePreview(undefined); + resetBridgeMutation(); + resetTransferMutation(); + } + router.dismissTo("/activity"); + }} + /> ); } @@ -940,7 +900,11 @@ export default function Bridge() { {assetGroups.length > 0 && ( - {isTransfer ? t("Destination") : t("Destination asset")} + {t("Receive on")} {t("Exa Account")} | {shortenHex(account ?? zeroAddress, 4, 6)} @@ -1101,110 +1065,24 @@ export default function Bridge() { sourceAmount > 0n && !insufficientBalance && ( - - - {t("You send")} - - - {`${Number(formatUnits(sourceAmount, sourceToken.decimals)).toLocaleString(language, { - minimumFractionDigits: 0, - maximumFractionDigits: sourceToken.decimals, - useGrouping: false, - })} ${sourceToken.symbol}`} - - - - - {t("Source network")} - - - {selectedGroup?.chain.name ?? (source?.chain ? t("Chain {{id}}", { id: source.chain }) : "—")} - - - - - {t("Estimated arrival")} - - - {quote.estimate.toAmount - ? `≈${Number( - formatUnits(BigInt(quote.estimate.toAmount), destinationToken.decimals), - ).toLocaleString(language, { - minimumFractionDigits: 0, - maximumFractionDigits: destinationToken.decimals, - useGrouping: false, - })} ${destinationToken.symbol}` - : "—"} - - - - - {t("Destination network")} - - - {chain.name} - - {quote.estimate.toAmountMin && ( - - - {t("Minimum received")} - - - {`${Number( - formatUnits(BigInt(quote.estimate.toAmountMin), destinationToken.decimals), - ).toLocaleString(language, { - minimumFractionDigits: 0, - maximumFractionDigits: destinationToken.decimals, - useGrouping: false, - })} ${destinationToken.symbol}`} - - - )} - - - {t("Fees")} - - - 0.25% - - - - - {t("Slippage")} - - - 2% - - - {quote.estimate.executionDuration ? ( - - - {t("Estimated time")} - - - {t("~{{minutes}} min", { - minutes: Math.max(1, Math.round(quote.estimate.executionDuration / 60)), - })} - - - ) : null} - {(quote.tool ?? quote.estimate.tool) && ( - - - {t("Exchange")} - - - {quote.tool ?? quote.estimate.tool} - - + )} + + + + )} {statusMessage && ( @@ -1253,6 +1131,18 @@ export default function Bridge() { )} + {!isExaSender && (isTransfer || !!quote) && ( + + + + + + + {t("You must confirm the transactions on your external wallet.")} + + + + )} + + + {t("Select another asset")} + + + + + + + ); +} diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index 1bfe2a2087..1d3481b8e1 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -38,6 +38,7 @@ import chain from "@exactly/common/generated/chain"; import shortenHex from "@exactly/common/shortenHex"; import { WAD } from "@exactly/lib"; +import AssetMatchSheet from "./AssetMatchSheet"; import AssetSelectSheet from "./AssetSelectSheet"; import { callsStatus } from "../../utils/accountClient"; import alchemyChainById from "../../utils/alchemyChains"; @@ -89,6 +90,7 @@ export default function Bridge() { } = useTranslation(); const [assetSheetOpen, setAssetSheetOpen] = useState(false); + const [assetMatch, setAssetMatch] = useState<{ chainId: number; destinationSymbol: string; token: Token }>(); const [destinationModalOpen, setDestinationModalOpen] = useState(false); const { address: account } = useAccount(); @@ -1182,10 +1184,44 @@ export default function Bridge() { groups={assetGroups} selected={source} onSelect={(chainId, token) => { + const correlatedSymbol = + token.symbol in tokenCorrelation + ? tokenCorrelation[token.symbol as keyof typeof tokenCorrelation] + : undefined; + const correlatedToken = + correlatedSymbol && correlatedSymbol !== token.symbol && (isExaSender || chainId !== chain.id) + ? destinationTokens.find((destination) => destination.symbol === correlatedSymbol) + : undefined; + if (correlatedToken) { + setAssetMatch({ chainId, destinationSymbol: correlatedToken.symbol, token }); + return; + } setSourceAmount(0n); setSelectedSource({ chain: chainId, address: token.address.toLowerCase() }); }} /> + group.chain.id === assetMatch?.chainId)?.chain.name ?? ""} + destinationSymbol={assetMatch?.destinationSymbol ?? ""} + onClose={() => setAssetMatch(undefined)} + onConfirm={() => { + if (!assetMatch) return; + setSourceAmount(0n); + setSelectedSource({ chain: assetMatch.chainId, address: assetMatch.token.address.toLowerCase() }); + setSelectedDestinationAddress( + destinationTokens.find((token) => token.symbol === assetMatch.destinationSymbol)?.address, + ); + setAssetMatch(undefined); + }} + onSelectAnother={() => { + setAssetMatch(undefined); + setAssetSheetOpen(true); + }} + /> Date: Thu, 23 Jul 2026 14:25:12 -0300 Subject: [PATCH 12/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20request=20sent?= =?UTF-8?q?=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/plush-swans-send.md | 5 ++++ src/components/add-funds/Bridge.tsx | 38 +++++++++++++++-------------- src/i18n/es.json | 6 ++--- src/i18n/pt.json | 6 ++--- 4 files changed, 31 insertions(+), 24 deletions(-) create mode 100644 .changeset/plush-swans-send.md diff --git a/.changeset/plush-swans-send.md b/.changeset/plush-swans-send.md new file mode 100644 index 0000000000..8532de4d93 --- /dev/null +++ b/.changeset/plush-swans-send.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add request sent screen diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index 1d3481b8e1..951db8a0e0 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -4,7 +4,7 @@ import { Pressable } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { ArrowLeft, Check, CircleHelp, Clock, Repeat, Wallet, X } from "@tamagui/lucide-icons"; +import { ArrowLeft, ArrowRight, Check, CircleHelp, Clock, Repeat, Wallet, X } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, Square, XStack, YStack } from "tamagui"; @@ -782,22 +782,10 @@ export default function Bridge() { if (processing) { const status = isBridgeError || isTransferError ? "error" : isBridgeSuccess || isTransferSuccess ? "success" : "pending"; - const labels = { - bridge: { - error: t("Bridge failed"), - success: t("Bridge transaction submitted"), - processing: t("Processing bridge"), - }, - swap: { - error: t("Swap failed"), - success: t("Swap transaction submitted"), - processing: t("Processing swap"), - }, - transfer: { - error: t("Transfer failed"), - success: t("Transfer transaction submitted"), - processing: t("Processing transfer"), - }, + const errorTitle = { + bridge: t("Bridge failed"), + swap: t("Swap failed"), + transfer: t("Transfer failed"), }[bridgePreview.operation]; const amount = Number(formatUnits(bridgePreview.sourceAmount, bridgePreview.sourceToken.decimals)); @@ -806,7 +794,13 @@ export default function Bridge() { return ( {!pending && ( + {status === "success" && ( + + )} {t("Close")} diff --git a/src/i18n/es.json b/src/i18n/es.json index ec7b90a842..f3c0062af9 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -52,6 +52,7 @@ "Activate your new Exa Card": "Activa tu nueva Exa Card", "Activating your new Exa Card": "Activando tu nueva Exa Card", "Activity": "Actividad", + "Add funds request sent": "Solicitud para agregar fondos enviada", "Add funds to account": "Agregar fondos a la cuenta", "Add funds to your account": "Agregar fondos a tu cuenta", "Add funds to your account to start spending with the Exa Card.": "Agrega fondos a tu cuenta para empezar a gastar con la Exa Card.", @@ -540,12 +541,10 @@ "Pounds": "Libras", "Press “Continue” to proceed or “Back” to cancel.": "Presiona “Continuar” para continuar o “Atrás” para cancelar.", "Processing swap request": "Procesando solicitud de intercambio", + "Processing add funds request": "Procesando solicitud para agregar fondos", "Processing balance {{amount}}": "Saldo en procesamiento {{amount}}", "Processing balance → {{amount}}": "Saldo en procesamiento → {{amount}}", - "Processing bridge": "Procesando bridge", "Processing rollover": "Procesando refinanciamiento", - "Processing swap": "Procesando intercambio", - "Processing transfer": "Procesando transferencia", "Processing transfer...": "Procesando transferencia...", "Processing...": "Procesando...", "Processing": "Procesando", @@ -785,6 +784,7 @@ "View pending request": "Ver solicitud pendiente", "View pending requests": "Ver solicitudes pendientes", "View PIN number": "Ver PIN", + "View requests": "Ver solicitudes", "View Statement": "Ver estado de cuenta", "View statement": "Ver resumen", "Visa Signature benefits": "Beneficios Visa Signature", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 821be5a3ac..875fb3e785 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -52,6 +52,7 @@ "Activate your new Exa Card": "Ative seu novo Exa Card", "Activating your new Exa Card": "Ativando seu novo Exa Card", "Activity": "Atividade", + "Add funds request sent": "Solicitação para adicionar fundos enviada", "Add funds to account": "Adicionar fundos à conta", "Add funds to your account": "Adicionar fundos à sua conta", "Add funds to your account to start spending with the Exa Card.": "Adicione fundos à sua conta para começar a gastar com o Exa Card.", @@ -540,12 +541,10 @@ "Pounds": "Libras", "Press “Continue” to proceed or “Back” to cancel.": "Pressione “Continuar” para prosseguir ou “Voltar” para cancelar.", "Processing swap request": "Processando solicitação de troca", + "Processing add funds request": "Processando solicitação para adicionar fundos", "Processing balance {{amount}}": "Saldo em processamento {{amount}}", "Processing balance → {{amount}}": "Saldo em processamento → {{amount}}", - "Processing bridge": "Processando bridge", "Processing rollover": "Processando refinanciamento", - "Processing swap": "Processando troca", - "Processing transfer": "Processando transferência", "Processing transfer...": "Processando transferência...", "Processing...": "Processando...", "Processing": "Processando", @@ -785,6 +784,7 @@ "View pending request": "Ver solicitação pendente", "View pending requests": "Ver solicitações pendentes", "View PIN number": "Ver PIN", + "View requests": "Ver solicitações", "View statement": "Ver extrato", "View Statement": "Ver extrato", "Visa Signature benefits": "Benefícios Visa Signature", From 050b007c6e82ad1dc12e51389f4e6725f0802b85 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 18:46:33 -0300 Subject: [PATCH 13/28] =?UTF-8?q?=F0=9F=92=84=20app:=20toggle=20qr=20inlin?= =?UTF-8?q?e=20on=20receive=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/warm-swifts-flip.md | 5 ++ src/components/add-funds/AddCrypto.tsx | 119 ++++++++++++------------- src/i18n/es.json | 3 +- src/i18n/pt.json | 3 +- 4 files changed, 66 insertions(+), 64 deletions(-) create mode 100644 .changeset/warm-swifts-flip.md diff --git a/.changeset/warm-swifts-flip.md b/.changeset/warm-swifts-flip.md new file mode 100644 index 0000000000..5ed8e42447 --- /dev/null +++ b/.changeset/warm-swifts-flip.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 toggle qr inline on receive screen diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index d5b08bd01e..36d61b31f3 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -7,7 +7,7 @@ import { setStringAsync } from "expo-clipboard"; import { selectionAsync } from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { AlertTriangle, ArrowLeft, Copy, QrCode, RefreshCw, Share as ShareIcon } from "@tamagui/lucide-icons"; +import { AlertTriangle, ArrowLeft, Copy, Hash, QrCode, RefreshCw, Share as ShareIcon } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, XStack, YStack } from "tamagui"; @@ -31,7 +31,6 @@ import CopyAddressSheet from "../shared/CopyAddressSheet"; import IconButton from "../shared/IconButton"; import Image from "../shared/Image"; import InfoAlert from "../shared/InfoAlert"; -import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; import SendWarning from "../shared/SendWarning"; import Skeleton from "../shared/Skeleton"; @@ -89,6 +88,7 @@ export default function AddCrypto() { ? (receiveChain?.name ?? alchemyChainById.get(receiveChainId)?.name ?? `#${receiveChainId}`) : chain.name; const assets = isBridge ? [currency] : asset ? [asset] : supportedAssets; + const bridgeLogoURI = isBridge && network in networkLogos ? networkLogos[network] : undefined; const toast = useToastController(); const [copyAddressShown, setCopyAddressShown] = useState(false); @@ -147,11 +147,7 @@ export default function AddCrypto() { isPending={!isBridge && !asset && isPending} onPress={isBridge || asset ? undefined : () => setSupportedAssetsShown(true)} /> - + {t("Wallet address")} - - {address ? ( - - {address} - - ) : isBridge && isError && !isFetching ? ( - - {t("Failed to load deposit address.")} - - ) : ( - - )} - + {qrShown && address ? ( + + + + + {bridgeLogoURI ? ( + + ) : ( + + )} + + + + ) : ( + + {address ? ( + + {address} + + ) : isBridge && isError && !isFetching ? ( + + {t("Failed to load deposit address.")} + + ) : ( + + )} + + )} {!!address && !memo && ( - setQRShown(true)}> + setQRShown(!qrShown)}> - {t("Show QR")} + {qrShown ? t("Show wallet address") : t("Show QR")} - + {qrShown ? ( + + ) : ( + + )} )} @@ -221,43 +257,6 @@ export default function AddCrypto() { )} - {!!address && !memo && ( - { - setQRShown(false); - }} - > - - - - {isBridge - ? t("{{network}} deposit address", { network: networkName }) - : t("Your {{chain}} address", { chain: networkName })} - - - - - { - setQRShown(false); - }} - > - - {t("Close")} - - - - - - )} { diff --git a/src/i18n/es.json b/src/i18n/es.json index f3c0062af9..e558f95396 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -13,7 +13,6 @@ "{{currency}} via {{method}}": "{{currency}} vía {{method}}", "{{currency}} via {{methods}}": "{{currency}} vía {{methods}}", "{{discount}} off": "{{discount}} off", - "{{network}} deposit address": "Dirección de depósito de {{network}}", "{{percent}} OFF": "{{percent}} OFF", "{{rate}} APR": "{{rate}} TNA", "{{source}} on {{network}} matches {{destination}} on {{chain}}. You can swap and bridge between these assets or just select another supported asset.": "{{source}} en {{network}} coincide con {{destination}} en {{chain}}. Puedes hacer swap y bridge entre estos activos o simplemente seleccionar otro activo soportado.", @@ -639,6 +638,7 @@ "Show QR": "Mostrar QR", "Show QR Code": "Mostrar código QR", "Show sensitive": "Mostrar sensibles", + "Show wallet address": "Mostrar dirección de billetera", "Sign in": "Iniciar sesión", "Sign up with browser wallet": "Regístrate con tu billetera del navegador", "Sign up with Passkey": "Regístrate con llave de acceso", @@ -831,7 +831,6 @@ "You’re all set!": "¡Todo listo!", "You’re trying to borrow more than your collateral allows. Please enter a lower amount.": "Estás intentando pedir prestado más de lo que tu garantía permite. Por favor, introduce un monto menor.", "You've reached 90% of your weekly card spending limit.": "Has alcanzado el 90% de tu límite de gasto semanal.", - "Your {{chain}} address": "Tu dirección en {{chain}}", "Your address needs to be verified": "Tu dirección necesita ser verificada", "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Tus activos aún no pueden respaldar tu tarjeta. Intercámbialos por un activo compatible para empezar a gastar.", "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.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 875fb3e785..6324447fe1 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -13,7 +13,6 @@ "{{currency}} via {{method}}": "{{currency}} via {{method}}", "{{currency}} via {{methods}}": "{{currency}} via {{methods}}", "{{discount}} off": "{{discount}} off", - "{{network}} deposit address": "Endereço de depósito {{network}}", "{{percent}} OFF": "{{percent}} OFF", "{{rate}} APR": "{{rate}} APR", "{{source}} on {{network}} matches {{destination}} on {{chain}}. You can swap and bridge between these assets or just select another supported asset.": "{{source}} em {{network}} corresponde a {{destination}} em {{chain}}. Você pode fazer swap e bridge entre esses ativos ou simplesmente selecionar outro ativo suportado.", @@ -639,6 +638,7 @@ "Show QR": "Mostrar QR", "Show QR Code": "Mostrar código QR", "Show sensitive": "Mostrar sensíveis", + "Show wallet address": "Mostrar endereço da carteira", "Sign in": "Entrar", "Sign up with browser wallet": "Cadastre-se com a carteira do navegador", "Sign up with Passkey": "Cadastre-se com chave de acesso", @@ -831,7 +831,6 @@ "You’re all set!": "Tudo pronto!", "You’re trying to borrow more than your collateral allows. Please enter a lower amount.": "Você está tentando emprestar mais do que sua garantia permite. Por favor, insira um valor menor.", "You've reached 90% of your weekly card spending limit.": "Você atingiu 90% do seu limite de gastos semanal.", - "Your {{chain}} address": "Seu endereço na {{chain}}", "Your address needs to be verified": "Seu endereço precisa ser verificado", "Your assets can't back your card yet. Swap them to a supported asset to start spending.": "Seus ativos ainda não podem servir de garantia para o seu cartão. Troque-os por um ativo compatível para começar a gastar.", "Your card is awaiting activation. Follow the steps to enable it.": "Seu cartão está aguardando ativação. Siga os passos para ativá-lo.", From dfd77759be396e5333da03a035498752879cd501 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 23 Jul 2026 18:47:00 -0300 Subject: [PATCH 14/28] =?UTF-8?q?=F0=9F=92=84=20app:=20redesign=20copy=20a?= =?UTF-8?q?ddress=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/neat-cranes-copy.md | 5 ++ src/components/add-funds/AddCrypto.tsx | 3 +- src/components/shared/CopyAddressSheet.tsx | 98 ++++++++-------------- src/i18n/es.json | 1 - src/i18n/pt.json | 1 - 5 files changed, 43 insertions(+), 65 deletions(-) create mode 100644 .changeset/neat-cranes-copy.md diff --git a/.changeset/neat-cranes-copy.md b/.changeset/neat-cranes-copy.md new file mode 100644 index 0000000000..d29a5967eb --- /dev/null +++ b/.changeset/neat-cranes-copy.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 redesign copy address sheet diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx index 36d61b31f3..53421229ea 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -264,8 +264,7 @@ export default function AddCrypto() { }} address={isBridge ? depositAddress : undefined} network={isBridge ? network : receiveChainId ? networkName : undefined} - networkLogo={isBridge ? networkLogos[network] : receiveChain?.logoURI} - assets={isBridge || asset ? assets : undefined} + asset={isBridge ? currency : asset || undefined} /> {!isBridge && !asset && ( void; open: boolean; }) { const { address: accountAddress } = useAccount(); - const { supportedAssets, isPending } = useMarkets(); - const displayAssets = assets ?? supportedAssets; const { t } = useTranslation(); return ( - + - + {t("Address copied")} - + {t("Double-check your address before sending funds to avoid losing them.")} - + {overrideAddress ?? accountAddress} - - - - {t("Network")} - - - {displayAssets.length === 1 ? t("Asset") : t("Supported Assets")} - - - - - {networkLogo ? ( - - ) : ( - - )} - - {network ?? chain.name} + + + + + + + { + presentArticle("8950801").catch(reportError); + }} + > + {" "} + {t("Learn more about adding funds.")} - - - {!assets && isPending - ? Array.from({ length: 5 }, (_, index) => ( - - - - )) - : displayAssets.map((symbol, index) => ( - - - - ))} - + - - + + ) : unavailable ? ( + + + + {t("An error occurred. Please try again later.")} + + + + ) : unverified ? ( + + + + {t("Verify your identity")} + + + {t("Complete identity verification to start swapping.")} + + + + ) : balancesUnavailable ? ( + + + + {t("An error occurred. Please try again later.")} + + + + ) : empty ? ( + + + + {t("Nothing to swap yet")} + + + {t("Deposit assets to start swapping.")} + + + ) : ( + <> + + + + + {(["from", "to"] as const).map((type) => { + const tokenData = type === "from" ? fromToken : toToken; + const amount = type === "from" ? fromAmount : toAmount; + const isActive = activeInput === type; + return ( + { + updateSwap((old) => ({ ...old, tokenSelectionType: type, tokenModalOpen: true })); + setAcknowledged(false); + }} + onFocus={() => { + setAcknowledged(false); + }} + onChange={(value: bigint) => { + setActiveInput(type); + handleAmountChange(value, type); + setAcknowledged(false); + }} + onUseMax={(value: bigint) => { + setActiveInput(type); + handleAmountChange(value, type); + setAcknowledged(false); + }} + /> + ); + })} + + {fromToken && toToken && route && ( + + )} + + + + + + {(caution || danger) && showWarning && ( + + + { + setAcknowledged(!acknowledged); }} - onUseMax={(value: bigint) => { - setActiveInput(type); - handleAmountChange(value, type); - setAcknowledged(false); + > + {danger ? ( + + ) : ( + + + + + + )} + + {danger + ? t( + "Swapping this much of your collateral could instantly trigger liquidation. Try a smaller amount to stay protected.", + ) + : t("I acknowledge the risks of swapping this much of my collateral assets.")} + + + + + )} + + + { + openBrowser(`https://li.fi/`).catch(reportError); + }} + /> + ), }} /> - ); - })} - - {fromToken && toToken && route && ( - - )} - - - - - - {(caution || danger) && showWarning && ( - - - { - setAcknowledged(!acknowledged); - }} - > - {danger ? ( - - ) : ( - - - - - - )} - - {danger - ? t( - "Swapping this much of your collateral could instantly trigger liquidation. Try a smaller amount to stay protected.", - ) - : t("I acknowledge the risks of swapping this much of my collateral assets.")} - - )} - - - { - openBrowser(`https://li.fi/`).catch(reportError); - }} - /> - ), - }} - /> - - - - - - updateSwap((old) => ({ ...old, tokenModalOpen: false }))} - isLoading={isTokensLoading} - title={tokenSelectionType === "from" ? t("Select token to pay") : t("Select token to receive")} - /> + + + updateSwap((old) => ({ ...old, tokenModalOpen: false }))} + isLoading={isTokensLoading} + title={tokenSelectionType === "from" ? t("Select token to pay") : t("Select token to receive")} + /> + + )} ); { diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index 0b22dd8796..5c03b6f860 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -25,6 +25,7 @@ "Choose installments": "Elegí cuotas", "Choose your preferred authentication method": "Elegí tu método de autenticación preferido", "Complete a quick identity check to access more networks.": "Completá una verificación de identidad rápida para acceder a más redes.", + "Complete identity verification to start swapping.": "Completá la verificación de identidad para empezar a intercambiar.", "Connect to Exactly Protocol to access USDC funding": "Conectate a Exactly Protocol para acceder a financiamiento en USDC", "Connect to LI.FI to swap tokens": "Conectate a LI.FI para intercambiar tokens", "Connect your wallet to Exactly Protocol": "Conectá tu billetera a Exactly Protocol", @@ -37,6 +38,7 @@ "Couldn't update the contact. Please try again.": "No se pudo actualizar el contacto. Intentá de nuevo.", "Deposit {{symbol}} directly to an external wallet": "Depositá {{symbol}} directamente en una billetera externa", "Deposit {{symbol}} into your Exa App wallet": "Depositá {{symbol}} en tu billetera de Exa App", + "Deposit assets to start swapping.": "Depositá activos para empezar a intercambiar.", "Double-check your address before sending funds to avoid losing them.": "Verificá tu dirección antes de enviar fondos para evitar perderlos.", "Enter a lower amount to swap": "Introducí una cantidad menor para intercambiar", "Enter a purchase amount": "Ingresá un monto de compra", diff --git a/src/i18n/es.json b/src/i18n/es.json index a81bb951fe..525b5df3e0 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -173,6 +173,7 @@ "Collateral assets": "Activos de garantía", "Collateral": "Garantía", "Complete a quick identity check to access more networks.": "Completa una verificación de identidad rápida para acceder a más redes.", + "Complete identity verification to start swapping.": "Completa la verificación de identidad para empezar a intercambiar.", "Complete verification": "Completar verificación", "Confirm and borrow {{symbol}}": "Confirmar y pedir prestado {{symbol}}", "Confirm and receive {{symbol}}": "Confirmar y recibir {{symbol}}", @@ -234,6 +235,7 @@ "Deposit {{symbol}} into your Exa App wallet": "Deposita {{symbol}} en tu billetera de Exa App", "Deposit address": "Dirección de depósito", "Deposit alias": "Alias de depósito", + "Deposit assets to start swapping.": "Deposita activos para empezar a intercambiar.", "Deposit with PIX": "Depositar con PIX", "Details": "Detalles", "Dismiss": "Descartar", @@ -784,6 +786,7 @@ "USDC funding": "Financiamiento con USDC", "Use a 1-3 letter state code": "Usa un código de estado de 1 a 3 letras", "Verification failed": "La verificación falló", + "Verification in review": "Verificación en revisión", "Verify your identity": "Verifica tu identidad", "Verifying your identity grants you access to our onchain Exa Card, enabling you to easily spend your crypto.": "Verificar tu identidad te da acceso a nuestra Exa Card on-chain, permitiéndote gastar fácilmente tu cripto.", "Verifying...": "Verificando...", @@ -854,6 +857,7 @@ "Your funds serve as collateral to increase your spending limits.": "Tus fondos sirven como garantía para aumentar tus límites de gasto.", "Your funds serve as collateral, increasing your spending limits. The more funds you add, the more you can spend with the Exa Card.": "Tus fondos sirven como garantía, aumentando tus límites de gasto. Cuantos más fondos agregues, más podrás gastar con la Exa Card.", "Your ID needs to be updated": "Tu documento necesita ser actualizado", + "Your identity verification is under review. We'll let you know once it's been processed.": "Tu verificación de identidad está en revisión. Te avisaremos cuando se haya procesado.", "Your KYC isn't approved for this currency": "Tu KYC no está aprobado para esta moneda", "Your limit increase request is under review. We'll let you know once it's been processed.": "Tu solicitud de aumento de límite está en revisión. Te avisaremos cuando se haya procesado.", "Your password manager does not support passkey backups. Please try a different one": "Tu gestor de contraseñas no admite copias de seguridad de llaves de acceso. Por favor, prueba con otro.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index df8bfc4c08..6cc8e13e4a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -173,6 +173,7 @@ "Collateral assets": "Ativos de garantia", "Collateral": "Garantia", "Complete a quick identity check to access more networks.": "Complete uma verificação de identidade rápida para acessar mais redes.", + "Complete identity verification to start swapping.": "Conclua a verificação de identidade para começar a trocar.", "Complete verification": "Completar verificação", "Confirm and borrow {{symbol}}": "Confirmar empréstimo de {{symbol}}", "Confirm and receive {{symbol}}": "Confirmar e receber {{symbol}}", @@ -234,6 +235,7 @@ "Deposit {{symbol}} into your Exa App wallet": "Deposite {{symbol}} na sua carteira do Exa App", "Deposit address": "Endereço de depósito", "Deposit alias": "Chave de depósito", + "Deposit assets to start swapping.": "Deposite ativos para começar a trocar.", "Deposit with PIX": "Depositar com PIX", "Details": "Detalhes", "Dismiss": "Descartar", @@ -784,6 +786,7 @@ "USDC funding": "Financiamento com USDC", "Use a 1-3 letter state code": "Use um código de estado de 1 a 3 letras", "Verification failed": "A verificação falhou", + "Verification in review": "Verificação em análise", "Verify your identity": "Verifique sua identidade", "Verifying your identity grants you access to our onchain Exa Card, enabling you to easily spend your crypto.": "Verificar sua identidade dá acesso ao nosso Exa Card on-chain, permitindo que você gaste facilmente sua cripto.", "Verifying...": "Verificando...", @@ -854,6 +857,7 @@ "Your funds serve as collateral to increase your spending limits.": "Seus fundos servem como garantia para aumentar seus limites de gastos.", "Your funds serve as collateral, increasing your spending limits. The more funds you add, the more you can spend with the Exa Card.": "Seus fundos servem como garantia, aumentando seus limites de gastos. Quanto mais fundos você adicionar, mais poderá gastar com o Exa Card.", "Your ID needs to be updated": "Seu documento precisa ser atualizado", + "Your identity verification is under review. We'll let you know once it's been processed.": "Sua verificação de identidade está em análise. Avisaremos quando for processada.", "Your KYC isn't approved for this currency": "Seu KYC não está aprovado para esta moeda", "Your limit increase request is under review. We'll let you know once it's been processed.": "Sua solicitação de aumento de limite está em análise. Avisaremos quando for processada.", "Your password manager does not support passkey backups. Please try a different one": "Seu gerenciador de senhas não suporta backup de chaves de acesso. Por favor, tente outro", diff --git a/src/utils/lifi.ts b/src/utils/lifi.ts index efc5d29841..e47004827c 100644 --- a/src/utils/lifi.ts +++ b/src/utils/lifi.ts @@ -82,14 +82,8 @@ export function balancesOptions(account: Address | undefined) { if (!account) return {} as Record; ensureConfig(); const [amounts, lifiTokens, exa] = await Promise.all([ - getWalletBalances(account).catch((error: unknown) => { - reportError(error); - return {} as Record; - }), - queryClient.fetchQuery(lifiTokensOptions).catch((error: unknown) => { - reportError(error); - return [] as Token[]; - }), + getWalletBalances(account), + queryClient.fetchQuery(lifiTokensOptions), exaAddress ? getToken(chain.id, exaAddress).catch((error: unknown) => { reportError(error); @@ -491,7 +485,9 @@ async function getWalletBalances(account: Address) { }), ); for (const [key, { ids, error }] of failures) { - reportError(new Error(`balances failed for chains ${ids.join(", ")}: ${key}`, { cause: error })); + const failure = new Error(`balances failed for chains ${ids.join(", ")}: ${key}`, { cause: error }); + if (ids.includes(chain.id)) throw failure; + reportError(failure); } return balances; } From 91d40d5e7406c078b871ddfcd0bb7f1848cecad6 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Fri, 19 Jun 2026 15:27:24 -0300 Subject: [PATCH 23/28] =?UTF-8?q?=F0=9F=8D=B1=20app:=20add=20base=20card?= =?UTF-8?q?=20svgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/images/card-bg-base.svg | 1 + src/assets/images/card-signature-base.svg | 1 + 2 files changed, 2 insertions(+) create mode 100644 src/assets/images/card-bg-base.svg create mode 100644 src/assets/images/card-signature-base.svg diff --git a/src/assets/images/card-bg-base.svg b/src/assets/images/card-bg-base.svg new file mode 100644 index 0000000000..d5c9bca57c --- /dev/null +++ b/src/assets/images/card-bg-base.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/card-signature-base.svg b/src/assets/images/card-signature-base.svg new file mode 100644 index 0000000000..274b919546 --- /dev/null +++ b/src/assets/images/card-signature-base.svg @@ -0,0 +1 @@ + \ No newline at end of file From 843649e3ac2bf95d4c8978c61324286f588a149e Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Thu, 11 Jun 2026 11:48:55 -0300 Subject: [PATCH 24/28] =?UTF-8?q?=F0=9F=92=84=20app:=20implement=20app=20t?= =?UTF-8?q?heme=20for=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/clever-crews-pay.md | 5 + common/wagmi.config.ts | 3 + metro.config.cjs | 103 ++++++++++-------- src/app/+html.tsx | 29 +++-- src/app/_layout.tsx | 3 +- src/assets/images/account-created-blob.svg | 2 +- src/assets/images/account-created.svg | 2 +- src/assets/images/activity-empty.svg | 2 +- src/assets/images/background.svg | 2 +- src/assets/images/calendar-blob.svg | 2 +- src/assets/images/calendar-rollover.svg | 2 +- src/assets/images/calendar.svg | 2 +- src/assets/images/connect.svg | 2 +- src/assets/images/defi-banner.svg | 2 +- src/assets/images/defi.svg | 2 +- src/assets/images/denied.svg | 2 +- src/assets/images/documents.svg | 2 +- src/assets/images/earnings-blob.svg | 2 +- src/assets/images/earnings.svg | 2 +- src/assets/images/error.svg | 2 +- src/assets/images/exa-card-blob.svg | 2 +- src/assets/images/exa-card.svg | 2 +- src/assets/images/exa-promo.svg | 2 +- src/assets/images/face-id.svg | 2 +- src/assets/images/passkeys-blob.svg | 2 +- src/assets/images/passkeys.svg | 2 +- src/assets/images/qr-code-blob.svg | 2 +- src/assets/images/qr-code.svg | 2 +- src/assets/images/types.d.ts | 10 ++ src/assets/images/verify-identity.svg | 2 +- src/components/activity/Empty.tsx | 5 +- src/components/add-funds/KYC.tsx | 5 +- src/components/add-funds/Status.tsx | 13 ++- src/components/auth/Auth.tsx | 31 +++--- src/components/auth/ListItem.tsx | 5 +- src/components/auth/Passkeys.tsx | 9 +- src/components/auth/Success.tsx | 9 +- src/components/benefits/BenefitsSection.tsx | 7 +- src/components/card/CardDetails.tsx | 19 +++- src/components/card/CardDisclaimer.tsx | 9 +- src/components/card/VerificationFailure.tsx | 5 +- src/components/card/exa-card/CardContents.tsx | 10 +- src/components/card/exa-card/ExaCard.tsx | 18 ++- src/components/context/ThemeProvider.tsx | 7 +- src/components/defi/ConnectionSheet.tsx | 5 +- src/components/defi/IntroSheet.tsx | 5 +- src/components/home/CardStatus.tsx | 10 +- src/components/home/ExploreDeFi.tsx | 6 +- src/components/home/PromoSheet.tsx | 5 +- src/components/home/VisaSignatureBanner.tsx | 2 +- src/components/home/card-upgrade/Intro.tsx | 5 +- src/components/pay/Empty.tsx | 9 +- src/components/pay/RolloverIntroSheet.tsx | 5 +- src/components/ramp/AssetHero.tsx | 7 +- src/components/shared/Error.tsx | 5 +- src/components/shared/Skeleton.tsx | 6 +- src/components/shared/ThemedSvg.tsx | 78 +++++++++++++ src/i18n/es.json | 1 + src/i18n/pt.json | 1 + tamagui.config.ts | 71 +++++++++++- 60 files changed, 410 insertions(+), 164 deletions(-) create mode 100644 .changeset/clever-crews-pay.md mode change 100755 => 100644 src/assets/images/account-created-blob.svg mode change 100755 => 100644 src/assets/images/account-created.svg mode change 100755 => 100644 src/assets/images/calendar-blob.svg mode change 100755 => 100644 src/assets/images/calendar.svg mode change 100755 => 100644 src/assets/images/earnings-blob.svg mode change 100755 => 100644 src/assets/images/earnings.svg mode change 100755 => 100644 src/assets/images/exa-card-blob.svg mode change 100755 => 100644 src/assets/images/exa-card.svg mode change 100755 => 100644 src/assets/images/passkeys-blob.svg mode change 100755 => 100644 src/assets/images/passkeys.svg mode change 100755 => 100644 src/assets/images/qr-code-blob.svg mode change 100755 => 100644 src/assets/images/qr-code.svg create mode 100644 src/components/shared/ThemedSvg.tsx diff --git a/.changeset/clever-crews-pay.md b/.changeset/clever-crews-pay.md new file mode 100644 index 0000000000..bf402955ad --- /dev/null +++ b/.changeset/clever-crews-pay.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 implement app theme for base diff --git a/common/wagmi.config.ts b/common/wagmi.config.ts index b703fcc341..ac4a699425 100644 --- a/common/wagmi.config.ts +++ b/common/wagmi.config.ts @@ -190,6 +190,7 @@ function addresses( } function chain(): Plugin { + const isBase = chainId === base.id || chainId === baseSepolia.id; if (chainId === anvil.id) { return { name: "Chain", @@ -199,6 +200,7 @@ const chain = anvil as Chain chain.rpcUrls.alchemy = chain.rpcUrls.default chain.contracts = { multicall3: { address: "${optimism.contracts.multicall3.address}" } } chain.blockExplorers = { default: { name: "Otterscan", url: "http://localhost:5100" } } +export const isBase: boolean = ${isBase} export default chain as Chain & { contracts: { multicall3: { address: \`0x\${string}\` } } rpcUrls: { alchemy: { http: readonly [string] } } @@ -219,6 +221,7 @@ export default chain as Chain & { run: () => ({ content: `import { ${importName} } from "@account-kit/infra" import { type Chain } from "viem/chains" +export const isBase: boolean = ${isBase} export default ${importName} as Chain & { contracts: { multicall3: { address: \`0x\${string}\` } } rpcUrls: { alchemy: { http: readonly [string] } } diff --git a/metro.config.cjs b/metro.config.cjs index 40b6eba0a3..691fdc683c 100644 --- a/metro.config.cjs +++ b/metro.config.cjs @@ -1,48 +1,65 @@ -const { getSentryExpoConfig } = require("@sentry/react-native/metro"); +const { getExpoTransformer, getReactNativeTransformer } = require("react-native-svg-transformer"); +const reactNativeSvgTransformer = require("react-native-svg-transformer/expo"); + const path = require("node:path"); -const config = getSentryExpoConfig(__dirname, { annotateReactComponents: true }); +const rawTransformer = getExpoTransformer() ?? getReactNativeTransformer(); +const themeable = /\b(fill|stroke)="#([0-9a-f]{6})"/g; -/** @type {import('metro-config').InputConfigT} */ -module.exports = { - ...config, - resolver: { - ...config.resolver, - extraNodeModules: { crypto: require.resolve("react-native-quick-crypto") }, - assetExts: config.resolver?.assetExts?.filter((extension) => extension !== "svg"), - sourceExts: [...(config.resolver?.sourceExts ?? []), "svg"], - blockList: [ - ...((config.resolver?.blockList && - (Array.isArray(config.resolver.blockList) ? config.resolver.blockList : [config.resolver.blockList])) ?? - []), - new RegExp(path.join(__dirname, String.raw`\.\w+/`)), - new RegExp(path.join(__dirname, "android/")), - new RegExp(path.join(__dirname, "contracts/")), - new RegExp(path.join(__dirname, "build/")), - new RegExp(path.join(__dirname, "dist/")), - new RegExp(path.join(__dirname, "ios/")), - new RegExp(path.join(__dirname, "public/")), - new RegExp(path.join(__dirname, "server/")), - ], - resolveRequest: (context, moduleName, platform) => { - if (moduleName === "tslib") return context.resolveRequest(context, "tslib/tslib.es6.js", platform); - if ( - /date-fns\/locale\.(?:js|cjs|mjs)$/.test(context.originModulePath) && - moduleName.startsWith("./locale/") && - !/^(?:en|es|pt)(?:-|$)/.test(moduleName.slice("./locale/".length).replace(/\.js$/, "")) - ) { - return { type: "empty" }; - } - try { - return context.resolveRequest(context, moduleName, platform); - } catch (error) { - if (moduleName.endsWith(".js")) return context.resolveRequest(context, moduleName.slice(0, -3), platform); - throw error; - } +module.exports = function config() { + const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + const base = getSentryExpoConfig(__dirname, { annotateReactComponents: true }); + /** @type {import('metro-config').InputConfigT} */ + const merged = { + ...base, + resolver: { + ...base.resolver, + extraNodeModules: { crypto: require.resolve("react-native-quick-crypto") }, + assetExts: base.resolver?.assetExts?.filter((extension) => extension !== "svg"), + sourceExts: [...(base.resolver?.sourceExts ?? []), "svg"], + blockList: [ + ...((base.resolver?.blockList && + (Array.isArray(base.resolver.blockList) ? base.resolver.blockList : [base.resolver.blockList])) ?? + []), + new RegExp(path.join(__dirname, String.raw`\.\w+/`)), + new RegExp(path.join(__dirname, "android/")), + new RegExp(path.join(__dirname, "contracts/")), + new RegExp(path.join(__dirname, "build/")), + new RegExp(path.join(__dirname, "dist/")), + new RegExp(path.join(__dirname, "ios/")), + new RegExp(path.join(__dirname, "public/")), + new RegExp(path.join(__dirname, "server/")), + ], + resolveRequest: (context, moduleName, platform) => { + if (moduleName === "tslib") return context.resolveRequest(context, "tslib/tslib.es6.js", platform); + if ( + /date-fns\/locale\.(?:js|cjs|mjs)$/.test(context.originModulePath) && + moduleName.startsWith("./locale/") && + !/^(?:en|es|pt)(?:-|$)/.test(moduleName.slice("./locale/".length).replace(/\.js$/, "")) + ) { + return { type: "empty" }; + } + try { + return context.resolveRequest(context, moduleName, platform); + } catch (error) { + if (moduleName.endsWith(".js")) return context.resolveRequest(context, moduleName.slice(0, -3), platform); + throw error; + } + }, }, - }, - transformer: { - ...config.transformer, - babelTransformerPath: require.resolve("react-native-svg-transformer/expo"), - }, + transformer: { ...base.transformer, babelTransformerPath: require.resolve("./metro.config.cjs") }, + }; + return merged; +}; + +/** @param {import("@sentry/react-native/dist/js/tools/vendor/metro/metroBabelTransformer").BabelTransformerArgs} args */ +module.exports.transform = function transform(args) { + if (args.filename.endsWith(".svg")) { + if (args.src.includes(' data-themed=""')) { + const themed = args.src.replaceAll(' data-themed=""', "").replaceAll(themeable, '$1="var(--s$2,#$2)"'); + return rawTransformer.transform({ ...args, src: `module.exports = ${JSON.stringify(themed)};` }); + } + return reactNativeSvgTransformer.transform(args); + } + return require("@sentry/react-native/dist/js/tools/sentryBabelTransformer").transform(args); }; diff --git a/src/app/+html.tsx b/src/app/+html.tsx index e76c93c96d..5ecd8ce70c 100644 --- a/src/app/+html.tsx +++ b/src/app/+html.tsx @@ -3,6 +3,7 @@ import React, { type ReactNode } from "react"; import { ScrollViewStyleReset } from "expo-router/html"; import domain from "@exactly/common/domain"; +import { isBase } from "@exactly/common/generated/chain"; import appMetadata from "../../package.json"; @@ -28,26 +29,30 @@ export default function HTML({ children }: { children: ReactNode }) { - + `); +} + +function recolor(hex: string, brandHue: number) { + const [h, s, l] = toHsl(hex); + if (s < 0.15 || l < 0.06 || l > 0.95 || h < tealMin || h > tealMax) return `#${hex}`; + return hsl(brandHue, s, l); +} + +function hueOf(color: unknown) { + const hex = typeof color === "string" ? color.replace("#", "") : ""; + return /^[\da-f]{6}$/i.test(hex) ? toHsl(hex)[0] : fallbackHue; +} + +function toHsl(hex: string): [number, number, number] { + const r = Number.parseInt(hex.slice(0, 2), 16) / 255; + const g = Number.parseInt(hex.slice(2, 4), 16) / 255; + const b = Number.parseInt(hex.slice(4, 6), 16) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + const s = d === 0 ? 0 : l > 0.5 ? d / (2 - max - min) : d / (max + min); + const h = d === 0 ? 0 : 60 * (max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4); + return [h, s, l]; +} + +function hsl(h: number, s: number, l: number) { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + const [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x]; + const channel = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, "0"); + return `#${channel(r)}${channel(g)}${channel(b)}`; +} diff --git a/src/i18n/es.json b/src/i18n/es.json index 525b5df3e0..33ce0f920f 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -483,6 +483,7 @@ "Non-collateral assets": "Activos sin garantía", "Non-supported network": "Red no compatible", "Nothing to see here for now. Once you add funds or make a payment, all your account activity will appear in this section.": "Nada que ver por ahora. Una vez que agregues fondos o realices un pago, toda la actividad de tu cuenta aparecerá en esta sección.", + "Nothing to swap yet": "Aún no hay nada para intercambiar", "Now": "Ahora", "Numbers only": "Solo números", "On chain": "On-chain", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 6cc8e13e4a..80b729ef1c 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -483,6 +483,7 @@ "Non-collateral assets": "Ativos sem garantia", "Non-supported network": "Rede não compatível", "Nothing to see here for now. Once you add funds or make a payment, all your account activity will appear in this section.": "Nada para ver por enquanto. Assim que você adicionar fundos ou fizer um pagamento, toda a atividade da sua conta aparecerá nesta seção.", + "Nothing to swap yet": "Ainda não há nada para trocar", "Now": "Agora", "Numbers only": "Apenas números", "On chain": "On-chain", diff --git a/tamagui.config.ts b/tamagui.config.ts index 59c232201f..070180b512 100644 --- a/tamagui.config.ts +++ b/tamagui.config.ts @@ -4,9 +4,22 @@ import { createAnimations } from "@tamagui/animations-moti"; import { config } from "@tamagui/config/v3"; import { createFont, createTamagui, createTokens } from "tamagui"; +import { isBase } from "@exactly/common/generated/chain"; + const tokens = createTokens({ color: { - cardBackground: "#1A181A", + baseBlue: "#0000FF", + baseBlueStrong: "#0010BF", + baseCerulean: "#3C8AFF", + baseGray0: "#FFFFFF", + baseGray10: "#EEF0F3", + baseGray100: "#0A0B0D", + baseGray15: "#DEE1E7", + baseGray30: "#B1B7C3", + baseGray50: "#717886", + baseGray80: "#32353D", + cardBackground: "#1A211E", + cardPreviewBackground: "#1A181A", creditDark1: "#18111B", creditDark5: "#48295C", creditDark9: "#8E4EC6", @@ -216,6 +229,57 @@ const tokens = createTokens({ zIndex: config.tokens.zIndex, }); +const baseLight = { + backgroundSoft: tokens.color.baseGray0, + backgroundMild: tokens.color.baseGray10, + backgroundStrong: tokens.color.baseGray15, + backgroundBrand: tokens.color.baseBlue, + backgroundBrandSoft: tokens.color.baseGray0, + backgroundBrandMild: tokens.color.baseGray10, + backgroundBanner: tokens.color.baseBlue, + uiNeutralPrimary: tokens.color.baseGray100, + uiNeutralSecondary: tokens.color.baseGray80, + uiNeutralTertiary: tokens.color.baseGray15, + uiNeutralPlaceholder: tokens.color.baseGray30, + uiNeutralInversePrimary: tokens.color.baseGray0, + uiNeutralInverseSecondary: tokens.color.baseGray15, + uiBrandPrimary: tokens.color.baseBlueStrong, + uiBrandSecondary: tokens.color.baseBlue, + uiBrandTertiary: tokens.color.baseCerulean, + interactiveBaseBrandDefault: tokens.color.baseBlue, + interactiveBaseBrandHover: tokens.color.baseBlueStrong, + interactiveBaseBrandPressed: tokens.color.baseBlue, + interactiveBaseBrandSoftDefault: tokens.color.baseGray10, + interactiveBaseBrandSoftHover: tokens.color.baseGray15, + interactiveBaseBrandSoftPressed: tokens.color.baseGray10, + interactiveOnBaseBrandDefault: tokens.color.baseGray0, + interactiveOnBaseBrandSoft: tokens.color.baseGray100, + interactiveTextBrandDefault: tokens.color.baseBlue, + interactiveTextBrandHover: tokens.color.baseBlueStrong, + interactiveTextBrandPressed: tokens.color.baseBlue, + interactiveDisabled: tokens.color.baseGray30, + interactiveOnDisabled: tokens.color.baseGray50, + borderNeutralSoft: tokens.color.baseGray15, + borderNeutralMild: tokens.color.baseGray30, + borderNeutralStrong: tokens.color.baseGray50, + borderNeutralSeparator: tokens.color.baseGray15, + borderNeutralDisabled: tokens.color.baseGray30, + borderBrandSoft: tokens.color.baseCerulean, + borderBrandMild: tokens.color.baseBlue, + borderBrandStrong: tokens.color.baseBlueStrong, + iconPrimary: tokens.color.baseGray100, + iconSecondary: tokens.color.baseGray80, + iconInversePrimary: tokens.color.baseGray0, + iconInverseSecondary: tokens.color.baseGray10, + iconBrandDefault: tokens.color.baseBlue, + iconBrandHover: tokens.color.baseBlueStrong, + iconBrandPressed: tokens.color.baseBlue, + iconBrandSoftDefault: tokens.color.baseGray10, + iconBrandSoftHover: tokens.color.baseGray15, + iconBrandSoftPressed: tokens.color.baseGray30, + iconDisabled: tokens.color.baseGray50, +}; + const sizes = config.fonts.body.size; const body = createFont({ family: "SplineSans-Regular", @@ -259,6 +323,7 @@ const tamagui = createTamagui({ themes: { light: { cardBackground: tokens.color.cardBackground, + cardPreviewBackground: tokens.color.cardPreviewBackground, cardDebitBackground: tokens.color.debitLight1, cardDebitInteractive: tokens.color.debitLight9, cardDebitText: tokens.color.debitLight12, @@ -274,6 +339,7 @@ const tamagui = createTamagui({ backgroundBrand: tokens.color.primaryLight9, backgroundBrandSoft: tokens.color.primaryLight2, backgroundBrandMild: tokens.color.primaryLight3, + backgroundBanner: tokens.color.grayscaleLight12, uiNeutralPrimary: tokens.color.grayscaleLight12, uiNeutralSecondary: tokens.color.grayscaleLight11, uiNeutralTertiary: tokens.color.grayscaleLight3, @@ -401,9 +467,11 @@ const tamagui = createTamagui({ borderColorFocus: "transparent", borderColorPress: "transparent", outlineColor: "", + ...(isBase ? baseLight : {}), }, dark: { cardBackground: tokens.color.cardBackground, + cardPreviewBackground: tokens.color.cardPreviewBackground, cardDebitBackground: tokens.color.debitDark1, cardDebitInteractive: tokens.color.debitDark9, cardDebitText: tokens.color.debitLight12, @@ -419,6 +487,7 @@ const tamagui = createTamagui({ backgroundBrand: tokens.color.primaryDark9, backgroundBrandSoft: tokens.color.primaryDark2, backgroundBrandMild: tokens.color.primaryDark3, + backgroundBanner: tokens.color.grayscaleLight12, uiNeutralPrimary: tokens.color.grayscaleDark12, uiNeutralSecondary: tokens.color.grayscaleDark11, uiNeutralTertiary: tokens.color.grayscaleDark3, From cb4c24ffbe9846898727c5f57ec13e27e5e37b31 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Wed, 1 Jul 2026 12:27:40 -0300 Subject: [PATCH 25/28] =?UTF-8?q?=F0=9F=8D=B1=20app:=20add=20visa=20card?= =?UTF-8?q?=20variant=20for=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/images/visa-base.webp | Bin 0 -> 10492 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/assets/images/visa-base.webp diff --git a/src/assets/images/visa-base.webp b/src/assets/images/visa-base.webp new file mode 100644 index 0000000000000000000000000000000000000000..5bc344302ea82f8a1ec5354296cbfe2dc8e41301 GIT binary patch literal 10492 zcmZv8V{jf&*XXMn8KK7#uHJ1J?u!v9=5?dFIRRML(g zZZh<&AfEaq;t8917)n=!w(JXrZ`iEul4W+rA(DKBLe3p5A$y!y!!<1;<2Q7sfgd1t znP{Z_@0cZr71!20^VepF6`Q{SYWSwhqZ-W)ixrP9CchV{b%>k1seYaf@dWw2RE! z)5%LzPe=L6X_QB^!&aNye@`5iL+jMffm|M%3qwf;a{4%8;Q4`V#RP1jO|tS5xMDF!18)qFAS)qTMZBMc#6xa zG-~@_pz{9fPGTJmGH8JX=^p2KvKvYWmDJ3r}COWj#p%r$h#s;=Z26QA$H zLpkp6>ky6&FRIt((38MGp8ton7%_nG6vCI-yNyg1Jvr6d$xgRfp|ijCpKgZ^&^~u# zYU_8hoQ_C{Z#~hED@j^@SAH-5rJe=xzr^jKDz%BE1718ArffS*OazYOQi}%XI*80m zbs6lH$tL<58+UA7Wy($&0{ySTr}(+f*F>JTEkI zFTKxRC97kCa2l)1z^jfrJE+SC*`h;hFnl`ynKsFS6jgS#BR35bnWFAjVL{eu;sV}-8|^hQ6^NBYsf9Tfa; zzYmiCoA<7d%FDmE{3wFsY!eqL1>(0Pm_>d)vjK$52_Wm23lkX?;)AyfL&ALNb^_l7?mOvNMRElV=Z^!_~Ux@C-w61M-AI6%cnT zP_krPM7UDy1lw?x0}(Wn0uEX7d%x>CWz9j(n2fKz(rsKvk=`&_gVjq1T*NAC#m32V zQBg%?V&4rB81Pq`XXg7_OdYPMJJZ(CH0VjF*co8&QZS2ckD(z~SR$gD4JvqH+g3jC zvyJO#3MO-}RK7CuT|KG-MB0>WSrG_-w$!L!MI>_9!ht11kKJ?stK_zGK8vE`rYnN3gflSFsD07 zWQbJ!u><`l&&VnD@^xM-V1XAAm>&Oq9OSGJCVntsruT5)Btun+%>mVLgZLYYNy6Fn zmbsn}C(Vn^*uMfq};C0;)d~=Xd#XhFKLvb~;F*q|E}ALumO=D5m>hz48Arao(xl z_9>WS>zy{$LU8Qx+xPq0?dUYUs>y?b9;twU_YgWsK{&S25%HDa$S1ossK>Omk|oIm z+I$%F;`sU@%C13+KAHFA$ShK)rt7<6iBWMX=q3`}<8Rz68G{`f+o4rLcj#ekAQSSxQ6Aoa)`!8e19AZlk z#FiJE#|a^ftaHDKWrKwXeyIiiPQal*q5R~H0&PauH21BPM$kUr>mpSBevJ;onCA$% zZ(63feZlTTPzho^5 zUbxhPh>u-k(4;fj1Gf0W70r7Lxj=Z>R|B&}h+PuE2?V?h5OX2%NI|Z* z<4Kw;xB>g22%&LUqo|-aed@c#uYWNaRV=vHA4kcvwP-VF$ zHu`ocvNZkxr8;tbtk9wiz*0P0TQdFx`>catS0D5$AaWk5diJ|K?9xndgHDX1D%2Fh z9#mc0{nz=9-d&0#%9K@WQo1&31Z3S3@?Yx7IS(PNxaJ)}9KVNodk{Y@{4~+R3anZG zWgylON2!Pi1^PtiKzibRt7E;mq3+hOjarL){MT=vQvYTi0X${ex7`E`6d>y%_h*I5 zh2ZGQGV`@9{*#o6l=|og{%hq*d5;Lxg9B4SfIjG1#X*0wwrkjzC};(TgWbJpaujnY`as^^!;#5>qzxKZ$W)B^#Q>&c$av|%Bi zAWO~d?X2=L`9Vo2%RU4`w1xQn>LQ4Ixw`w)Tko9mDL1Ql_G>TMjP?xmU%^d$yP=ES zL)C|xUi4i6(hH&ofAEm9x;l`H+`}T*mtt2PNOXB`id{M>Es5a1jYT(;dQ@5|RA{Qc zlFXUD+h*wz2Cigj+8eWWqJ8zmcQtgIR>t@PJ|FucIy#{}z}uWD`kr-_IuUE93`lam zdqf2LgX55Q3;cxrEfF)c_jk$t)9~;le{LbAYZ7yS7|x6w495Rk+e$?z5b&?1#Hs58 zvZ(`qfonqY08GVe8tE{BjSl2r1BDI&9ScCQZa#jzHcYEnJ;mLu$HRu2w|aYI3a70e zD9f{vE^ddsKo-Po_t%_WitM*NXuEtdUmvKhL_FjT*e9Shcx_^y`urkUa;*ZSx$3;fKf9vdkc+EXh2jAsD^2x z9VuaYc0#`P`JJNhu%mYlzCr4MH7$4_2s6`i8PkeJT}XX>kvo` z1@)I=u*{^OVmj>3EczU~9yF`!S~c0X%^wJ&s>S;$^eVKm+&y8DZE8kQu;avyyn}}B z-=h>VE+Kws3eT6_q;+M>_hO553XfS0Atj_Oo{j5X@$;C6voo3meY<<&Qt5Oi-lfwO zHS4mXA=bjd+d*;pc@u&qAl-lm!cD05TBjh%`sG3~mq1=l9C#nev1;X-IZPL}Zz-p#S&tbrHx35&$5l*)TP0s0?+gBI_~ z=j5CxN4C$1Dma+@W|9Hl>0)7D>w9aSGBu?4AIr%)%qMcOpC3AYdin9*u`rz zOM#uDlefts>UD!E3Yku@PWPpz+=U!QX=t1|_D`Z}4f@lu21t+zS-*I4cC)CSYa-1< zHEV^`TMQcQ6s(NH{wkkmH8YzS`VlxgpYsZRj2oNUVK|OL%-S9uEN?a^+WBFJ|T`Fxyv}3DCIvTYu>~2+_Vn4Tv(Vv2&<0`K5alO7miC z=Ye#XK=z788B{_q9eMq8BpVAB5RQj_jrU?&DFeh(*-z!#TO5B2iG(T|24F#{M}obX z7TCh%ad7`o7eigs~($$6@AOTu);8QW{K13 zp@85}CiV(=qCeWf_%Hq9GW^V%XPnC-@t>iW3X3rVjG=;nqagw4w@Bxis85QQIwU!acl4}O@2mUy-Ew6kB7~i$y{NRbq%D;4HWKz4W z79E>(zT*eXbOsw?dl!q#lk)f*E6Gw=zQJCY2Xc&C2Z2K7(z8YBTV`QGw5N(W-aEbq zjHjqL{Mh4YwDXLj(0Lx$vY(|~ic`#zJY0V~ZTvk~=pn8n93kltNQ=(o`UDPC`e@9I zUIa3?I=WIX7gn?qIoUWQT^Q|FQmiC4V&^wj^?GC!J~_WYea&|BLXvCYdAWA_$Y)k! zwg1`DP8FpGanup=KuH!c3*nYfBqtPdldW;OHQKf_Uo`XYr>$mtB=h3|!h_^lDT(Zg zAj-28QUA!udkilxA$q$v)e_OV^^RStz2s$lA=y)lCuZ4*h^R?W)5C$#}vjkF3JD zGTSmfvdLAwL_^dRpqm%Q^}Dbht#o>DKTO1eX0S8o2;wi(J~VlO=~B=i$Rni3>#^m= zfmMiK-qgFdGkknlZ3rq)TtJ*8&L;#VR{ni@P=Z79P34&G=@jE>yCq?=@}!MF;O+Sh z`}IF8CzNL;zg5wsr~VQ7qI>C6@qG`yzH0FiGRs9MvCKVLvn(3Jbx}swW?>_8UkSUV z=yx%f$PIo?F?UsvMXF>)u-6P+2DV77r{?ag<08j>9%$$^h!;r{W|-e`^P()t1gfK0 zlx{!@KwI?&c=?ZLOZAjnj7K5lnm(sMuyS!?y4Om2SZ(@nsg0vy_wTGB5~T-529Z60 zki3#WW)WTCm;W%6Mz_di$P&fyiVw#CgQ=({AB*&@r27T5K?OVI0sIH;yuyH$28F$`K!|qGP%^>yr7LCWZ zha8j$5(xh-{-=lX%CWP&12~4fkcj1Ko&2q#iWyrYctjtg z0RO0(^S)JQYD}Qv1HoJYwrBSI`wAepqxHMImlgw$8P+v?qb*OfT(*Iy;Dcc?OAEjn zB9$<|Kk&Xi&w%PTU@4dbn)7f8|JAR58tRjnC(|+Unbm8)k@{8zqdv9mX7?KBaNYk^)b=1rh2@KfUR>vPlVE` zFcRV7*bs5=Z;p;YJ&BzGMOb%fbuT}d zSQh3$%i3dmIONKu#h=+?wS;$^G`$8_WgcxE3+dKrPxI=87sZxD%Lx4pejaLsEtPJ; zD*e9c>Xv+vyh=VvLIx3bR%#+d7~oWSYzEG(P*5FTc#s8VL;Gaz5K8_%x^M1|2$<2a0X`)b>Xc$ zQzzZ=Dz>gNIC1Ulj^6uu)avia;SKTSx-ZQLr!nJBrGvL}xuPN664S4~5l?WIAm&Zq zR9|X=7k}N5rDll%UweC$-o}mw+X-VWCDvWRrA>c^(zy?oHB-;5SU!vKJ+e4JfWK*1 zpJ%~gK&DtN(3wSQ!Uk?om%g^JmsHaypoqex9vrgFGyj@l9Gvo8b`ogI!_QO48m~pd zJ&AA`sUn8J@}L;-+wu2F9XKdop4I!rAIJfz=A!rF_|ovIH0y&D@s#p4&^4yDn6#`A zukeI$h8wI@eAxVQ{AyOCD#oROa7K`xYMAcOlSAEG$RZI9^!Vs0o@7oZud96;_g7-? zpHD&NvWBx@r4_|pPT(KiNI_)@p@J79f5z+Q$ZMn**NX0EL!Eiw>-f?QyqIBCpR5(A z`N~vcu9bS~1uuMCLj%iWm=+A>Adc7EuS^})r6~JY)Le|ABrnt<#!u-SnSx~C3SJd# zMe$0ArMrg454k9dzJtkF^D4(IKQ9c``ilG8N9{^?vM%ex@0zZ~Zlw}4H={o69X;dk zFqIm@(HE~8zT0V?e%n_)n5lbUIqCI^W1xgcSg2JKgT+uc8JAEfg7?F%d~o6%GJ(IY zb2|tWGjg`Jz?=My#C91a4~49xsSJJRqUW1?rH-5yA0_NJFG#y|7A-n-3MZx=HhK3m zB_HK<0t>C~6j60B2uG1Zc4oy%bC@%Ho4I9=G^*}_fom~@xI36vbi=+KF}cjk?<(}vP?5<^EHtFdT4Sx}RIpLih2nffD>haC?Km8%Dc#a0W zGgX7M2u7NgW&*q9>@)|Y@_4=T>N{W67K@i7KRWk!7&3!roIy{4unC<=QV`)WDrVvgh z;i!0so^bGlpsaw1q>EwAjG9Td~R*gQk zs)2AFVHMb1%g^Ke6I$8+>_XuWiRkpzVI3>0V#b6+2nzX6oj}7s*kciuGDbn<)W6ya77N=K{KCpo@M@vwi3%S>$nKfmMLjh~%7#{;Ozw6sZ z46E=t8YOu4nP3=&rgP{>EPiCCBsj|6DLAJyowwkmu!*!g@V++M-Jd)y2MPXAOfd-* zmN3Z=N4Wd3D6JZwe0WuR@ve|w9&s@}K(Uz!!{Z=PWHoX%F=NC!!{eosk9K(S(=DlH z6!$0;{Vf?fZ|hV4L63Amgk74c=UZufd2n>F^PSDR^;uJ8N@wb|&uH@sz00TWo7r44 zzEvG9K(>gBl-G#2)}Avzh&0li!4s;h`iQ|bUEsOk5StrL)M$y7o~<$mRFZANE()i7 z7df>CpH;@Qgr%45?Vur35e-CDX&P%vGBgWs3Y6??CthA4*y!8Y|1IZBX%g4<5j)(Bd4)Cg}KSNITXGPtcBS! zG)_y9D@KpkM9s}_^?JHP;}bZi3R94WK$8o-)Kn;rytH`>5eD?$8XGuVN@LICdZG1h zFt+CC`O_IX;-)Qve*cRVlALYnL?`xgt$0v(&MVFyvx{g)cV}{5lHe!7t7h;=g)^Iu z4_N>}*9xPyxU3`jWZ{a(w%u&T1~4Ak_*1jF<%N?BI9vf_S8$_Ld{{eBFLT4gBqcR& zHr&*0SRl{OZpo})+{Z^Nf_Y;qUdD&7ko#Je25xdxw33)nM`**zK3o0;l<+BU$PfV*vE zeLfJ7)6G{e{%E}5n_S4}C^TOL$KRf$%v2d=P<=kt1&&yN=qu~+#}%Vv(3w?gYoq_9XUfdJQC%6RlR%uoW9-3m11pa} zy{%9dZ>4A6M~M7d{raBg@q1aX7|n0*N@nFxyai%Mf3 zO@G@umtv3QI3Msog?1fd3zgk>>o8|s;(Q0kU}CO)#-3#$)Ji1>(jOt(c=~clU z*n-J+@_Z2P_T3?=*TisewmVn>_9Jdpp3Gln<$GD7z8d4HDJG1{%OS@=exp@uy6)B> zh3MFeL~YKY=sdDyP7vkbvB;HpD4Yud9hv`GNBd|gB+?cD47of6LTce^44YYt*c~w@ zsH~edvzHpdFV?j;;a*ix*}_#sG9YT8Rxain%rG~Bgp+jGKb@YGw|Sdta?+-j-J_pK6!(tX4tX1Z9wGixIOb$I~q*MNru zUXg1Lyd@-aN3BC*GbVp_L~(jtSMjQz_o~oyLQ=#Rfbf2 zbmRQQ?IF?RG|UNT#z$;rh2{6)W%Hu&%_YkiL5{9aiHs=QJG8ld>fPq007df5W|-^t z&buztcp>&tks41*fYJB%cP5Hi3<`3+?S&XcFVA|?lml4R*;%q#{QImVE7%^-i(u(F z`V(D^m2d*3V_g!-eE@Dm&=X^;ruItVG|&dYx&gnI zr}!y?XOv3hP@{~511N)?MefV_s(6!ym-6B{hEz72XwM##q?}La*+Crf@XE&>kzg~y zQ)xl9qu$CRprw$d-?9kf+`wj?y+f5NsKWX3nvt|@Z|vssy|zNTx{L-7aCp3Vg-R{N z7c#+%uOr`_=dbyb&%<=3dC7>rzHk9RvzruKzT{AFZV|XOmbE_1{gzDNfE3#XZ5ROq zq2vqNQMsM+KB+EaSDs#NfT&HI6|kn zCMP?z+?CUmWlLM;9#o~=)46vPTYR8M|+F`fYW)4@Q6B3X+|T@tQ5 zXz&;_BN-i(c-2s@^ycET0q7T=-6z1PPWRs|!t#d{KPf&i zsXz;lm1Kwc;igC(vSZ0-ls&>n(+y>h5`*GV6bThal=A(Avmx#qeQh zs(RLk)nitXqWb5H6`YT6HXRGBaFOue;LNMPANwMX4emul^}hvPq8bq-8zhsClMAYU ze#<);?b7A74KLl*4npuM&4^BR`%d<|CBG$&G$5XANcO!@6FO@YQXk;xzkx?p7vB`^ zwGOacv0{5h!ss8+P#5_brp|TPoRvL6jZ&V|G|-m_x$7fMhMqI|%yDp+U-TUk4LD6) zc=o3!!Or`tfTraNEGVc?s}iEw_`$F=PaXOTy$?e84E3$d%z8dzd3kGO6^1_IWlky* z#*wm7YRe!9Gk}bH&s@uB`mX3xIB1W5*`0(*kQ9nWP-EP>$6BmACB(fI=JH!%CyAsu zniMXS@n|ADp8S>q{q5Ue>oE5del#YH6W9GWPNR~pFbblXz?fD6Wa!+SNdnb#nf6SHK$8(WwnNS7WV#Nzp1sG=kKB^mf|u7Dzk zg@;YsK?>$t?2hD!cGgWvX&EXA9_U`mE%3S%T_RL86Ni3mBTavuM7_pk1Xa*Ua%tlz zt#J3j?Lj3c;zVO<5+VQETMK!?9Mr)lI6=QIPYK(AB)VHjSsTW;)igIYuh*%S2Q+yV z!)11k(Dxtbq+sUq8l(}Fp#6FRr#Y(fT(>dsBKe5RGKW{pNCNCV!j2Yc1)kA8|9FIf zIgYAaUM`c2L5X-<6US`HTD-s5^wp<_9n^s-apEN(zl;?}8spBge6i>&FbycX7&#n( zd6e?Bbyq9)YAs(z8|{5SG~&&H*%n{%Zb+~f(WJ?1-PU?;rNwEs7%^yh&~+TkcsduE zE+>$d95s}ok_1S*E>tb(duC>aK3SH3Vkv%);@#1un{P#xXetZhXakLP*YmW`LfLL;#z}c7sdB$*AG-`e0debk9*a(UAyaU)d>P|E1gsZBNn*4^Z0j}D0*=yI%2Cw_UBGla%L>DW%u!J9d;Islk36jC z;;4iW<}qi%g*Ra!YY{?JMcski2uQ%{k5R~LikI}OafMt@4bL@Kq7(_a-{Mgh+b1p? z_B_Rx{_P!HF~edn#5m}6hB;BmOt$vZ@uts0c(Rb5z)z*69O)B6P+Xj* z1PGdOo}jnIXSj%|Mpn)J5{=>?oCi!->Nxn;WO89o6&bu**?{69Ka^_zSdc7gLZsah zA3UmEe2_zk8wj(;@%=8sp!yuwq1|tufFQ^MVug<6pEVot# z2K+M{3#qg?W(}De8X~MFbHwNTQOS-;-lH2*C936cYms8)-o(s)=>+%`NFzUt^tiwy zEf&>Nc1$t(M3p41dN(ObeAE^yneEc(-G@ah&n*Vz^Uq{RzwmtvS{$F@mRSc52s_d? z0O&H$P<<`9S2v$5Y!T^Ai&hHc>ICWq?#q%Hm%}h*ovV&1?;sOy#W+fB-h*wiT2FVz zd;hAyC&6750IObh2<|9SkwE_CcTsa<1(BQ+7B9*3Aww;s9HhcAkmh5Qq*(sO_{ubA z4sY)?U9>$$m#}Wz@hJQ(>1VvDq*-52to`c5zGjFeV?GQyVXKw^28=56gh;*1dlU?` z8*G}c8OOPhX5EhE_t7LtH7C{>!uK74Zw%T`CXulNcppkE2;nke!oRsxueBi%l?V>u zHVXo|n>={I1w46hoaeu%IG3Is3$@ZwyU+86%GhV2%qH8d!cIMsF6Prw!w&VQ9&XT8 zn|MLQEPdAR_bT8>ROBgKYMa|Jg+K7E**TC33{k8 Date: Wed, 1 Jul 2026 12:28:10 -0300 Subject: [PATCH 26/28] =?UTF-8?q?=F0=9F=92=84=20app:=20theme=20visa=20bene?= =?UTF-8?q?fit=20card=20for=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/benefits/BenefitsSection.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/benefits/BenefitsSection.tsx b/src/components/benefits/BenefitsSection.tsx index 29969d8920..9fccbabb81 100644 --- a/src/components/benefits/BenefitsSection.tsx +++ b/src/components/benefits/BenefitsSection.tsx @@ -9,7 +9,9 @@ import { LinearGradient } from "expo-linear-gradient"; import { useTheme, View } from "tamagui"; -import { isBase } from "@exactly/common/generated/chain"; +import { useQuery } from "@tanstack/react-query"; + +import { BASE_PRODUCT_ID } from "@exactly/common/panda"; import BenefitCard from "./BenefitCard"; import BenefitSheet from "./BenefitSheet"; @@ -19,11 +21,14 @@ import ExaLogo from "../../assets/images/exa-logo.svg"; import exaPromo from "../../assets/images/exa-promo.svg"; import PaxLogo from "../../assets/images/pax.svg"; import PaxImage from "../../assets/images/pax.webp"; +import VisaBaseImage from "../../assets/images/visa-base.webp"; import VisaLogo from "../../assets/images/visa.svg"; import VisaImage from "../../assets/images/visa.webp"; import { isPromoActive } from "../../utils/promo"; import ThemedSvg from "../shared/ThemedSvg"; +import type { CardDetails } from "../../utils/server"; + function ExaBackground() { return ( @@ -97,7 +102,15 @@ const BENEFITS = [ "Learn more about all Visa Signature benefits.", ], logo: VisaLogo, - Background: () => , + Background: () => ( + ({ queryKey: ["card", "details"] }).data?.productId === BASE_PRODUCT_ID + ? VisaBaseImage + : VisaImage + } + /> + ), linkText: "Learn more", buttonText: "Go to Visa", url: "https://help.exactly.app/{language}/articles/11172343-visa-signature-benefits-with-your-exa-card", From 96bea770834db6fc978bf6c0c994fb7e282ac9ae Mon Sep 17 00:00:00 2001 From: franm Date: Wed, 1 Jul 2026 19:19:16 -0300 Subject: [PATCH 27/28] =?UTF-8?q?=E2=9E=95=20app:=20install=20expo-file-sy?= =?UTF-8?q?stem=20expo-sharing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 ++ pnpm-lock.yaml | 93 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 2375e4b193..b755f1786d 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "expo-camera": "~55.0.9", "expo-clipboard": "~55.0.8", "expo-constants": "~55.0.9", + "expo-file-system": "~55.0.23", "expo-font": "~55.0.4", "expo-haptics": "~55.0.9", "expo-image": "~55.0.5", @@ -83,6 +84,7 @@ "expo-local-authentication": "~55.0.9", "expo-localization": "~55.0.9", "expo-router": "~55.0.5", + "expo-sharing": "~55.0.21", "expo-status-bar": "~55.0.4", "expo-system-ui": "~55.0.9", "expo-updates": "~55.0.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed06467030..29b8942432 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: expo-constants: specifier: ~55.0.9 version: 55.0.9(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3) + expo-file-system: + specifier: ~55.0.23 + version: 55.0.24(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10)) expo-font: specifier: ~55.0.4 version: 55.0.4(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0) @@ -297,6 +300,9 @@ importers: expo-router: specifier: ~55.0.5 version: 55.0.8(e648b7c2d7f97def12d5a5dbfb5d4641) + expo-sharing: + specifier: ~55.0.21 + version: 55.0.22(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0) expo-status-bar: specifier: ~55.0.4 version: 55.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0) @@ -1115,6 +1121,9 @@ packages: resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@babel/code-frame@7.10.4': + resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1218,6 +1227,10 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} + '@babel/highlight@7.25.9': + resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} @@ -3120,12 +3133,18 @@ packages: '@expo/code-signing-certificates@0.0.6': resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + '@expo/config-plugins@55.0.11': + resolution: {integrity: sha512-85ZSmIK8rMfvYbG/2IHtwnykVdb6LI0ZavduM3ZwUMThyyVegYjVsO5Zek2ec8xzPhCmYX0ar5onnFEcwUDUlw==} + '@expo/config-plugins@55.0.7': resolution: {integrity: sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==} '@expo/config-types@55.0.5': resolution: {integrity: sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==} + '@expo/config-types@55.0.6': + resolution: {integrity: sha512-S+GJKYoIjnWlert/9vXuTohaTsMbyOLSVxdIgPgoq3P4N1p4CWrfyZLnz6qRug8wYSO5fcYcS9mFleyEP8wRLg==} + '@expo/config@55.0.11': resolution: {integrity: sha512-14AkSmR1gOIUhCsPJ0cAo5ZduMNsPQsmFV9jBNZn1xC5Zb3D8x5eqvUie5QzWaUwdcyrq79uYJ2bTCiC6+nD0Q==} @@ -3164,6 +3183,9 @@ packages: '@expo/json-file@10.0.12': resolution: {integrity: sha512-inbDycp1rMAelAofg7h/mMzIe+Owx6F7pur3XdQ3EPTy00tme+4P6FWgHKUcjN8dBSrnbRNpSyh5/shzHyVCyQ==} + '@expo/json-file@10.0.16': + resolution: {integrity: sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==} + '@expo/local-build-cache-provider@55.0.7': resolution: {integrity: sha512-Qg9uNZn1buv4zJUA4ZQaz+ZnKDCipRgjoEg2Gcp8Qfy+2Gq5yZKX4YN1TThCJ01LJk/pvJsCRxXlXZSwdZppgg==} @@ -3206,6 +3228,9 @@ packages: '@expo/plist@0.5.2': resolution: {integrity: sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==} + '@expo/plist@0.5.4': + resolution: {integrity: sha512-Jqppj0FULNq6Zp5JtQrFICl8TtpMjwwUbxEcEC2T3z7m+TOrTQEHZXz3D3Ay7vhbmvD+VMgfWJ4ARclJXeN8Eg==} + '@expo/prebuild-config@55.0.11': resolution: {integrity: sha512-PqjbTTHXS0dnZMH4X5/0rnLxKfQqyN1s/5lmxITn+U6WDUNibatUepfjwV+5C2jU4hv5z2haqX6e9hQ0zUtDMA==} peerDependencies: @@ -8827,8 +8852,8 @@ packages: expo-eas-client@55.0.3: resolution: {integrity: sha512-KkkjjPc4VKpLVEbOMAvWp87m1YiFEgM6fDNNk5LRynfJ4V8a287P5jlnZuRvHCFWWczTczXT9iS39r8G4VZGDQ==} - expo-file-system@55.0.12: - resolution: {integrity: sha512-MFN/3L3gm174nxP2HqKQsSsPbjAj92wuidKFGSbl3Lt6oJTS09EbTwszX5BhYeeVSprcsw8pnlxYSmhkSqGEFw==} + expo-file-system@55.0.24: + resolution: {integrity: sha512-7/HJdvaaf3kP5T3atd+6Q0/QexrGio4Fs2GVtp7G8d/xQCSu01yeGReu7tGQo9VAM67Snq+ujGlL8q2wbMXLkQ==} peerDependencies: expo: '*' react-native: '*' @@ -8955,6 +8980,13 @@ packages: resolution: {integrity: sha512-xI72FTm469FfuuBL2R5aNtthgH+GR7ygOpsx/KcPS0K8AZaZd7VjtEExbzn9/qyyYkWW3T+3dAmCDKOMX8gdmQ==} engines: {node: '>=20.16.0'} + expo-sharing@55.0.22: + resolution: {integrity: sha512-52s4FfjNMLJfKl4wxakRjbrfgya3w7XZ15tCc/7IVH2HX/q467mePBS1VqYWbuuKDjvDAL02Aj2LcFaUNwkoWQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-status-bar@55.0.4: resolution: {integrity: sha512-BPDjUXKqv1F9j2YNGLRZfkBEZXIEEpqj+t81y4c+4fdSN3Pos7goIHXgcl2ozbKQLgKRZQyNZQtbUgh5UjHYUQ==} peerDependencies: @@ -14242,6 +14274,10 @@ snapshots: is-docker: 4.0.0 package-manager-detector: 1.6.0 + '@babel/code-frame@7.10.4': + dependencies: + '@babel/highlight': 7.25.9 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -14396,6 +14432,13 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 + '@babel/highlight@7.25.9': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 @@ -16534,6 +16577,24 @@ snapshots: dependencies: node-forge: 1.4.0 + '@expo/config-plugins@55.0.11': + dependencies: + '@expo/config-types': 55.0.6 + '@expo/json-file': 10.0.16 + '@expo/plist': 0.5.4 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3(supports-color@7.2.0) + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.8.5 + slugify: 1.6.8 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + '@expo/config-plugins@55.0.7': dependencies: '@expo/config-types': 55.0.5 @@ -16554,6 +16615,8 @@ snapshots: '@expo/config-types@55.0.5': {} + '@expo/config-types@55.0.6': {} + '@expo/config@55.0.11(typescript@5.9.3)': dependencies: '@expo/config-plugins': 55.0.7 @@ -16630,6 +16693,11 @@ snapshots: '@babel/code-frame': 7.29.0 json5: 2.2.3 + '@expo/json-file@10.0.16': + dependencies: + '@babel/code-frame': 7.10.4 + json5: 2.2.3 + '@expo/local-build-cache-provider@55.0.7(typescript@5.9.3)': dependencies: '@expo/config': 55.0.11(typescript@5.9.3) @@ -16729,6 +16797,12 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + '@expo/plist@0.5.4': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + '@expo/prebuild-config@55.0.11(expo@55.0.9)(typescript@5.9.3)': dependencies: '@expo/config': 55.0.11(typescript@5.9.3) @@ -23963,7 +24037,7 @@ snapshots: expo-eas-client@55.0.3: {} - expo-file-system@55.0.12(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10)): + expo-file-system@55.0.24(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10)): dependencies: expo: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(bufferutil@4.1.0)(expo-router@55.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10) react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10) @@ -24109,6 +24183,17 @@ snapshots: expo-server@55.0.6: {} + expo-sharing@55.0.22(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0): + dependencies: + '@expo/config-plugins': 55.0.11 + '@expo/config-types': 55.0.6 + '@expo/plist': 0.5.4 + expo: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(bufferutil@4.1.0)(expo-router@55.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + react: 19.2.0 + react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - supports-color + expo-status-bar@55.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0): dependencies: react: 19.2.0 @@ -24186,7 +24271,7 @@ snapshots: babel-preset-expo: 55.0.13(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.9)(react-refresh@0.14.2) expo-asset: 55.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3) expo-constants: 55.0.9(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3) - expo-file-system: 55.0.12(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10)) + expo-file-system: 55.0.24(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10)) expo-font: 55.0.4(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0) expo-keep-awake: 55.0.4(expo@55.0.9)(react@19.2.0) expo-modules-autolinking: 55.0.12(typescript@5.9.3) From 6160a7e9bf3fdfa2e96e33a7c5c922f6d1dab07b Mon Sep 17 00:00:00 2001 From: franm Date: Tue, 28 Jul 2026 19:17:37 -0300 Subject: [PATCH 28/28] =?UTF-8?q?=E2=9C=A8=20app:=20add=20card=20statement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/curvy-jobs-sort.md | 5 + app.config.ts | 1 + src/app/(main)/payment-history/_layout.tsx | 10 + src/app/(main)/payment-history/index.tsx | 1 + src/app/(main)/statement/_layout.tsx | 10 + src/app/(main)/statement/index.tsx | 1 + src/components/pay/Breakdown.tsx | 131 ++++++++ src/components/pay/History.tsx | 84 ++++++ src/components/pay/HistorySheet.tsx | 105 +++++++ src/components/pay/Pay.tsx | 30 +- src/components/pay/PaymentHistory.tsx | 54 ++++ src/components/pay/PaymentRow.tsx | 39 +++ src/components/pay/PaymentSheet.tsx | 62 ++-- src/components/pay/Repay.tsx | 2 + src/components/pay/StatementActions.tsx | 94 ++++++ src/components/shared/ModalSheet.tsx | 4 +- src/components/statement/Statement.tsx | 335 +++++++++++++++++++++ src/i18n/es.json | 24 +- src/i18n/pt.json | 24 +- src/utils/server.ts | 12 + src/utils/statement.ts | 108 +++++++ src/utils/useStatement.ts | 10 + src/utils/useStatements.ts | 33 ++ 23 files changed, 1130 insertions(+), 49 deletions(-) create mode 100644 .changeset/curvy-jobs-sort.md create mode 100644 src/app/(main)/payment-history/_layout.tsx create mode 100644 src/app/(main)/payment-history/index.tsx create mode 100644 src/app/(main)/statement/_layout.tsx create mode 100644 src/app/(main)/statement/index.tsx create mode 100644 src/components/pay/Breakdown.tsx create mode 100644 src/components/pay/History.tsx create mode 100644 src/components/pay/HistorySheet.tsx create mode 100644 src/components/pay/PaymentHistory.tsx create mode 100644 src/components/pay/PaymentRow.tsx create mode 100644 src/components/pay/StatementActions.tsx create mode 100644 src/components/statement/Statement.tsx create mode 100644 src/utils/statement.ts create mode 100644 src/utils/useStatement.ts create mode 100644 src/utils/useStatements.ts diff --git a/.changeset/curvy-jobs-sort.md b/.changeset/curvy-jobs-sort.md new file mode 100644 index 0000000000..fd4797bb23 --- /dev/null +++ b/.changeset/curvy-jobs-sort.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add card statement diff --git a/app.config.ts b/app.config.ts index 1dbe60c115..d1a94e4300 100644 --- a/app.config.ts +++ b/app.config.ts @@ -83,6 +83,7 @@ export default { { supportedLocales: ["en", "es", "es-AR", "es-CR", "es-GT", "es-HN", "es-NI", "es-PY", "es-SV", "es-UY", "pt"] }, ], "expo-router", + "expo-sharing", [ "@intercom/intercom-react-native", { diff --git a/src/app/(main)/payment-history/_layout.tsx b/src/app/(main)/payment-history/_layout.tsx new file mode 100644 index 0000000000..ba23dfba66 --- /dev/null +++ b/src/app/(main)/payment-history/_layout.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +import { Stack } from "expo-router"; + +import useBackgroundColor from "../../../utils/useBackgroundColor"; + +export default function PaymentHistoryLayout() { + useBackgroundColor(); + return ; +} diff --git a/src/app/(main)/payment-history/index.tsx b/src/app/(main)/payment-history/index.tsx new file mode 100644 index 0000000000..8b8920b000 --- /dev/null +++ b/src/app/(main)/payment-history/index.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/pay/History"; diff --git a/src/app/(main)/statement/_layout.tsx b/src/app/(main)/statement/_layout.tsx new file mode 100644 index 0000000000..9a61950a03 --- /dev/null +++ b/src/app/(main)/statement/_layout.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +import { Stack } from "expo-router"; + +import useBackgroundColor from "../../../utils/useBackgroundColor"; + +export default function StatementLayout() { + useBackgroundColor(); + return ; +} diff --git a/src/app/(main)/statement/index.tsx b/src/app/(main)/statement/index.tsx new file mode 100644 index 0000000000..fe19c69f4d --- /dev/null +++ b/src/app/(main)/statement/index.tsx @@ -0,0 +1 @@ +export { default } from "../../../components/statement/Statement"; diff --git a/src/components/pay/Breakdown.tsx b/src/components/pay/Breakdown.tsx new file mode 100644 index 0000000000..3dc7554883 --- /dev/null +++ b/src/components/pay/Breakdown.tsx @@ -0,0 +1,131 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { Coins, CreditCard, FileText } from "@tamagui/lucide-icons"; +import { Separator, XStack, YStack } from "tamagui"; + +import { MATURITY_INTERVAL } from "@exactly/lib"; + +import reportError from "../../utils/reportError"; +import { group } from "../../utils/statement"; +import useStatement from "../../utils/useStatement"; +import InfoAlert from "../shared/InfoAlert"; +import Skeleton from "../shared/Skeleton"; +import Text from "../shared/Text"; + +export default function Breakdown({ + maturity, + onBack, + onViewStatement, +}: { + maturity: number; + onBack: () => void; + onViewStatement: () => void; +}) { + const { + t, + i18n: { language }, + } = useTranslation(); + const { data, isLoading, isError, isFetching, refetch } = useStatement(maturity); + const { cards, paid, discount, due } = useMemo(() => group(data ?? []), [data]); + + const money = (value: number) => value.toLocaleString(language, { style: "currency", currency: "USD" }); + const day = (value: number) => + new Date(value * 1000).toLocaleDateString(language, { year: "numeric", month: "long", day: "numeric" }); + + return ( + + + + + {t("Summary")} + + + + {t("View statement")} + + + + + + {t("{{start}} to {{end}}", { start: day(maturity - MATURITY_INTERVAL), end: day(maturity) })} + + + + {isLoading ? ( + + + + + + ) : isError && !data ? ( + { + refetch().catch(reportError); + }} + /> + ) : ( + + {cards.map((card) => ( + + + + {t("**** {{lastFour}} installments", { lastFour: card.lastFour })} + + + {money(card.total)} + + + ))} + + + + + + {t("Payments")} + + {discount >= 0.01 && ( + + {t("{{amount}} discount", { amount: money(discount) })} + + )} + + + {money(paid === 0 ? 0 : -paid)} + + + + + + + + {t("Total due")} + + + {day(maturity)} + + + + {money(due)} + + + + )} + + + {t("Back")} + + + ); +} diff --git a/src/components/pay/History.tsx b/src/components/pay/History.tsx new file mode 100644 index 0000000000..760e88dbcc --- /dev/null +++ b/src/components/pay/History.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useRouter } from "expo-router"; + +import { ArrowLeft } from "@tamagui/lucide-icons"; +import { ScrollView, Separator, XStack, YStack } from "tamagui"; + +import HistorySheet from "./HistorySheet"; +import PaymentRow from "./PaymentRow"; +import queryClient from "../../utils/queryClient"; +import useMarkets from "../../utils/useMarkets"; +import useStatements from "../../utils/useStatements"; +import IconButton from "../shared/IconButton"; +import RefreshControl from "../shared/RefreshControl"; +import SafeView from "../shared/SafeView"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function History() { + const { t } = useTranslation(); + const router = useRouter(); + const { timestamp, refetch } = useMarkets(); + const maturities = useStatements(); + const [selected, setSelected] = useState(); + const now = Number(timestamp); + const past = maturities.filter((maturity) => maturity < now); + const refresh = () => + Promise.all([ + refetch(), + queryClient.invalidateQueries({ queryKey: ["activity"], exact: true }), + queryClient.invalidateQueries({ queryKey: ["activity", "statement"] }), + ]); + + return ( + + + { + router.back(); + }} + /> + + } + > + + + {t("Payment history")} + + + {past.length === 0 ? ( + + + {t("No payments yet")} + + + ) : ( + + {past.map((maturity, index) => ( + + {index > 0 && } + + + ))} + + )} + + setSelected(undefined)} /> + + ); +} diff --git a/src/components/pay/HistorySheet.tsx b/src/components/pay/HistorySheet.tsx new file mode 100644 index 0000000000..ccaf425666 --- /dev/null +++ b/src/components/pay/HistorySheet.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useRouter } from "expo-router"; + +import { CircleCheck, Headset } from "@tamagui/lucide-icons"; +import { XStack, YStack } from "tamagui"; + +import Breakdown from "./Breakdown"; +import StatementActions from "./StatementActions"; +import { present } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import useStatement from "../../utils/useStatement"; +import IconButton from "../shared/IconButton"; +import ModalSheet from "../shared/ModalSheet"; +import SafeView from "../shared/SafeView"; +import Text from "../shared/Text"; + +export default function HistorySheet({ maturity, onClose }: { maturity: number | undefined; onClose: () => void }) { + const { + t, + i18n: { language }, + } = useTranslation(); + const router = useRouter(); + const { data } = useStatement(maturity); + const [breakdownOpen, setBreakdownOpen] = useState(false); + + const close = () => { + setBreakdownOpen(false); + onClose(); + }; + + const day = (value: number | string) => + new Date(typeof value === "number" ? value * 1000 : value).toLocaleDateString(language, { + year: "numeric", + month: "long", + day: "numeric", + }); + const time = (value: string) => new Date(value).toLocaleTimeString(language, { hour: "2-digit", minute: "2-digit" }); + const paidAt = data + ?.filter((item) => item.type === "repay") + .map((item) => item.timestamp) + .sort() + .at(-1); + const paid = paidAt && `${day(paidAt)} - ${time(paidAt)}`; + + return ( + + + {maturity !== undefined && breakdownOpen ? ( + { + setBreakdownOpen(false); + }} + onViewStatement={() => { + close(); + router.navigate({ pathname: "/statement", params: { maturity: String(maturity) } }); + }} + /> + ) : ( + + + + + + {maturity !== undefined && t("Due {{date}}", { date: day(maturity) })} + + {paid && ( + + {t("Paid on {{date}}", { date: paid })} + + )} + + { + present().catch(reportError); + }} + /> + + {maturity !== undefined && ( + { + setBreakdownOpen(true); + }} + onClose={close} + /> + )} + + )} + + + ); +} diff --git a/src/components/pay/Pay.tsx b/src/components/pay/Pay.tsx index c5cbfd596e..50605ac852 100644 --- a/src/components/pay/Pay.tsx +++ b/src/components/pay/Pay.tsx @@ -9,7 +9,6 @@ import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceStrict } from "date-fns"; -import { optimismSepolia } from "viem/chains"; import accountInit from "@exactly/common/accountInit"; import chain, { exaPluginAddress, marketUSDCAddress } from "@exactly/common/generated/chain"; @@ -17,13 +16,14 @@ import { useReadUpgradeableModularAccountGetInstalledPlugins } from "@exactly/co import { WAD } from "@exactly/lib"; import Empty from "./Empty"; +import HistorySheet from "./HistorySheet"; import OverduePayments from "./OverduePayments"; +import PaymentHistory from "./PaymentHistory"; import PaymentSheet from "./PaymentSheet"; import RolloverIntroSheet from "./RolloverIntroSheet"; import UpcomingPayments from "./UpcomingPayments"; import { date } from "../../i18n"; import { presentArticle } from "../../utils/intercom"; -import openBrowser from "../../utils/openBrowser"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; @@ -59,16 +59,21 @@ export default function Pay() { query: { refetchOnMount: true, enabled: !!address && !!credential }, }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; - const { account, market: exaUSDC } = useAsset(marketUSDCAddress); + const { market: exaUSDC } = useAsset(marketUSDCAddress); const { markets, timestamp, refetch } = useMarkets({ refetchInterval: 30_000 }); const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] }); const { data: rolloverIntroShown } = useQuery({ queryKey: ["settings", "rollover-intro-shown"] }); const [rolloverIntroMaturity, setRolloverIntroMaturity] = useState(); + const [historyMaturity, setHistoryMaturity] = useState(); const [infoType, setInfoType] = useState<"discount" | "fees" | "total" | null>(null); const scrollRef = useRef(null); const refresh = () => - Promise.all([refetch(), queryClient.invalidateQueries({ queryKey: ["activity"], exact: true })]); + Promise.all([ + refetch(), + queryClient.invalidateQueries({ queryKey: ["activity"], exact: true }), + queryClient.invalidateQueries({ queryKey: ["activity", "statement"] }), + ]); useTabPress("pay-mode", () => { scrollRef.current?.scrollTo({ y: 0, animated: true }); refresh().catch(reportError); @@ -105,10 +110,8 @@ export default function Pay() { }, [allMaturities, exaUSDC]); const viewStatement = useCallback(() => { - openBrowser( - `https://${{ [optimismSepolia.id]: "testnet" }[chain.id] ?? "app"}.exact.ly/dashboard?account=${account}&tab=b`, - ).catch(reportError); - }, [account]); + if (firstMaturity) router.navigate({ pathname: "/statement", params: { maturity: String(firstMaturity[0]) } }); + }, [router, firstMaturity]); const onSelect = useCallback( (maturity: bigint) => { @@ -155,7 +158,6 @@ export default function Pay() { count={allMaturities.length} t={t} onInfoPress={() => setInfoType("total")} - onStatementsPress={viewStatement} /> {firstMaturity && exaUSDC && ( @@ -187,8 +189,10 @@ export default function Pay() { )} + + setHistoryMaturity(undefined)} /> setRolloverIntroMaturity(undefined)} @@ -281,13 +285,11 @@ function TotalOutstandingCard({ amount, count, onInfoPress, - onStatementsPress, t, }: { amount: number; count: number; onInfoPress: () => void; - onStatementsPress: () => void; t: (key: string, options?: Record) => string; }) { return ( @@ -305,12 +307,6 @@ function TotalOutstandingCard({ onPress={onInfoPress} /> - - - {t("Statements")} - - - diff --git a/src/components/pay/PaymentHistory.tsx b/src/components/pay/PaymentHistory.tsx new file mode 100644 index 0000000000..67349a1459 --- /dev/null +++ b/src/components/pay/PaymentHistory.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { useRouter } from "expo-router"; + +import { ChevronRight } from "@tamagui/lucide-icons"; +import { Separator, XStack, YStack } from "tamagui"; + +import PaymentRow from "./PaymentRow"; +import useMarkets from "../../utils/useMarkets"; +import useStatements from "../../utils/useStatements"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function PaymentHistory({ onSelect }: { onSelect: (maturity: number) => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const { timestamp } = useMarkets(); + const maturities = useStatements(); + const now = Number(timestamp); + const past = maturities.filter((maturity) => maturity < now); + if (past.length === 0) return null; + return ( + + + + {t("Payment history")} + + { + router.push("/payment-history"); + }} + > + + + {t("View all")} + + + + + + + {past.slice(0, 4).map((maturity, index) => ( + + {index > 0 && } + + + ))} + + + ); +} diff --git a/src/components/pay/PaymentRow.tsx b/src/components/pay/PaymentRow.tsx new file mode 100644 index 0000000000..493a9b73f4 --- /dev/null +++ b/src/components/pay/PaymentRow.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { selectionAsync } from "expo-haptics"; + +import { MoreHorizontal } from "@tamagui/lucide-icons"; +import { XStack } from "tamagui"; + +import reportError from "../../utils/reportError"; +import Text from "../shared/Text"; + +export default function PaymentRow({ maturity, onSelect }: { maturity: number; onSelect: (maturity: number) => void }) { + const { + i18n: { language }, + } = useTranslation(); + const label = format(maturity, language); + return ( + { + selectionAsync().catch(reportError); + onSelect(maturity); + }} + > + + {label} + + + + ); +} + +function format(maturity: number, language: string) { + return new Date(maturity * 1000).toLocaleDateString(language, { year: "numeric", month: "long", day: "numeric" }); +} diff --git a/src/components/pay/PaymentSheet.tsx b/src/components/pay/PaymentSheet.tsx index afe7ef0a15..e33164c144 100644 --- a/src/components/pay/PaymentSheet.tsx +++ b/src/components/pay/PaymentSheet.tsx @@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { Coins, ExternalLink, FileText, Info, RefreshCw } from "@tamagui/lucide-icons"; +import { Coins, ExternalLink, Info, RefreshCw } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { XStack, YStack, type YStackProps } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceStrict, isAfter } from "date-fns"; import { digits, pipe, safeParse, string } from "valibot"; -import { optimismSepolia } from "viem/chains"; import accountInit from "@exactly/common/accountInit"; import chain, { exaPluginAddress, marketUSDCAddress } from "@exactly/common/generated/chain"; import { useReadUpgradeableModularAccountGetInstalledPlugins } from "@exactly/common/generated/hooks"; import { WAD } from "@exactly/lib"; +import Breakdown from "./Breakdown"; +import StatementActions from "./StatementActions"; import { date } from "../../i18n"; import { presentArticle } from "../../utils/intercom"; -import openBrowser from "../../utils/openBrowser"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import useAsset from "../../utils/useAsset"; @@ -50,6 +50,7 @@ export default function PaymentSheet({ onRolloverIntro }: { onRolloverIntro?: (m const { market: USDCMarket, timestamp } = useAsset(marketUSDCAddress); const [infoOpen, setInfoOpen] = useState(false); const [open, setOpen] = useState(() => !!maturity); + const [breakdownOpen, setBreakdownOpen] = useState(false); const [displayMaturity, setDisplayMaturity] = useState(maturity); const { data: rolloverIntroShown } = useQuery({ queryKey: ["settings", "rollover-intro-shown"] }); const { @@ -95,20 +96,11 @@ export default function PaymentSheet({ onRolloverIntro }: { onRolloverIntro?: (m const close = useCallback(() => { setInfoOpen(false); + setBreakdownOpen(false); setOpen(false); router.setParams({ ...parameters, maturity: undefined }); }, [parameters, router]); - const viewStatement = useCallback(() => { - openBrowser( - `https://${ - { - [optimismSepolia.id]: "testnet", - }[chain.id] ?? "app" - }.exact.ly/dashboard?account=${address}&tab=b`, - ).catch(reportError); - }, [address]); - const navigateToRepay = useCallback(() => { close(); router.navigate({ pathname: "/pay", params: { maturity: displayMaturity } }); @@ -133,21 +125,36 @@ export default function PaymentSheet({ onRolloverIntro }: { onRolloverIntro?: (m const renderContent = () => { if (!displayMaturity || !USDCMarket || !borrow) return ; + if (breakdownOpen) + return ( + + setBreakdownOpen(false)} + onViewStatement={() => { + close(); + router.navigate({ pathname: "/statement", params: { maturity: displayMaturity } }); + }} + /> + + ); return ( setBreakdownOpen(true)} + onClose={close} onInfoPress={() => setInfoOpen(true)} onRepayPress={navigateToRepay} onRolloverPress={navigateToRollover} - onViewStatement={viewStatement} /> ); }; return ( <> - + {renderContent()} {open && borrow && ( @@ -243,10 +250,12 @@ function NotAvailableView({ onClose }: { onClose: () => void }) { function DetailsView({ borrow, language, + maturity, + onBreakdown, + onClose, onInfoPress, onRepayPress, onRolloverPress, - onViewStatement, }: { borrow: { discount: number; @@ -257,10 +266,12 @@ function DetailsView({ previewValue: bigint; }; language: string; + maturity: number; + onBreakdown: () => void; + onClose: () => void; onInfoPress: () => void; onRepayPress: () => void; onRolloverPress: () => void; - onViewStatement: () => void; }) { const { t } = useTranslation(); const { previewValue, positionValue, discount, dueDate, isUpcoming, dueStatus } = borrow; @@ -272,18 +283,10 @@ function DetailsView({ - - - - {dueStatus} - - - - - {t("View statement")} - - - + + + {dueStatus} + {dueDate.toLocaleDateString(language, { year: "numeric", month: "short", day: "numeric" })} @@ -349,6 +352,7 @@ function DetailsView({ + ); diff --git a/src/components/pay/Repay.tsx b/src/components/pay/Repay.tsx index d410edea95..834ff8ded4 100644 --- a/src/components/pay/Repay.tsx +++ b/src/components/pay/Repay.tsx @@ -431,6 +431,7 @@ export default function Repay() { }, onSuccess() { queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); + queryClient.invalidateQueries({ queryKey: ["activity", "statement"] }).catch(reportError); }, onSettled() { setEnableSimulations(true); @@ -502,6 +503,7 @@ export default function Repay() { onSuccess() { queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); queryClient.invalidateQueries({ queryKey: ["lifi", "balances"] }).catch(reportError); + queryClient.invalidateQueries({ queryKey: ["activity", "statement"] }).catch(reportError); }, onSettled() { setEnableSimulations(true); diff --git a/src/components/pay/StatementActions.tsx b/src/components/pay/StatementActions.tsx new file mode 100644 index 0000000000..ca23f5ed87 --- /dev/null +++ b/src/components/pay/StatementActions.tsx @@ -0,0 +1,94 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useRouter } from "expo-router"; + +import { AlignJustify, ChevronRight, Download, FileText } from "@tamagui/lucide-icons"; +import { useToastController } from "@tamagui/toast"; +import { Separator, Spinner, XStack, YStack } from "tamagui"; + +import reportError from "../../utils/reportError"; +import { downloadStatement } from "../../utils/statement"; +import Text from "../shared/Text"; + +export default function StatementActions({ + maturity, + onBreakdown, + onClose, +}: { + maturity: number; + onBreakdown: () => void; + onClose?: () => void; +}) { + const { t } = useTranslation(); + const router = useRouter(); + const toast = useToastController(); + const [downloading, setDownloading] = useState(false); + + function download() { + if (downloading) return; + setDownloading(true); + downloadStatement(maturity, `account-statement-${maturity}.pdf`) + .catch((error: unknown) => { + reportError(error); + toast.show(t("Couldn't download your statement. Please try again."), { + burntOptions: { haptic: "error", preset: "error" }, + }); + }) + .finally(() => { + setDownloading(false); + }); + } + + return ( + + + + { + onClose?.(); + router.navigate({ pathname: "/statement", params: { maturity: String(maturity) } }); + }} + /> + + + + ); +} + +function Action({ + icon: Icon, + label, + loading, + onPress, +}: { + icon: typeof FileText; + label: string; + loading?: boolean; + onPress?: () => void; +}) { + return ( + + + + {label} + + {loading ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/shared/ModalSheet.tsx b/src/components/shared/ModalSheet.tsx index 80eac78257..cc37f7b1a0 100644 --- a/src/components/shared/ModalSheet.tsx +++ b/src/components/shared/ModalSheet.tsx @@ -9,7 +9,9 @@ export default function ModalSheet({ children, heightPercent, disableDrag = true, + animation = "default", }: { + animation?: React.ComponentProps["animation"]; children: React.ReactNode; disableDrag?: boolean; heightPercent?: number; @@ -22,7 +24,7 @@ export default function ModalSheet({ dismissOnSnapToBottom unmountChildrenWhenHidden forceRemoveScrollEnabled={open} - animation="default" + animation={animation} dismissOnOverlayPress onOpenChange={(isOpen: boolean) => { if (!isOpen) onClose(); diff --git a/src/components/statement/Statement.tsx b/src/components/statement/Statement.tsx new file mode 100644 index 0000000000..e8087bfd23 --- /dev/null +++ b/src/components/statement/Statement.tsx @@ -0,0 +1,335 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useLocalSearchParams, useRouter } from "expo-router"; + +import { ArrowLeft, CircleCheck, CircleHelp, Coins, CreditCard, Download, FileText } from "@tamagui/lucide-icons"; +import { useToastController } from "@tamagui/toast"; +import { ScrollView, Separator, Spinner, XStack, YStack } from "tamagui"; + +import { MATURITY_INTERVAL } from "@exactly/lib"; + +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import { downloadStatement, group } from "../../utils/statement"; +import useStatement from "../../utils/useStatement"; +import IconButton from "../shared/IconButton"; +import InfoAlert from "../shared/InfoAlert"; +import SafeView from "../shared/SafeView"; +import Skeleton from "../shared/Skeleton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function Statement() { + const { + t, + i18n: { language }, + } = useTranslation(); + const router = useRouter(); + const toast = useToastController(); + const insets = useSafeAreaInsets(); + const parameter = useLocalSearchParams().maturity; + const raw = Array.isArray(parameter) ? parameter[0] : parameter; + const maturity = raw && /^\d+$/.test(raw) ? Number(raw) : undefined; + const { data, isLoading, isError, isFetching, refetch } = useStatement(maturity); + const [downloading, setDownloading] = useState(false); + + const money = (value: number) => value.toLocaleString(language, { style: "currency", currency: "USD" }); + const percent = (value: number) => + value.toLocaleString(language, { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const day = (value: number | string) => + new Date(typeof value === "number" ? value * 1000 : value).toLocaleDateString(language, { + year: "numeric", + month: "long", + day: "numeric", + }); + + const period = + maturity === undefined + ? undefined + : t("{{start}} to {{end}}", { start: day(maturity - MATURITY_INTERVAL), end: day(maturity) }); + + const { cards, payments, paid, due } = useMemo(() => group(data ?? []), [data]); + const empty = cards.length === 0 && payments.length === 0; + const ready = !isLoading && !empty; + const lastCard = cards.length - 1; + + function download() { + if (maturity === undefined || downloading) return; + setDownloading(true); + downloadStatement(maturity, `account-statement-${maturity}.pdf`) + .catch((error: unknown) => { + reportError(error); + toast.show(t("Couldn't download your statement. Please try again."), { + burntOptions: { haptic: "error", preset: "error" }, + }); + }) + .finally(() => { + setDownloading(false); + }); + } + + return ( + + + { + router.back(); + }} + /> + { + presentArticle("10245778").catch(reportError); + }} + /> + + + + + + + + {t("Statement")} + + + {!empty && ( + + + {t("Download")} + + {downloading ? ( + + ) : ( + + )} + + )} + + {period && ( + + {period} + + )} + + + {isLoading ? ( + + + + + + + ) : isError && !data ? ( + + { + refetch().catch(reportError); + }} + /> + + ) : empty ? ( + + + {t("No statement for this period")} + + + ) : ( + + {cards.map((card, index) => ( + + } first={index === 0}> + {t("Card **** {{lastFour}} installments", { lastFour: card.lastFour })} + + {card.dates.map((dates) => ( + + + + {day(dates.label)} + + + {dates.rows.map((row) => ( + + + + + {row.merchant} + + {row.total > 1 && ( + + {t("Installment {{current}} of {{total}}", { + current: row.current, + total: row.total, + })} + + )} + + + {money(row.amount)} + + + + ))} + + ))} + } + last={payments.length === 0 && index === lastCard} + > + + + + {t("Total installments")} + + + {t("Card **** {{lastFour}}", { lastFour: card.lastFour })} + + + + {money(card.total)} + + + + + ))} + + {payments.length > 0 && ( + <> + }> + {t("Payments")} + + {payments.map((payment) => { + const off = + payment.positionAmount > 0 ? (payment.positionAmount - payment.amount) / payment.positionAmount : 0; + return ( + + + + + {day(payment.timestamp)} + + {off >= 0.0001 ? ( + + {t("{{percent}} OFF", { percent: percent(off) })} + + ) : off <= -0.0001 ? ( + + {t("{{percent}} late fee", { percent: percent(-off) })} + + ) : null} + + + {money(payment.amount)} + + + + ); + })} + } last> + + + {t("Total payments")} + + + {money(paid)} + + + + + )} + + )} + + + + {ready && ( + + + + {t("Total due")} + + + {money(due)} + + + )} + + + ); +} + +function TimelineRow({ + node, + bullet, + first, + last, + children, +}: { + bullet?: boolean; + children: React.ReactNode; + first?: boolean; + last?: boolean; + node?: React.ReactNode; +}) { + return ( + + + + {node ? ( + + {node} + + ) : bullet ? ( + + + + ) : null} + + + {children} + + + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ); +} diff --git a/src/i18n/es.json b/src/i18n/es.json index 33ce0f920f..c677bf6a4c 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -1,4 +1,5 @@ { + "{{amount}} discount": "{{amount}} de descuento", "{{amount}} left": "{{amount}} restante", "{{asset}} on {{chain}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to swap it to a supported asset. Learn more.": "{{asset}} en {{chain}} no es un activo de colateral soportado. Para generar rendimiento y aumentar el límite de crédito de tu Exa Card, deberás hacer swap a un activo soportado. Aprende más.", "{{asset}} on {{network}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to bridge it to {{chain}} and swap it to a supported asset. Learn more.": "{{asset}} en {{network}} no es un activo de colateral soportado. Para generar rendimiento y aumentar el límite de crédito de tu Exa Card, deberás hacer bridge a {{chain}} y hacer swap a un activo soportado. Aprende más.", @@ -15,6 +16,8 @@ "{{currency}} via {{method}}": "{{currency}} vía {{method}}", "{{currency}} via {{methods}}": "{{currency}} vía {{methods}}", "{{discount}} off": "{{discount}} off", + "{{network}} deposit address": "Dirección de depósito de {{network}}", + "{{percent}} late fee": "{{percent}} de interés", "{{percent}} OFF": "{{percent}} OFF", "{{rate}} APR": "{{rate}} TNA", "{{source}} on {{network}} matches {{destination}} on {{chain}}. You can swap and bridge between these assets or just select another supported asset.": "{{source}} en {{network}} coincide con {{destination}} en {{chain}}. Puedes hacer swap y bridge entre estos activos o simplemente seleccionar otro activo soportado.", @@ -22,6 +25,9 @@ "{{symbol}} on {{network}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to bridge it to {{asset}} on {{chain}}. Learn more.": "{{symbol}} en {{network}} no es un activo de colateral soportado. Para generar rendimiento y aumentar el límite de crédito de tu Exa Card, deberás hacer bridge a {{asset}} en {{chain}}. Aprende más.", "{{symbol}}, {{balance}} available": "{{symbol}}, {{balance}} disponible", "{{time}} past due": "{{time}} de atraso", + "{{start}} to {{end}}": "{{start}} a {{end}}", + "**** {{lastFour}} installments": "Cuotas **** {{lastFour}}", + "~{{minutes}} min": "~{{minutes}} min", "*0% interest promo through {{month}}": "*Promo 0% de interés hasta {{month}}", "0% APR on installments": "0% TNA en cuotas", "0% APR*": "0% TNA*", @@ -126,6 +132,7 @@ "Biometrics must be enabled to use passkeys. Please enable biometrics in your device settings": "Se debe habilitar la biometría para usar llaves de acceso. Habilita la biometría en la configuración de tu dispositivo", "Borrow_home": "Préstamo", "BR Code": "Código BR", + "Breakdown": "Desglose", "Bridge {{symbol}}": "Hacer bridge de {{symbol}}", "Bridge and swap it to a supported asset on {{chain}}.": "Haz bridge y swap a un activo soportado en {{chain}}.", "Bridge and swap needed after receiving": "Bridge y swap necesarios después de recibir", @@ -151,6 +158,8 @@ "Can't delete this contact while a withdrawal is in progress.": "No puedes eliminar este contacto mientras hay un retiro en curso.", "Cancel": "Cancelar", "Cannot proceed": "No se puede continuar", + "Card **** {{lastFour}}": "Tarjeta **** {{lastFour}}", + "Card **** {{lastFour}} installments": "Cuotas de la tarjeta **** {{lastFour}}", "Card activated!": "¡Tarjeta activada!", "Card details": "Detalles de la tarjeta", "Card number copied!": "¡Número de tarjeta copiado!", @@ -205,8 +214,10 @@ "Copy": "Copiar", "Couldn't create the account. Please try again.": "No se pudo crear la cuenta. Inténtalo de nuevo.", "Couldn't delete the contact. Please try again.": "No se pudo eliminar el contacto. Inténtalo de nuevo.", + "Couldn't download your statement. Please try again.": "No se pudo descargar tu resumen. Inténtalo de nuevo.", "Couldn't load the exchange rate. Please try again.": "No se pudo cargar el tipo de cambio. Inténtalo de nuevo.", "Couldn't load your contacts. Please try again.": "No se pudieron cargar tus contactos. Inténtalo de nuevo.", + "Couldn't load your statement. Please try again.": "No se pudo cargar tu resumen. Inténtalo de nuevo.", "Couldn't update the contact. Please try again.": "No se pudo actualizar el contacto. Inténtalo de nuevo.", "Country": "País", "Create account": "Crear cuenta", @@ -246,6 +257,8 @@ "Dollars": "Dólares", "Don't show again": "No mostrar de nuevo", "Double-check your address before sending funds to avoid losing them.": "Verifica tu dirección antes de enviar fondos para evitar perderlos.", + "Download": "Descargar", + "Download statement": "Descargar resumen", "due {{date}}": "vence {{date}}", "Due {{date}}": "Vence {{date}}", "Due in {{time}}": "Vence en {{time}}", @@ -381,6 +394,7 @@ "In-store QR payments, with crypto": "Pagos con QR en tienda, con cripto", "Increase spending limit": "Aumentar límite de gasto", "Individual": "Persona física", + "Installment {{current}} of {{total}}": "Cuota {{current}} de {{total}}", "Installment due": "Cuota a pagar", "INSTALLMENT PLANS": "PAGAR EN CUOTAS", "Installments calculator": "Calculadora de cuotas", @@ -476,8 +490,10 @@ "No available assets.": "No hay activos disponibles.", "No external assets detected": "No se detectaron activos externos", "No payments pending": "Sin pagos pendientes", + "No payments yet": "Aún no hay pagos", "No recent contacts.": "No hay contactos recientes.", "No saved contacts.": "No hay contactos guardados.", + "No statement for this period": "No hay resumen para este período", "No tokens available": "No hay tokens disponibles", "No tokens found": "No se encontraron tokens", "Non-collateral assets": "Activos sin garantía", @@ -508,6 +524,7 @@ "Overdue payment {{date}}, {{amount}}": "Pago vencido {{date}}, {{amount}}", "Overdue payments": "Pagos vencidos", "Paid": "Pagado", + "Paid on {{date}}": "Pagado el {{date}}", "Passkeys advantages": "Ventajas de las llaves de acceso", "Passkeys are intrinsically linked with the app or website they were created for, so people can never be tricked into using their passkey to sign in to a fraudulent app or website.": "Las llaves de acceso están intrínsecamente vinculadas con la aplicación o sitio web para el que fueron creadas, por lo que las personas nunca pueden ser engañadas para usarlas en una aplicación o sitio web fraudulento.", "Passkeys replace passwords with cryptographic keys. Your private key stays on your device, while the public key is shared with the service. This ensures secure and seamless authentication.": "Las llaves de acceso reemplazan las contraseñas con claves criptográficas. Tu clave privada permanece en tu dispositivo, mientras que la clave pública se comparte con el servicio. Esto garantiza una autenticación segura y fluida.", @@ -531,6 +548,7 @@ "Payment details": "Detalles del pago", "Payment due {{date}}, {{amount}}": "Pago con vencimiento {{date}}, {{amount}}", "Payment failed": "Pago fallido", + "Payment history": "Historial de pagos", "Payment info": "Información del pago", "payment schedule": "calendario de pagos", "Payment schedule": "Calendario de pagos", @@ -669,7 +687,7 @@ "Start verification": "Iniciar verificación", "Starting...": "Iniciando...", "State": "Estado", - "Statements": "Resúmenes", + "Statement": "Resumen", "Stay connected around the world.": "Mantente conectado en todo el mundo.", "Stay connected everywhere": "Mantente conectado en todas partes", "Stay connected": "Mantener conexión", @@ -679,6 +697,7 @@ "Submitting bridge transaction...": "Enviando transacción de bridge...", "Submitting transfer transaction...": "Enviando transacción de transferencia...", "Subtotal": "Subtotal", + "Summary": "Resumen", "Support": "Soporte", "Supported asset match": "Coincidencia de activo soportado", "Supported assets": "Activos soportados", @@ -743,9 +762,12 @@ "Too long": "Demasiado largo", "Top up an external wallet supported by LI.FI to unlock bridging into {{chain}}.": "Recarga una billetera externa compatible con LI.FI para habilitar el bridge hacia {{chain}}.", "Total after rollover": "Total después del refinanciamiento", + "Total due": "Total a pagar", + "Total installments": "Total de cuotas", "Total outstanding info": "Información del total pendiente", "Total outstanding": "Total pendiente", "Total": "Total", + "Total payments": "Total de pagos", "Track your spending and see how much you’ve spent with your Exa Card so far.": "Sigue tus gastos y descubre cuánto has gastado con tu Exa Card hasta ahora.", "transaction declined": "transacción rechazada", "Transaction details": "Detalles de la transacción", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 80b729ef1c..775f63b54d 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -1,4 +1,5 @@ { + "{{amount}} discount": "{{amount}} de desconto", "{{amount}} left": "{{amount}} restante", "{{asset}} on {{chain}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to swap it to a supported asset. Learn more.": "{{asset}} em {{chain}} não é um ativo de colateral suportado. Para gerar rendimento e aumentar o limite de crédito do seu Exa Card, você precisará fazer swap para um ativo suportado. Saiba mais.", "{{asset}} on {{network}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to bridge it to {{chain}} and swap it to a supported asset. Learn more.": "{{asset}} em {{network}} não é um ativo de colateral suportado. Para gerar rendimento e aumentar o limite de crédito do seu Exa Card, você precisará fazer bridge para {{chain}} e fazer swap para um ativo suportado. Saiba mais.", @@ -15,6 +16,8 @@ "{{currency}} via {{method}}": "{{currency}} via {{method}}", "{{currency}} via {{methods}}": "{{currency}} via {{methods}}", "{{discount}} off": "{{discount}} off", + "{{network}} deposit address": "Endereço de depósito {{network}}", + "{{percent}} late fee": "{{percent}} de juros", "{{percent}} OFF": "{{percent}} OFF", "{{rate}} APR": "{{rate}} APR", "{{source}} on {{network}} matches {{destination}} on {{chain}}. You can swap and bridge between these assets or just select another supported asset.": "{{source}} em {{network}} corresponde a {{destination}} em {{chain}}. Você pode fazer swap e bridge entre esses ativos ou simplesmente selecionar outro ativo suportado.", @@ -22,6 +25,9 @@ "{{symbol}} on {{network}} isn't a supported collateral asset. To earn yield and increase your Exa Card credit limit, you'll need to bridge it to {{asset}} on {{chain}}. Learn more.": "{{symbol}} em {{network}} não é um ativo de colateral suportado. Para gerar rendimento e aumentar o limite de crédito do seu Exa Card, você precisará fazer bridge para {{asset}} em {{chain}}. Saiba mais.", "{{symbol}}, {{balance}} available": "{{symbol}}, {{balance}} disponível", "{{time}} past due": "{{time}} de atraso", + "{{start}} to {{end}}": "{{start}} a {{end}}", + "**** {{lastFour}} installments": "Parcelas **** {{lastFour}}", + "~{{minutes}} min": "~{{minutes}} min", "*0% interest promo through {{month}}": "*Promo 0% de juros até {{month}}", "0% APR on installments": "0% APR em parcelas", "0% APR*": "0% APR*", @@ -126,6 +132,7 @@ "Biometrics must be enabled to use passkeys. Please enable biometrics in your device settings": "A biometria deve estar ativada para usar chaves de acesso. Ative a biometria nas configurações do seu dispositivo", "Borrow_home": "Empréstimo", "BR Code": "Código BR", + "Breakdown": "Detalhamento", "Bridge {{symbol}}": "Fazer bridge de {{symbol}}", "Bridge and swap it to a supported asset on {{chain}}.": "Faça bridge e swap para um ativo suportado em {{chain}}.", "Bridge and swap needed after receiving": "Bridge e swap necessários após o recebimento", @@ -151,6 +158,8 @@ "Can't delete this contact while a withdrawal is in progress.": "Não é possível excluir este contato enquanto há um saque em andamento.", "Cancel": "Cancelar", "Cannot proceed": "Não é possível continuar", + "Card **** {{lastFour}}": "Cartão **** {{lastFour}}", + "Card **** {{lastFour}} installments": "Parcelas do cartão **** {{lastFour}}", "Card activated!": "Cartão ativado!", "Card details": "Detalhes do cartão", "Card number copied!": "Número do cartão copiado!", @@ -205,8 +214,10 @@ "Copy": "Copiar", "Couldn't create the account. Please try again.": "Não foi possível criar a conta. Tente novamente.", "Couldn't delete the contact. Please try again.": "Não foi possível excluir o contato. Tente novamente.", + "Couldn't download your statement. Please try again.": "Não foi possível baixar seu extrato. Tente novamente.", "Couldn't load the exchange rate. Please try again.": "Não foi possível carregar a taxa de câmbio. Tente novamente.", "Couldn't load your contacts. Please try again.": "Não foi possível carregar seus contatos. Tente novamente.", + "Couldn't load your statement. Please try again.": "Não foi possível carregar seu extrato. Tente novamente.", "Couldn't update the contact. Please try again.": "Não foi possível atualizar o contato. Tente novamente.", "Country": "País", "Create account": "Criar conta", @@ -246,6 +257,8 @@ "Dollars": "Dólares", "Don't show again": "Não mostrar novamente", "Double-check your address before sending funds to avoid losing them.": "Verifique seu endereço antes de enviar fundos para evitar perdê-los.", + "Download": "Baixar", + "Download statement": "Baixar extrato", "due {{date}}": "vence {{date}}", "Due {{date}}": "Vence {{date}}", "Due in {{time}}": "Vence em {{time}}", @@ -381,6 +394,7 @@ "In-store QR payments, with crypto": "Pagamentos com QR na loja, com cripto", "Increase spending limit": "Aumentar limite de gastos", "Individual": "Pessoa física", + "Installment {{current}} of {{total}}": "Parcela {{current}} de {{total}}", "Installment due": "Parcela a pagar", "INSTALLMENT PLANS": "PAGAR EM PARCELAS", "Installments calculator": "Calculadora de parcelas", @@ -476,8 +490,10 @@ "No available assets.": "Nenhum ativo disponível.", "No external assets detected": "Nenhum ativo externo detectado", "No payments pending": "Nenhum pagamento pendente", + "No payments yet": "Ainda não há pagamentos", "No recent contacts.": "Nenhum contato recente.", "No saved contacts.": "Nenhum contato salvo.", + "No statement for this period": "Não há extrato para este período", "No tokens available": "Nenhum token disponível", "No tokens found": "Nenhum token encontrado", "Non-collateral assets": "Ativos sem garantia", @@ -508,6 +524,7 @@ "Overdue payment {{date}}, {{amount}}": "Pagamento atrasado {{date}}, {{amount}}", "Overdue payments": "Pagamentos atrasados", "Paid": "Pago", + "Paid on {{date}}": "Pago em {{date}}", "Passkeys advantages": "Vantagens das chaves de acesso", "Passkeys are intrinsically linked with the app or website they were created for, so people can never be tricked into using their passkey to sign in to a fraudulent app or website.": "As chaves de acesso estão intrinsecamente vinculadas ao aplicativo ou site para o qual foram criadas, então as pessoas nunca podem ser enganadas para usá-las em um aplicativo ou site fraudulento.", "Passkeys replace passwords with cryptographic keys. Your private key stays on your device, while the public key is shared with the service. This ensures secure and seamless authentication.": "As chaves de acesso substituem senhas por chaves criptográficas. Sua chave privada permanece no seu dispositivo, enquanto a chave pública é compartilhada com o serviço. Isso garante uma autenticação segura e fluida.", @@ -531,6 +548,7 @@ "Payment details": "Detalhes do pagamento", "Payment due {{date}}, {{amount}}": "Pagamento com vencimento {{date}}, {{amount}}", "Payment failed": "Pagamento recusado", + "Payment history": "Histórico de pagamentos", "Payment info": "Informações do pagamento", "payment schedule": "agenda de pagamentos", "Payment schedule": "Agenda de pagamentos", @@ -669,7 +687,7 @@ "Start verification": "Iniciar verificação", "Starting...": "Iniciando...", "State": "Estado", - "Statements": "Extratos", + "Statement": "Extrato", "Stay connected around the world.": "Fique conectado em todo o mundo.", "Stay connected everywhere": "Fique conectado em qualquer lugar", "Stay connected": "Manter conexão", @@ -679,6 +697,7 @@ "Submitting bridge transaction...": "Enviando transação de bridge...", "Submitting transfer transaction...": "Enviando transação de transferência...", "Subtotal": "Subtotal", + "Summary": "Resumo", "Support": "Suporte", "Supported asset match": "Correspondência de ativo suportado", "Supported assets": "Ativos suportados", @@ -743,9 +762,12 @@ "Too long": "Muito longo", "Top up an external wallet supported by LI.FI to unlock bridging into {{chain}}.": "Recarregue uma carteira externa compatível com LI.FI para habilitar o bridge para {{chain}}.", "Total after rollover": "Total após refinanciamento", + "Total due": "Total a pagar", + "Total installments": "Total de parcelas", "Total outstanding info": "Informações do total pendente", "Total outstanding": "Total pendente", "Total": "Total", + "Total payments": "Total de pagamentos", "Track your spending and see how much you’ve spent with your Exa Card so far.": "Acompanhe seus gastos e veja quanto você gastou com seu Exa Card até agora.", "transaction declined": "transação rejeitada", "Transaction details": "Detalhes da transação", diff --git a/src/utils/server.ts b/src/utils/server.ts index 7a56f3a0e7..70bffc6323 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -308,6 +308,18 @@ queryClient.setQueryDefaults(["activity", "details"], { throw new Error("don't refetch"); }, }); +queryClient.setQueryDefaults(["activity", "statement"], { + staleTime: 60_000, + gcTime: isServer ? Infinity : 60 * 60_000, +}); + +export async function getStatement(maturity: number) { + return getActivity({ maturity: String(maturity) }, "application/pdf"); +} + +export async function getStatementActivity(maturity: number) { + return getActivity({ maturity: String(maturity), include: ["card", "repay"] }); +} let authenticating: Promise | undefined; export async function auth() { diff --git a/src/utils/statement.ts b/src/utils/statement.ts new file mode 100644 index 0000000000..121634b78d --- /dev/null +++ b/src/utils/statement.ts @@ -0,0 +1,108 @@ +import { Platform } from "react-native"; + +import { File, Paths } from "expo-file-system"; +import { isAvailableAsync, shareAsync } from "expo-sharing"; + +import { getStatement } from "./server"; + +import type { getStatementActivity } from "./server"; + +export function group(items: Awaited>) { + const cards = new Map; lastFour: string }>(); + const payments: Payment[] = []; + for (const item of items) { + if (item.type === "repay") { + payments.push({ + id: item.id, + amount: item.amount, + positionAmount: item.positionAmount, + timestamp: item.timestamp, + }); + continue; + } + if (item.type !== "panda" && item.type !== "card") continue; + const lines = installments(item); + if (lines.length === 0) continue; + const card = cards.get(item.cardId) ?? { + lastFour: item.lastFour, + dates: new Map(), + }; + const key = item.timestamp.slice(0, 10); + const dates = card.dates.get(key) ?? { label: item.timestamp, rows: [] }; + dates.rows.push(...lines.map((line) => ({ merchant: item.merchant.name, ...line }))); + card.dates.set(key, dates); + cards.set(item.cardId, card); + } + const grouped = [...cards.values()] + .map(({ lastFour, dates }) => { + const days = [...dates.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => ({ key, ...value })); + const total = days.reduce((sum, { rows }) => sum + rows.reduce((amount, row) => amount + row.amount, 0), 0); + return { lastFour, dates: days, total }; + }) + .sort((a, b) => a.lastFour.localeCompare(b.lastFour)); + const purchases = grouped.reduce((sum, { total }) => sum + total, 0); + const paid = payments.reduce((sum, { amount }) => sum + amount, 0); + const settled = payments.reduce((sum, { positionAmount }) => sum + positionAmount, 0); + return { + cards: grouped, + payments: payments.sort((a, b) => a.timestamp.localeCompare(b.timestamp)), + paid, + discount: settled - paid, + due: purchases - settled, + }; +} + +export async function downloadStatement(maturity: number, filename: string) { + const bytes = await getStatement(maturity); + if (Platform.OS !== "web") return share(bytes, filename); + const url = pdf(bytes); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +async function share(bytes: Uint8Array, filename: string) { + const file = new File(Paths.cache, filename); + if (file.exists) file.delete(); + file.write(bytes); + if (!(await isAvailableAsync())) throw new Error("sharing unavailable"); + await shareAsync(file.uri, { mimeType: "application/pdf", UTI: "com.adobe.pdf", dialogTitle: filename }); +} + +function pdf(bytes: Uint8Array) { + return URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: "application/pdf" })); +} + +function installments(item: Purchase) { + if (item.type === "panda") { + return item.operations.flatMap((operation) => + "borrow" in operation + ? "installments" in operation.borrow + ? operation.borrow.installments.map((installment) => ({ + current: installment.current, + total: operation.mode, + amount: installment.amount, + })) + : [{ current: 1, total: 1, amount: operation.borrow.amount }] + : [], + ); + } + if (!("borrow" in item)) return []; + return "installments" in item.borrow + ? item.borrow.installments.map((installment) => ({ + current: installment.current, + total: item.mode, + amount: installment.amount, + })) + : [{ current: 1, total: 1, amount: item.borrow.amount }]; +} + +type Purchase = Extract>[number], { type: "card" | "panda" }>; +type Row = { amount: number; current: number; merchant: string; total: number }; +type Payment = { amount: number; id: string; positionAmount: number; timestamp: string }; diff --git a/src/utils/useStatement.ts b/src/utils/useStatement.ts new file mode 100644 index 0000000000..9ce9d5806e --- /dev/null +++ b/src/utils/useStatement.ts @@ -0,0 +1,10 @@ +import { skipToken, useQuery } from "@tanstack/react-query"; + +import { getStatementActivity } from "./server"; + +export default function useStatement(maturity: number | undefined) { + return useQuery({ + queryKey: ["activity", "statement", maturity], + queryFn: maturity === undefined ? skipToken : () => getStatementActivity(maturity), + }); +} diff --git a/src/utils/useStatements.ts b/src/utils/useStatements.ts new file mode 100644 index 0000000000..25bf585d08 --- /dev/null +++ b/src/utils/useStatements.ts @@ -0,0 +1,33 @@ +import { useMemo } from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { MATURITY_INTERVAL } from "@exactly/lib"; + +import useMarkets from "./useMarkets"; + +import type { ActivityItem } from "./queryClient"; + +export default function useStatements() { + const { data: activity } = useQuery({ queryKey: ["activity"] }); + const { timestamp } = useMarkets(); + return useMemo(() => { + if (!activity) return []; + const now = Number(timestamp); + const maturities = new Set(); + for (const item of activity) { + const borrows = + item.type === "panda" + ? item.operations.flatMap((operation) => ("borrow" in operation ? [operation.borrow] : [])) + : item.type === "card" && "borrow" in item + ? [item.borrow] + : []; + for (const borrow of borrows) { + if ("installments" in borrow) + for (const installment of borrow.installments) maturities.add(installment.maturity); + else maturities.add(borrow.maturity); + } + } + return [...maturities].filter((m) => m - MATURITY_INTERVAL < now).sort((a, b) => b - a); + }, [activity, timestamp]); +}