Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stale-teams-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

✨ enable kyc onboarding from add-funds
5 changes: 4 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@
"montos",
"refinancia",
"refinanciamiento",
"refinanciar"
"refinanciar",
"verifícate"
]
},
{
Expand All @@ -229,6 +230,7 @@
"asegurate",
"biometría",
"cambiá",
"completá",
"conectate",
"conectá",
"conectás",
Expand Down Expand Up @@ -278,6 +280,7 @@
"tocá",
"usalo",
"usás",
"verificate",
"verificá"
]
},
Expand Down
47 changes: 44 additions & 3 deletions src/components/add-funds/AddFunds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ScrollView, XStack, YStack } from "tamagui";

import { useQuery } from "@tanstack/react-query";
import { isAddress } from "viem";
import { base } from "viem/chains";

import domain from "@exactly/common/domain";
import chain from "@exactly/common/generated/chain";
Expand All @@ -20,12 +21,15 @@ import { presentArticle } from "../../utils/intercom";
import queryClient, { type AuthMethod } from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import { getKYCStatus, getRampProviders } from "../../utils/server";
import useBeginKYC from "../../utils/useBeginKYC";
import ChainLogo from "../shared/ChainLogo";
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";

import type { KYCStatus } from "../../utils/server";
import type { Credential } from "@exactly/common/validation";

export default function AddFunds() {
Expand All @@ -36,6 +40,10 @@ export default function AddFunds() {
const ownerAccount = credential && isAddress(credential.credentialId) ? credential.credentialId : undefined;

const { data: method } = useQuery<AuthMethod>({ queryKey: ["method"] });
const { data: kycStatus } = useQuery<KYCStatus>({ queryKey: ["kyc", "status"] });
const beginKYC = useBeginKYC();
const isBase = chain.id === base.id;
const isKYCApproved = kycStatus?.code === "ok" || kycStatus?.code === "legacy kyc";

const { data: countryCode } = useQuery({
queryKey: ["user", "country"],
Expand Down Expand Up @@ -135,21 +143,49 @@ export default function AddFunds() {
router.push({ pathname: "/add-funds", params: { type: "crypto" } });
}}
/>
{hasFiat !== false && (
{!isBase && hasFiat !== false && (
<AddFundsOption
icon={<Banknote size={24} color="$iconBrandDefault" />}
title={t("Bank transfers")}
subtitle={t("From a bank account")}
disabled={!hasFiat}
disabled={(isKYCApproved && !hasFiat) || beginKYC.isPending}
loading={beginKYC.isPending}
onPress={() => {
router.push({ pathname: "/add-funds", params: { type: "fiat" } });
if (isKYCApproved) {
router.push({ pathname: "/add-funds", params: { type: "fiat" } });
return;
}
beginKYC
.mutateAsync()
.then(async (result) => {
if (result?.status === "cancel") return;
const status = await queryClient.fetchQuery<KYCStatus>({
queryKey: ["kyc", "status"],
staleTime: 0,
});
if ("code" in status && (status.code === "ok" || status.code === "legacy kyc")) {
await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] });
router.push({ pathname: "/add-funds", params: { type: "fiat" } });
}
})
.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function -- error handled by useBeginKYC
}}
/>
)}
</>
)}
{type === "crypto" && (
<>
{!isBase && !isKYCApproved && (
<InfoAlert
title={t("Complete a quick identity check to access more networks.")}
actionText={t("Get verified")}
onPress={() => {
beginKYC.mutate();
}}
loading={beginKYC.isPending}
/>
)}
{method === "siwe" && (
<AddFundsOption
icon={<Wallet width={40} height={40} color="$iconBrandDefault" />}
Expand All @@ -175,6 +211,11 @@ export default function AddFunds() {
{renderProviders("crypto")}
</>
)}
{type === "fiat" && countryCode && isPending && (
<View justifyContent="center" alignItems="center">
<Skeleton width="100%" height={82} />
</View>
)}
{type === "fiat" && providers && (
<YStack gap="$s5">
{(["manteca", "bridge"] as const).map((key) => {
Expand Down
10 changes: 8 additions & 2 deletions src/components/add-funds/AddFundsOption.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";

import { ChevronRight } from "@tamagui/lucide-icons";
import { XStack, YStack } from "tamagui";
import { Spinner, XStack, YStack } from "tamagui";

import Text from "../shared/Text";
import View from "../shared/View";
Expand All @@ -11,10 +11,12 @@ export default function AddFundsOption({
title,
subtitle,
disabled,
loading,
onPress,
}: {
disabled?: boolean;
icon: React.ReactElement;
loading?: boolean;
onPress: () => void;
subtitle: string;
title: string;
Expand Down Expand Up @@ -54,7 +56,11 @@ export default function AddFundsOption({
</YStack>
</XStack>
<View>
<ChevronRight size={24} color="$uiBrandSecondary" />
{loading ? (
<Spinner size="small" color="$uiBrandSecondary" />
) : (
<ChevronRight size={24} color="$uiBrandSecondary" />
)}
</View>
</XStack>
</YStack>
Expand Down
6 changes: 5 additions & 1 deletion src/components/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ export default function Card() {
}
}
try {
await startKYC();
const result = await startKYC();
if (result.status === "complete") {
const status = await queryClient.fetchQuery<KYCStatus>({ queryKey: ["kyc", "status"], staleTime: 0 });
if ("code" in status && (status.code === "ok" || status.code === "legacy kyc")) setDisclaimerShown(true);
}
} catch (error) {
toast.show(t("An error occurred. Please try again later."), {
native: true,
Expand Down
9 changes: 7 additions & 2 deletions src/components/getting-started/GettingStarted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,19 @@ function CurrentStep({ hasKYC, isDeployed }: { hasKYC: boolean; isDeployed: bool
const { t } = useTranslation();
const router = useRouter();
const { currentStep, completedSteps } = useOnboardingSteps({ hasKYC, isDeployed });
const { mutate: beginKYC } = useBeginKYC();
const beginKYC = useBeginKYC();
function handleAction() {
switch (currentStep?.id) {
case "add-funds":
router.push("/add-funds/add-crypto");
break;
case "verify-identity":
beginKYC();
beginKYC
.mutateAsync()
.then((result) => {
if (result?.status !== "cancel") router.replace("/(main)/(home)");
})
.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function -- error handled by useBeginKYC
break;
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/es-AR.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"Choose <strong>Pay Now</strong> to pay from your USDC balance, or Pay Later to split your purchase into up to {{max}} fixed-rate USDC installments, powered by Exactly Protocol.*": "Elegí <strong>Pagar ahora</strong> para usar tu saldo de USDC, o <strong>Pagar en cuotas</strong> para dividir tu compra en hasta {{max}} cuotas fijas en USDC, con tecnología de Exactly Protocol.*",
"Choose how many installments to use for future card purchases. You can always change this before each purchase.": "Elegí cuántas cuotas usar para futuras compras con tarjeta. Siempre podés cambiar esto antes de cada compra.",
"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.",
"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",
Expand All @@ -36,6 +37,7 @@
"Exa App does not control your assets or provide financial services. All integrations are powered by independent third-party DeFi protocols. <link>Learn more about how to connect your wallet to DeFi.</link>": "Exa App no controla tus activos ni ofrece servicios financieros. Todas las integraciones están impulsadas por protocolos DeFi independientes de terceros. <link>Conocé más sobre cómo conectar tu billetera a DeFi.</link>",
"Get fixed-interest funding using your assets as collateral, no credit check needed. Choose an amount and repayment plan to receive USDC.": "Obtené financiamiento a tasa fija usando tus activos como garantía, sin necesidad de verificación de crédito. Elegí un monto y un plan de pago para recibir USDC.",
"Get more time to repay": "Obtené más tiempo para pagar",
"Get verified": "Verificate",
"Get your Visa Signature Exa Card": "Obtené tu Exa Card Visa Signature",
"Here you’ll find integrations with decentralized services powered by our partners. Exa App never controls your assets or how you use them when connected to the integrations provided by our partners.": "Aquí encontrarás integraciones con servicios descentralizados impulsados por nuestros socios. Exa App nunca controla tus activos ni cómo los usás cuando te conectás a las integraciones de nuestros socios.",
"Learn more": "Aprendé más",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"Collateral Assets": "Activos de garantía",
"Collateral": "Garantía",
"COMING SOON": "PRÓXIMAMENTE",
"Complete a quick identity check to access more networks.": "Completa una verificación de identidad rápida para acceder a más redes.",
"Confirm and borrow {{symbol}}": "Confirmar y pedir prestado {{symbol}}",
"Confirm and receive {{symbol}}": "Confirmar y recibir {{symbol}}",
"Confirm payment": "Confirmar pago",
Expand Down Expand Up @@ -254,6 +255,7 @@
"Get more time to repay": "Obtén más tiempo para pagar",
"Get now": "Obtener ahora",
"Get started": "Comenzar",
"Get verified": "Verifícate",
"Get your Visa Signature Exa Card": "Obtén tu Exa Card Visa Signature",
"Getting started": "Primeros pasos",
"Getting Started": "Primeros pasos",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"Collateral Assets": "Ativos de garantia",
"Collateral": "Garantia",
"COMING SOON": "EM BREVE",
"Complete a quick identity check to access more networks.": "Complete uma verificação de identidade rápida para acessar mais redes.",
"Confirm and borrow {{symbol}}": "Confirmar empréstimo de {{symbol}}",
"Confirm and receive {{symbol}}": "Confirmar e receber {{symbol}}",
"Confirm payment": "Confirmar pagamento",
Expand Down Expand Up @@ -254,6 +255,7 @@
"Get more time to repay": "Tenha mais tempo para pagar",
"Get now": "Obter agora",
"Get started": "Começar",
"Get verified": "Verifique-se",
"Get your Visa Signature Exa Card": "Obtenha seu Exa Card Visa Signature",
"Getting started": "Primeiros passos",
"Getting Started": "Primeiros passos",
Expand Down
24 changes: 11 additions & 13 deletions src/utils/persona.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Platform } from "react-native";
import type { Environment } from "react-native-persona";

import { router } from "expo-router";

import { sdk } from "@farcaster/miniapp-sdk";

import domain from "@exactly/common/domain";
Expand All @@ -13,17 +11,18 @@ import { getKYCTokens } from "./server";

export const environment = (__DEV__ || process.env.EXPO_PUBLIC_ENV === "e2e" ? "sandbox" : "production") as Environment;

type RampKYCResult = { status: "cancel" } | { status: "complete" } | { status: "error" };
type KYCResult = { status: "cancel" } | { status: "complete" };
type RampKYCResult = KYCResult | { status: "error" };

let current:
| undefined
| { controller: AbortController; promise: Promise<KYCResult>; type: "basic" }
| {
controller: AbortController;
promise: Promise<RampKYCResult>;
tokens?: { inquiryId: string; sessionToken: string };
type: "bridge" | "manteca";
}
| { controller: AbortController; promise: Promise<void>; type: "basic" };
};

export function startKYC() {
if (current && !current.controller.signal.aborted && current.type === "basic") return current.promise;
Expand All @@ -47,7 +46,7 @@ export function startKYC() {
]);
if (signal.aborted) throw signal.reason;

return new Promise<void>((resolve, reject) => {
return new Promise<KYCResult>((resolve, reject) => {
const onAbort = () => {
client.destroy();
reject(new Error("persona inquiry aborted", { cause: signal.reason }));
Expand All @@ -62,14 +61,14 @@ export function startKYC() {
globalThis.removeEventListener("pagehide", onPageHide);
client.destroy();
handleComplete();
resolve();
resolve({ status: "complete" });
},
onCancel: () => {
signal.removeEventListener("abort", onAbort);
globalThis.removeEventListener("pagehide", onPageHide);
client.destroy();
handleCancel();
resolve();
resolve({ status: "cancel" });
},
onError: (error) => {
signal.removeEventListener("abort", onAbort);
Expand All @@ -87,20 +86,20 @@ export function startKYC() {
if (signal.aborted) throw signal.reason;

const { Inquiry } = await import("react-native-persona");
return new Promise<void>((resolve, reject) => {
return new Promise<KYCResult>((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);
handleCancel();
resolve();
resolve({ status: "cancel" });
})
.onComplete(() => {
signal.removeEventListener("abort", onAbort);
handleComplete();
resolve();
resolve({ status: "complete" });
})
.onError((error) => {
signal.removeEventListener("abort", onAbort);
Expand Down Expand Up @@ -243,11 +242,10 @@ async function getRedirectURI() {

function handleComplete() {
queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(reportError);
queryClient.invalidateQueries({ queryKey: ["user", "country"] }).catch(reportError);
queryClient.setQueryData(["card-upgrade"], 1);
router.replace("/(main)/(home)");
}

function handleCancel() {
queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(reportError);
router.replace("/(main)/(home)");
}
2 changes: 1 addition & 1 deletion src/utils/useBeginKYC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function useBeginKYC() {
if (!(error instanceof APIError)) throw error;
if (error.text !== "not started" && error.text !== "no kyc") throw error;
}
await startKYC();
return startKYC();
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: ["kyc", "status"] });
Expand Down