diff --git a/.changeset/orange-cars-worry.md b/.changeset/orange-cars-worry.md new file mode 100644 index 000000000..d2c5b90c4 --- /dev/null +++ b/.changeset/orange-cars-worry.md @@ -0,0 +1,5 @@ +--- +"@frak-labs/nexus-sdk": patch +--- + +Add a `modalBuilder` to ease modal creation diff --git a/example/vanilla-js/src/main.ts b/example/vanilla-js/src/main.ts index 6b9b0d3fb..3024997ec 100644 --- a/example/vanilla-js/src/main.ts +++ b/example/vanilla-js/src/main.ts @@ -5,7 +5,12 @@ import { setupFrakClient } from "./module/setupClient"; import { displayWalletStatus } from "./module/walletStatus"; // Export the setup function and config for use in other files -window.FrakSetup = { frakConfig, frakClient: null, modalShare }; +window.FrakSetup = { + frakConfig, + frakClient: null, + modalShare, + modalBuilder: null, +}; document.addEventListener("DOMContentLoaded", () => { console.log("NexusSDK", window.NexusSDK); @@ -16,22 +21,19 @@ document.addEventListener("DOMContentLoaded", () => { return; } + const modalStepBuilder = window.NexusSDK.modalBuilder(frakClient, { + metadata: { + lang: "fr", + isDismissible: true, + }, + login: loginModalStep, + }); + window.FrakSetup.frakClient = frakClient; + window.FrakSetup.modalBuilder = modalStepBuilder; window.NexusSDK.referralInteraction(frakClient, { - modalConfig: { - steps: { - login: loginModalStep, - openSession: {}, - final: { - action: { key: "reward" }, - }, - }, - metadata: { - lang: "fr", - isDismissible: true, - }, - }, + modalConfig: modalStepBuilder.reward().params, options: { alwaysAppendUrl: true, }, diff --git a/example/vanilla-js/src/module/login.ts b/example/vanilla-js/src/module/login.ts index 2cd412b56..528ac852d 100644 --- a/example/vanilla-js/src/module/login.ts +++ b/example/vanilla-js/src/module/login.ts @@ -1,5 +1,3 @@ -import { loginModalStep } from "./config"; - export function bindLoginButton() { const loginButton = document.getElementById("login-button"); loginButton?.addEventListener("click", handleLogin); @@ -19,20 +17,11 @@ async function handleLogin() { loginButton.textContent = "Logging in..."; try { - if (!window.FrakSetup.frakClient) { + if (!window.FrakSetup.modalBuilder) { console.error("Frak client not initialized"); return; } - await window.NexusSDK.displayModal(window.FrakSetup.frakClient, { - metadata: { - lang: "fr", - isDismissible: true, - }, - steps: { - login: loginModalStep, - openSession: {}, - }, - }); + await window.FrakSetup.modalBuilder.display(); loginButton.textContent = "Logged In"; } catch (error) { console.error("Login error:", error); diff --git a/example/vanilla-js/src/module/modalShare.ts b/example/vanilla-js/src/module/modalShare.ts index e10d7d07f..a052cb6f0 100644 --- a/example/vanilla-js/src/module/modalShare.ts +++ b/example/vanilla-js/src/module/modalShare.ts @@ -1,29 +1,13 @@ -import { loginModalStep } from "./config"; - export function modalShare() { - const finalAction = { - key: "sharing", - options: { - popupTitle: "Share this article with your friends", - text: "Discover this awesome article", - link: typeof window !== "undefined" ? window.location.href : "", - }, - } as const; - if (!window.FrakSetup.frakClient) { + if (!window.FrakSetup.modalBuilder) { console.error("Frak client not initialized"); return; } - window.NexusSDK.displayModal(window.FrakSetup.frakClient, { - metadata: { - lang: "fr", - isDismissible: true, - }, - steps: { - login: loginModalStep, - openSession: {}, - final: { - action: finalAction, - }, - }, - }); + window.FrakSetup.modalBuilder + .sharing({ + popupTitle: "Share this article with your friends", + text: "Discover this awesome article", + link: typeof window !== "undefined" ? window.location.href : "", + }) + .display(); } diff --git a/example/vanilla-js/src/types/globals.d.ts b/example/vanilla-js/src/types/globals.d.ts index 223bb351a..949b71050 100644 --- a/example/vanilla-js/src/types/globals.d.ts +++ b/example/vanilla-js/src/types/globals.d.ts @@ -1,5 +1,7 @@ import type { + ModalBuilder, displayModal, + modalBuilder, referralInteraction, watchWalletStatus, } from "@frak-labs/nexus-sdk/actions"; @@ -18,11 +20,13 @@ declare global { displayModal: typeof displayModal; referralInteraction: typeof referralInteraction; watchWalletStatus: typeof watchWalletStatus; + modalBuilder: typeof modalBuilder; }; FrakSetup: { frakConfig: NexusWalletSdkConfig; frakClient: NexusClient | null; modalShare: () => void; + modalBuilder: ModalBuilder | null; }; } } diff --git a/packages/sdk/src/core/actions/index.ts b/packages/sdk/src/core/actions/index.ts index 0864ef788..2264fa240 100644 --- a/packages/sdk/src/core/actions/index.ts +++ b/packages/sdk/src/core/actions/index.ts @@ -2,6 +2,8 @@ export { watchWalletStatus } from "./watchWalletStatus"; export { sendInteraction } from "./sendInteraction"; export { displayModal } from "./displayModal"; export { openSso } from "./openSso"; +// Helper to track the purchase status +export { trackPurchaseStatus } from "./trackPurchaseStatus"; // Modal wrappers export { siweAuthenticate, @@ -11,7 +13,11 @@ export { sendTransaction, type SendTransactionParams, } from "./wrapper/sendTransaction"; -export { walletStatus } from "./wrapper/walletStatus"; +export { + modalBuilder, + type ModalStepBuilder, + type ModalBuilder, +} from "./wrapper/modalBuilder"; // Referral interaction export { referralInteraction } from "./referral/referralInteraction"; export { processReferral } from "./referral/processReferral"; diff --git a/packages/sdk/src/core/actions/referral/referralInteraction.ts b/packages/sdk/src/core/actions/referral/referralInteraction.ts index eaba9319f..a3488d62a 100644 --- a/packages/sdk/src/core/actions/referral/referralInteraction.ts +++ b/packages/sdk/src/core/actions/referral/referralInteraction.ts @@ -1,5 +1,5 @@ import type { Hex } from "viem"; -import { walletStatus } from "../"; +import { watchWalletStatus } from "../"; import type { DisplayModalParamsType, ModalStepTypes, @@ -29,7 +29,7 @@ export async function referralInteraction( }); // Get the current wallet status - const currentWalletStatus = await walletStatus(client); + const currentWalletStatus = await watchWalletStatus(client); try { return await processReferral(client, { diff --git a/packages/sdk/src/core/actions/trackPurchaseStatus.ts b/packages/sdk/src/core/actions/trackPurchaseStatus.ts new file mode 100644 index 000000000..66f6d1263 --- /dev/null +++ b/packages/sdk/src/core/actions/trackPurchaseStatus.ts @@ -0,0 +1,31 @@ +/** + * Function used to track the status of a purchase + */ +export async function trackPurchaseStatus(args: { + customerId: string | number; + orderId: string | number; + token: string; +}) { + if (typeof window === "undefined") { + console.warn("[Frak] No window found, can't track purchase"); + return; + } + const interactionToken = window.sessionStorage.getItem( + "frak-wallet-interaction-token" + ); + if (!interactionToken) { + console.warn("[Frak] No frak session found, skipping purchase check"); + return; + } + + // Submit the listening request + await fetch("https://backend.frak.id/interactions/listenForPurchase", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-wallet-sdk-auth": interactionToken, + }, + body: JSON.stringify(args), + }); +} diff --git a/packages/sdk/src/core/actions/watchWalletStatus.ts b/packages/sdk/src/core/actions/watchWalletStatus.ts index 93a6ecd03..4d7990387 100644 --- a/packages/sdk/src/core/actions/watchWalletStatus.ts +++ b/packages/sdk/src/core/actions/watchWalletStatus.ts @@ -1,5 +1,6 @@ import type { NexusClient } from "../types/client"; import type { WalletStatusReturnType } from "../types/rpc/walletStatus"; +import { Deferred } from "../utils"; /** * Function used to watch the current nexus wallet status @@ -8,12 +9,65 @@ import type { WalletStatusReturnType } from "../types/rpc/walletStatus"; */ export function watchWalletStatus( client: NexusClient, - callback: (status: WalletStatusReturnType) => void -) { - return client.listenerRequest( - { - method: "frak_listenToWalletStatus", - }, - callback - ); + callback?: (status: WalletStatusReturnType) => void +): Promise { + // If no callback is provided, just do a request with deferred result + if (!callback) { + return client + .request({ method: "frak_listenToWalletStatus" }) + .then((result) => { + // Save the potential interaction token + savePotentialToken(result.interactionToken); + + // Return the result + return result; + }); + } + + // Otherwise, listen to the wallet status and return the first one received + const firstResult = new Deferred(); + let hasResolved = false; + + // Start the listening request, and return the first result + return client + .listenerRequest( + { + method: "frak_listenToWalletStatus", + }, + (status) => { + // Transmit the status to the callback + callback(status); + + // Save the potential interaction token + savePotentialToken(status.interactionToken); + + // If the promise hasn't resolved yet, resolve it + if (!hasResolved) { + firstResult.resolve(status); + hasResolved = true; + } + } + ) + .then(() => firstResult.promise); +} + +/** + * Helper to save a potential interaction token + * @param interactionToken + */ +function savePotentialToken(interactionToken?: string) { + if (typeof window === "undefined") { + return; + } + + if (interactionToken) { + // If we got an interaction token, save it + window.sessionStorage.setItem( + "frak-wallet-interaction-token", + interactionToken + ); + } else { + // Otherwise, remove it + window.sessionStorage.removeItem("frak.interaction-token"); + } } diff --git a/packages/sdk/src/core/actions/wrapper/modalBuilder.ts b/packages/sdk/src/core/actions/wrapper/modalBuilder.ts new file mode 100644 index 000000000..7caf50cc1 --- /dev/null +++ b/packages/sdk/src/core/actions/wrapper/modalBuilder.ts @@ -0,0 +1,158 @@ +import type { + DisplayModalParamsType, + FinalActionType, + FinalModalStepType, + LoginModalStepType, + ModalRpcMetadata, + ModalRpcStepsResultType, + ModalStepTypes, + NexusClient, + OpenInteractionSessionModalStepType, + SendTransactionModalStepType, +} from "../../types"; +import { displayModal } from "../displayModal"; + +/** + * Represent the type of the modal step builder + */ +export type ModalStepBuilder< + Steps extends ModalStepTypes[] = ModalStepTypes[], +> = { + params: DisplayModalParamsType; + sendTx: ( + options: SendTransactionModalStepType["params"] + ) => ModalStepBuilder<[...Steps, SendTransactionModalStepType]>; + reward: ( + options?: Omit + ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; + sharing: ( + sharingOptions?: Extract< + FinalActionType, + { key: "sharing" } + >["options"], + options?: Omit + ) => ModalStepBuilder<[...Steps, FinalModalStepType]>; + display: () => Promise>; +}; + +/** + * Represent the output type of the modal builder + */ +export type ModalBuilder = ModalStepBuilder< + [LoginModalStepType, OpenInteractionSessionModalStepType] +>; + +/** + * Simple modal builder params builder + * @param client + * @param metadata + * @param login + * @param openSession + */ +export function modalBuilder( + client: NexusClient, + { + metadata, + login, + openSession, + }: { + metadata: ModalRpcMetadata; + login?: LoginModalStepType["params"]; + openSession?: OpenInteractionSessionModalStepType["params"]; + } +): ModalBuilder { + // Build the initial modal params + const baseParams: DisplayModalParamsType< + [LoginModalStepType, OpenInteractionSessionModalStepType] + > = { + steps: { + login: login ?? {}, + openSession: openSession ?? {}, + }, + metadata, + }; + + // Return the step builder + return modalStepsBuilder(client, baseParams); +} + +/** + * Build builder helping to add steps to the modal + * @param client + * @param params + */ +function modalStepsBuilder( + client: NexusClient, + params: DisplayModalParamsType +): ModalStepBuilder { + // Function add the send tx step + function sendTx(options: SendTransactionModalStepType["params"]) { + return modalStepsBuilder< + [...CurrentSteps, SendTransactionModalStepType] + >(client, { + ...params, + steps: { + ...params.steps, + sendTransaction: options, + }, + } as DisplayModalParamsType< + [...CurrentSteps, SendTransactionModalStepType] + >); + } + + // Function to add a reward step at the end + function reward(options?: Omit) { + return modalStepsBuilder<[...CurrentSteps, FinalModalStepType]>( + client, + { + ...params, + steps: { + ...params.steps, + final: { + ...options, + action: { key: "reward" }, + }, + }, + } as DisplayModalParamsType<[...CurrentSteps, FinalModalStepType]> + ); + } + + // Function to add sharing step at the end + function sharing( + sharingOptions?: Extract< + FinalActionType, + { key: "sharing" } + >["options"], + options?: Omit + ) { + return modalStepsBuilder<[...CurrentSteps, FinalModalStepType]>( + client, + { + ...params, + steps: { + ...params.steps, + final: { + ...options, + action: { key: "sharing", options: sharingOptions }, + }, + }, + } as DisplayModalParamsType<[...CurrentSteps, FinalModalStepType]> + ); + } + + // Function to display it + async function display() { + return await displayModal(client, params); + } + + return { + // Access current modal params + params, + // Function to add new steps + sendTx, + reward, + sharing, + // Display the modal + display, + }; +} diff --git a/packages/sdk/src/core/actions/wrapper/walletStatus.ts b/packages/sdk/src/core/actions/wrapper/walletStatus.ts deleted file mode 100644 index 52a6690b5..000000000 --- a/packages/sdk/src/core/actions/wrapper/walletStatus.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NexusClient, WalletStatusReturnType } from "../../types"; -import { Deferred } from "../../utils/Deferred"; -import { watchWalletStatus } from "../watchWalletStatus"; - -export async function walletStatus( - client: NexusClient, - callback?: (status: WalletStatusReturnType) => void -) { - // Our first result deferred - const firstResult = new Deferred(); - let hasResolved = false; - - // Setup the listener, with a callback request that will resolve the first result - await watchWalletStatus(client, (status) => { - callback?.(status); - - // If the promise hasn't resolved yet, resolve it - if (!hasResolved) { - firstResult.resolve(status); - hasResolved = true; - } - }); - - // Wait for the first response - return firstResult.promise; -} diff --git a/packages/sdk/src/react/hook/useWalletStatus.ts b/packages/sdk/src/react/hook/useWalletStatus.ts index d89535623..c6c2d6845 100644 --- a/packages/sdk/src/react/hook/useWalletStatus.ts +++ b/packages/sdk/src/react/hook/useWalletStatus.ts @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import type { WalletStatusReturnType } from "../../core"; -import { walletStatus } from "../../core/actions"; +import { watchWalletStatus } from "../../core/actions"; import { ClientNotFound } from "../../core/types/rpc/error"; import { useNexusClient } from "./useNexusClient"; @@ -37,7 +37,7 @@ export function useWalletStatus() { throw new ClientNotFound(); } - return walletStatus(client, newStatusUpdated); + return watchWalletStatus(client, newStatusUpdated); }, enabled: !!client, });