diff --git a/.changeset/spicy-poems-brush.md b/.changeset/spicy-poems-brush.md new file mode 100644 index 000000000..7ef4475a4 --- /dev/null +++ b/.changeset/spicy-poems-brush.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ implement persona web sdk diff --git a/package.json b/package.json index 1d62aa567..5680401a0 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "i18n-iso-countries": "^7.14.0", "i18next": "^25.7.4", "moti": "^0.30.0", + "persona": "^5.5.0", "react": "19.1.0", "react-dom": "19.1.0", "react-i18next": "^16.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1e9c817b..e9f3b1f89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,9 @@ importers: moti: specifier: ^0.30.0 version: 0.30.0(react-dom@19.1.0(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.6)(react-native-worklets@0.5.1(@babel/core@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0))(react@19.1.0) + persona: + specifier: ^5.5.0 + version: 5.5.0 react: specifier: 19.1.0 version: 19.1.0 @@ -9817,6 +9820,9 @@ packages: lodash.isarray@3.0.4: resolution: {integrity: sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==} + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + lodash.keysin@3.0.8: resolution: {integrity: sha512-YDB/5xkL3fBKFMDaC+cfGV00pbiJ6XoJIfRmBhv7aR6wWtbCW6IzkiWnTfkiHTF6ALD7ff83dAtB3OEaSoyQPg==} @@ -10856,6 +10862,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + persona@5.5.0: + resolution: {integrity: sha512-sSayn72ppan7RGhMhBHbfUjQtIeWGGKL/AKA/2+bXGEBhNgkzaLBuLd+urcRLXmqjoZhFXsWgG5oVxTFvABhcw==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -24567,6 +24576,8 @@ snapshots: lodash.isarray@3.0.4: {} + lodash.kebabcase@4.1.1: {} + lodash.keysin@3.0.8: dependencies: lodash.isarguments: 3.1.0 @@ -26245,6 +26256,11 @@ snapshots: pathe@2.0.3: {} + persona@5.5.0: + dependencies: + lodash.kebabcase: 4.1.1 + qs: 6.14.1 + pg-cloudflare@1.3.0: optional: true diff --git a/src/utils/persona.ts b/src/utils/persona.ts index cae5a28f5..a9b732dd5 100644 --- a/src/utils/persona.ts +++ b/src/utils/persona.ts @@ -13,43 +13,97 @@ import { getKYCTokens } from "./server"; export const environment = (__DEV__ || process.env.EXPO_PUBLIC_ENV === "e2e" ? "sandbox" : "production") as Environment; -export async function startKYC() { - const { otl: oneTimeLink, inquiryId, sessionToken } = await getKYCTokens("basic", await getRedirectURI()); +export const startKYC = (() => { + let current: undefined | { controller: AbortController; promise: Promise }; - if (Platform.OS === "web") { - if (await sdk.isInMiniApp()) { - await sdk.actions.openUrl(oneTimeLink); - return; - } - const embeddingContext = queryClient.getQueryData(["embedding-context"]); - if (embeddingContext && !embeddingContext.endsWith("-web")) { - window.location.replace(oneTimeLink); - return; - } - window.open(oneTimeLink, "_blank", "noopener,noreferrer"); // cspell:ignore noopener noreferrer - return; - } + return () => { + if (current && !current.controller.signal.aborted) return current.promise; - const { Inquiry } = await import("react-native-persona"); - Inquiry.fromInquiry(inquiryId) - .sessionToken(sessionToken) - .onCanceled(() => { - queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(reportError); - router.replace("/(main)/(home)"); - }) - .onComplete(() => { - queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(reportError); - queryClient.setQueryData(["card-upgrade"], 1); - router.replace("/(main)/(home)"); - }) - .onError((error) => reportError(error)) - .build() - .start(); -} + current?.controller.abort(new Error("persona inquiry aborted")); + const controller = new AbortController(); + + const promise = (async () => { + const { signal } = controller; + + if (Platform.OS === "web") { + const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([ + import("persona"), + getKYCTokens("basic", await getRedirectURI()), + ]); + if (signal.aborted) throw signal.reason; + + return new Promise((resolve, reject) => { + const client = new Client({ + inquiryId, + sessionToken, + environment: environment as "production" | "sandbox", // TODO implement environmentId + onReady: () => client.open(), + onComplete: () => { + client.destroy(); + handleComplete(); + resolve(); + }, + onCancel: () => { + client.destroy(); + handleCancel(); + resolve(); + }, + onError: (error) => { + client.destroy(); + reportError(error); + reject(new Error("persona inquiry failed", { cause: error })); + }, + }); + signal.addEventListener( + "abort", + () => { + client.destroy(); + reject(new Error("persona inquiry aborted", { cause: signal.reason })); + }, + { once: true }, + ); + }); + } + + const { inquiryId, sessionToken } = await getKYCTokens("basic", await getRedirectURI()); + if (signal.aborted) throw signal.reason; + + const { Inquiry } = await import("react-native-persona"); + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("persona inquiry aborted", { cause: signal.reason })), { + once: true, + }); + Inquiry.fromInquiry(inquiryId) + .sessionToken(sessionToken) + .onCanceled(() => { + handleCancel(); + resolve(); + }) + .onComplete(() => { + handleComplete(); + resolve(); + }) + .onError((error) => { + reportError(error); + reject(error); + }) + .build() + .start(); + }); + })().finally(() => { + if (current?.controller === controller) current = undefined; + }); + + current = { controller, promise }; + return promise; + }; +})(); async function getRedirectURI() { - const miniappContext = (await sdk.context) as unknown as undefined | { client: { appUrl?: string } }; - if (miniappContext?.client.appUrl) return miniappContext.client.appUrl; + if (Platform.OS === "web") { + const miniappContext = (await sdk.context) as unknown as undefined | { client: { appUrl?: string } }; + if (miniappContext?.client.appUrl) return miniappContext.client.appUrl; + } switch (queryClient.getQueryData(["embedding-context"])) { case "farcaster-web": return `https://farcaster.xyz/miniapps/${ @@ -60,3 +114,14 @@ async function getRedirectURI() { }/exa-app`; } } + +function handleComplete() { + queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(reportError); + queryClient.setQueryData(["card-upgrade"], 1); + router.replace("/(main)/(home)"); +} + +function handleCancel() { + queryClient.invalidateQueries({ queryKey: ["kyc", "status"] }).catch(reportError); + router.replace("/(main)/(home)"); +}