diff --git a/src/app.d.ts b/src/app.d.ts index f1dd96c..c9dedc2 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,9 +1,9 @@ /// -import type { StringPay } from "$lib/StringPay"; +import type { StringPay } from "$lib/sdk"; declare global { - interface Window { - StringPay: StringPay; - ethereum: any; - } + interface Window { + StringPay: StringPay; + ethereum: any; + } } diff --git a/src/lib/StringPay.ts b/src/lib/StringPay.ts deleted file mode 100644 index 67b4c86..0000000 --- a/src/lib/StringPay.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createServices, type Services } from "./services"; -import type { TransactionResponse } from "./services/apiClient.service"; - -export interface StringPayload { - assetName: string; - collection?: string; - price: string; - currency: string; - imageSrc: string; - imageAlt?: string; - chainID: number; - userAddress: string; - contractAddress: string; - contractFunction: string; - contractReturn: string; - contractParameters: string[]; - txValue: string; - gasLimit?: string; -} - -export type StringSDKEnvironment = "PROD" | "SANDBOX" | "DEV" | "LOCAL"; - -export interface StringOptions { - env: StringSDKEnvironment; - publicKey: string; - bypassDeviceCheck?: boolean; -} - -type StringEnvDetails = { - IFRAME_URL: string; - API_URL: string; -} - -const ENV_TABLE: Record = { - "PROD": { - IFRAME_URL: import.meta.env.PROD_IFRAME_URL, - API_URL: import.meta.env.PROD_API_URL, - }, - "SANDBOX": { - IFRAME_URL: import.meta.env.SBOX_IFRAME_URL, - API_URL: import.meta.env.SBOX_API_URL, - }, - "DEV": { - IFRAME_URL: import.meta.env.DEV_IFRAME_URL, - API_URL: import.meta.env.DEV_API_URL, - }, - "LOCAL": { - IFRAME_URL: import.meta.env.LOCAL_IFRAME_URL, - API_URL: import.meta.env.LOCAL_API_URL, - } -} - -const err = (msg: string) => { - console.error("[String Pay] " + msg); -}; - -export class StringPay { - isLoaded = false; - payload?: StringPayload; - frame?: HTMLIFrameElement; - container?: Element; - #services: Services; - private _IFRAME_URL: string; - - onFrameLoad: () => void; - onFrameClose: () => void; - onTxSuccess: (req: StringPayload, tx: TransactionResponse) => void; - onTxError: (req: StringPayload, txErr: any) => void; - - init(options: StringOptions) { - const envDetails = ENV_TABLE[options.env]; - if (!envDetails) { - return err(`Invalid environment: ${options.env}`); - } - - if (!options.publicKey) return err("You need an API key to use the String SDK"); - if (options.publicKey.slice(0, 4) !== "str.") return err(`Invalid API Key: ${options.publicKey}`); - - this._IFRAME_URL = envDetails.IFRAME_URL; - this.#services = createServices({ - baseUrl: envDetails.API_URL, - iframeUrl: envDetails.IFRAME_URL, - apiKey: options.publicKey, - bypassDeviceCheck: options.bypassDeviceCheck ?? false - }); - } - - async loadFrame(payload: StringPayload) { - // make sure there is a wallet connected - if (!window.ethereum || !window.ethereum.selectedAddress) return err("No wallet connected, please connect wallet"); - - const container = document.querySelector(".string-pay-frame"); - if (!container) return err("Unable to load String Frame, element 'string-pay-frame' does not exist"); - - // Clear out any existing children - while (container.firstChild) { - container.removeChild(container.firstChild); - } - - // Validate payload - if (!payload) return err("No payload specified"); - if (!payload.userAddress) return err("No user address found, please connect wallet"); - if (!this._IFRAME_URL) return err("IFRAME_URL not specified. Did you call init()?"); - if (!this.#services) return err("Services not initialized. Did you call init()?"); - - // Set payload - this.payload = payload; - - // Create iframe in dom - const iframe = document.createElement("iframe"); - iframe.style.width = "100vh"; - iframe.style.height = "900px"; - iframe.style.overflow = "none"; - iframe.src = this._IFRAME_URL; - - container.appendChild(iframe); - this.container = container; - this.frame = iframe; - - // set the default gas limit - this.payload.gasLimit = "8000000"; // TODO: Do we want this value to change dynamically? - - this.#services.eventsService.unregisterEvents(); - this.#services.eventsService.registerEvents(); - } -} - -function main() { - // Expose the StringPay instance to the window - window.StringPay = new StringPay(); -} - -main(); diff --git a/src/lib/StringPayButton.svelte b/src/lib/StringPayButton.svelte index ce6baf5..4598c36 100644 --- a/src/lib/StringPayButton.svelte +++ b/src/lib/StringPayButton.svelte @@ -1,34 +1,39 @@ - - + \ No newline at end of file + .btn[disabled] { + background-color: #b6d5ec; + color: #faf9f9; + } + diff --git a/src/lib/index.ts b/src/lib/index.ts index d301b7f..cdc9249 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,4 +1,3 @@ -import StringPayButton from './StringPayButton.svelte'; -import type { StringPayload } from './StringPay'; +import StringPayButton from "./StringPayButton.svelte"; -export { StringPayButton, type StringPayload }; \ No newline at end of file +export { StringPayButton }; diff --git a/src/lib/sdk/actions.ts b/src/lib/sdk/actions.ts new file mode 100644 index 0000000..4925302 --- /dev/null +++ b/src/lib/sdk/actions.ts @@ -0,0 +1,79 @@ +import type { Config } from "./config"; +import type { StringIframe } from "./iframe/common"; +import type { Services } from "./services"; +import type { ExecutionRequest, Quote } from "./services/apiClient.service"; + +export interface StringPayActions { + authorizeUser(): Promise; + submitCard(): Promise; + getQuote(): Promise; + subscribeToQuote(callback: (quote: Quote) => void): void; + unsubscribeFromQuote(callback: (quote: Quote) => void): void; + subscribeTo: (eventName: string, callback: (event: any) => void) => void; + unsubscribeFrom: (eventName: string, callback: (event: any) => void) => void; + setStyle(style: any): Promise; +} + +export function createActions(iframe: StringIframe, config: Config, services: Services): StringPayActions { + const events = services.events; + + async function authorizeUser() { + const walletAddress = await services.wallet.getWalletAddress(); + return services.auth.authorizeUser(walletAddress); + } + + /** + * Notify the payment iframe to submit the card details + */ + async function submitCard() { + return new Promise((resolve, reject) => { + iframe.submitCard(); + + events.once(events.CARD_TOKENIZED, (token: string) => { + return resolve(token); + }); + + events.once(events.CARD_TOKENIZE_FAILED, (error) => { + return reject(error); + }); + }); + } + + async function setStyle(style: any) { + iframe.setStyle(); + } + + async function getQuote() { + const payload = config.payload; + services.quote.getQuote(payload); + } + + function subscribeToQuote(callback: (quote: Quote) => void) { + const payload = config.payload; + services.quote.startQuote(payload, callback); + } + + function unsubscribeFromQuote() { + services.quote.stopQuote(); + } + + function subscribeTo(eventName: string, callback: (data: any) => void) { + events.on(eventName, callback); + } + + function unsubscribeFrom(eventName: string) { + // TODOX + // events.off(eventName); + } + + return { + authorizeUser, + getQuote, + subscribeToQuote, + unsubscribeFromQuote, + subscribeTo, + unsubscribeFrom, + submitCard, + setStyle, + }; +} diff --git a/src/lib/sdk/config.ts b/src/lib/sdk/config.ts new file mode 100644 index 0000000..b440ba2 --- /dev/null +++ b/src/lib/sdk/config.ts @@ -0,0 +1,92 @@ +const commonConfig = { + bypassDeviceCheck: false, + analyticsSubdomainUrl: "https://metrics.string.xyz", + analyticsPublicKey: "", +}; + +const defaultConfigs: Record = { + PROD: { + ...commonConfig, + apiUrl: "https://api.string-api.xyz", + checkoutIframeUrl: "https://iframe.string-api.xyz", + paymentIframeUrl: "https://payment-iframe.string-api.xyz/?env=prod&appType=web", + }, + SANDBOX: { + ...commonConfig, + apiUrl: "https://api.sandbox.string-api.xyz", + checkoutIframeUrl: "https://iframe-app.dev.string-api.xyz", + paymentIframeUrl: "https://payment-iframe.string-api.xyz/?env=dev&appType=web", + }, + DEV: { + ...commonConfig, + apiUrl: "https://string-api.dev.string-api.xyz", + checkoutIframeUrl: "https://iframe-app.dev.string-api.xyz", + paymentIframeUrl: "https://payment-iframe.string-api.xyz/?env=dev&appType=web", + }, + LOCAL: { + ...commonConfig, + apiUrl: "http://localhost:4444", + checkoutIframeUrl: "http://localhost:4040", + paymentIframeUrl: "http://localhost:4041?env=dev&appType=web", + }, +}; + +export function createConfig(options: UserOptions): Config { + const defaultConfig = defaultConfigs[options.env]; + + if (!defaultConfig) { + throw new Error(`Invalid environment: ${options.env}`); + } + + const config: Config = { + ...defaultConfig, + apiKeyPublic: options.apiKeyPublic, + payload: options.payload, + bypassDeviceCheck: options.bypassDeviceCheck || false, + }; + + if (!config.payload.gasLimit) { + config.payload.gasLimit = "8000000"; + } + + return config; +} + +export type DefaultConfig = { + apiUrl: string; + checkoutIframeUrl: string; + paymentIframeUrl: string; + bypassDeviceCheck: boolean; +}; + +export interface UserOptions { + env: Environment; + apiKeyPublic: string; + bypassDeviceCheck?: boolean; + payload: StringPayload; +} + +/* Extended config with user options */ +export interface Config extends DefaultConfig { + apiKeyPublic: string; + payload: StringPayload; +} + +export interface StringPayload { + assetName: string; + collection?: string; + price: string; + currency: string; + imageSrc: string; + imageAlt?: string; + chainID: number; + userAddress: string; + contractAddress: string; + contractFunction: string; + contractReturn: string; + contractParameters: string[]; + txValue: string; + gasLimit?: string; +} + +export type Environment = "PROD" | "SANDBOX" | "DEV" | "LOCAL"; diff --git a/src/lib/sdk/iframe/checkout/eventHandlers.ts b/src/lib/sdk/iframe/checkout/eventHandlers.ts new file mode 100644 index 0000000..849ed34 --- /dev/null +++ b/src/lib/sdk/iframe/checkout/eventHandlers.ts @@ -0,0 +1,190 @@ +import type { Services } from "$lib/sdk/services"; +import type { ExecutionRequest, Quote, TransactionRequest, UserUpdate } from "$lib/sdk/services/apiClient.service"; +import type { Config } from "$lib/sdk/config"; +import type { EventHandlers, IframeEvent, IframeEventSender } from "../common"; +import { createCheckoutIframePayload } from "./utils"; + +export function createEventHandlers(iframeElement: HTMLIFrameElement, eventSender: IframeEventSender, config: Config, services: Services): EventHandlers { + const send = eventSender.sendEvent; + const events = services.events; + + const eventToHandlersMap: EventHandlers = { + ready: onIframeReady, + close: onIframeClose, + resize: onIframeResize, + authorize_user: onAuthorizeUser, + update_user: onUpdateUser, + email_verification: onEmailVerification, + email_preview: onEmailPreview, + device_verification: onDeviceVerification, + saved_cards: onRequestSavedCards, + get_quote: onGetQuote, + confirm_transaction: onConfirmTransaction, + }; + + async function onIframeReady(reqEvent: IframeEvent): Promise { + console.log(">> Ready event: ", reqEvent); + + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const user = await services.auth.fetchLoggedInUser(config.payload.userAddress); + const iframePayload = createCheckoutIframePayload(config.payload, user); + + // stringPay.isLoaded = true; + events.propagate(events.IFRAME_LOADED, "string-payment-iframe"); + + resEvent.data = iframePayload; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onIframeClose(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + services.quote.stopQuote(); + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onIframeResize(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + if (reqEvent.data?.height != iframeElement.scrollHeight) { + iframeElement.style.height = (reqEvent.data?.height ?? iframeElement.scrollHeight) + "px"; + } + + return send(resEvent); + } + + async function onAuthorizeUser(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const { user } = await services.auth.authorizeUser(config.payload.userAddress); + + resEvent.data = user; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onUpdateUser(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const data = <{ userId: string; update: UserUpdate }>reqEvent.data; + const user = await services.auth.updateUser(data.userId, data.update); + + resEvent.data = user; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onEmailVerification(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const data = <{ userId: string; email: string }>resEvent.data; + const status = await services.auth.emailVerification(data.userId, data.email); + + resEvent.data = status; + } catch (error: any) { + resEvent.error = error; + } + + return send(resEvent); + } + + async function onDeviceVerification(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const data = <{ walletAddress: string }>reqEvent.data; + const status = await services.auth.deviceVerification(data.walletAddress); + + resEvent.data = status; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onEmailPreview(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const data = <{ walletAddress: string }>reqEvent.data; + const email = await services.auth.emailPreview(data.walletAddress); + + resEvent.data = email; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onRequestSavedCards(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const cards = await services.apiClient.getSavedCards(); + + resEvent.data = cards; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onGetQuote(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const quotePayload = config.payload; + const quote = await services.apiClient.getQuote(quotePayload); + + resEvent.data = quote; + } catch (e: any) { + resEvent.error = e; + } + + return send(resEvent); + } + + async function onConfirmTransaction(reqEvent: IframeEvent) { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + try { + const data = reqEvent.data; + + const txRes = await services.apiClient.transact(data); + resEvent.data = txRes; + + // propagate event to the service layer + services.events.propagate(services.events.TX_SUCCESS, txRes); + } catch (e: any) { + resEvent.error = e; + services.events.propagate(services.events.TX_ERROR, e); + } + + return send(resEvent); + } + + return eventToHandlersMap; +} diff --git a/src/lib/sdk/iframe/checkout/index.ts b/src/lib/sdk/iframe/checkout/index.ts new file mode 100644 index 0000000..d286122 --- /dev/null +++ b/src/lib/sdk/iframe/checkout/index.ts @@ -0,0 +1,65 @@ +import type { Services } from "$lib/sdk/services"; +import type { Config } from "../../config"; +import { createIframeEventListener, createIframeEventSender, type IframeEventListener, type StringIframe } from "../common"; +import { createEventHandlers } from "./eventHandlers"; + +export function createCheckoutIframe(config: Config, services: Services): StringIframe { + console.log("create checkout iframe"); + const eventChannel = "string-checkout-frame"; + let eventListener: IframeEventListener | undefined; + + const container = document.querySelector(".string-checkout-frame"); + const iframeElement = document.createElement("iframe"); + iframeElement.style.width = "100vh"; + iframeElement.style.height = "900px"; + iframeElement.style.overflow = "none"; + iframeElement.src = config.checkoutIframeUrl; + + const eventSender = createIframeEventSender(eventChannel, iframeElement); + + function load(): HTMLIFrameElement { + if (!container) throw new Error("Unable to load String Frame, element 'string-pay-frame' does not exist"); + + // Clear out any existing children + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + container.appendChild(iframeElement); + + _initEvents(); + + return iframeElement; + } + + function destroy() { + if (!container) throw new Error("Unable to load String Frame, element 'string-pay-frame' does not exist"); + + // Clear out any existing children + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + if (eventListener) eventListener.stopListening(); + } + + function _initEvents() { + /* a iframe to sdk events protocol is composed of 3 components: + * sender: send events to the iframe + * handlers: handle events from the iframe + * listeners: receive iframe events and map them to handler functions + * */ + const handlers = createEventHandlers(iframeElement, eventSender, config, services); + const listener = createIframeEventListener(eventChannel, iframeElement, handlers); + listener.startListening(); + + eventListener = listener; + } + + return { + load, + destroy, + submitCard: async () => {}, + setStyle: async () => {}, + }; +} diff --git a/src/lib/sdk/iframe/checkout/utils.ts b/src/lib/sdk/iframe/checkout/utils.ts new file mode 100644 index 0000000..e4e9690 --- /dev/null +++ b/src/lib/sdk/iframe/checkout/utils.ts @@ -0,0 +1,43 @@ +import type { User } from "$lib/sdk/services/apiClient.service"; +import type { StringPayload } from "../../config"; + +// Parse payload before sending it to the iframe +export function createCheckoutIframePayload(payload: StringPayload, user: User | null): CheckoutIframePayload { + const nft: NFT = { + assetName: payload.assetName, + price: payload.price, + currency: payload.currency, + collection: payload.collection ?? "", + imageSrc: payload.imageSrc, + imageAlt: payload?.imageAlt ?? "NFT", + }; + + return { + nft, + user: { + walletAddress: payload.userAddress, + id: user?.id ?? "", + status: user?.status ?? "", + email: user?.email ?? "", + }, + }; +} + +export interface NFT { + assetName: string; + price: string; + currency: string; + collection?: string; + imageSrc: string; + imageAlt?: string; +} + +export interface CheckoutIframePayload { + nft: NFT; + user: { + id: string; + walletAddress: string; + status: string; + email: string; + }; +} diff --git a/src/lib/sdk/iframe/common/IframeEventListener.ts b/src/lib/sdk/iframe/common/IframeEventListener.ts new file mode 100644 index 0000000..63d2375 --- /dev/null +++ b/src/lib/sdk/iframe/common/IframeEventListener.ts @@ -0,0 +1,40 @@ +import type { IframeEvent, EventHandlers } from "./types"; + +export function createIframeEventListener(channel: string, frame: HTMLIFrameElement, handlers: EventHandlers): IframeEventListener { + async function startListening() { + stopListening(); // remove previous listeners to prevent duplicates + + window.addEventListener("message", _handleEvent); + } + + async function stopListening() { + window.removeEventListener("message", _handleEvent); + } + + async function _handleEvent(e: any) { + if (new URL(e.origin).host !== new URL(frame.src).host) return; + + try { + const payload = JSON.parse(e.data); + if (payload.channel === channel) { + const event = payload.data; + const handler = handlers[event.eventName]; + + if (handler) await handler(event); + else console.debug("SDK :: Unhandled event: ", event); + } + } catch (error) { + console.error("string-sdk: error handling event: ", error); + } + } + + return { + startListening, + stopListening, + }; +} + +export interface IframeEventListener { + startListening(): Promise; + stopListening(): Promise; +} diff --git a/src/lib/sdk/iframe/common/IframeEventSender.ts b/src/lib/sdk/iframe/common/IframeEventSender.ts new file mode 100644 index 0000000..35a6fdc --- /dev/null +++ b/src/lib/sdk/iframe/common/IframeEventSender.ts @@ -0,0 +1,38 @@ +import type { IframeEvent } from "./types"; + +export function createIframeEventSender(channel: string, frame: HTMLIFrameElement): IframeEventSender { + /* ---------------------------------------------------------------- */ + + function sendData(eventName: string, data: T) { + const event: IframeEvent = { eventName, data }; + return sendEvent(event); + } + + function sendError(eventName: string, error: any) { + const event: IframeEvent = { eventName, error }; + return sendEvent(event); + } + + function sendEvent(event: IframeEvent) { + const message = JSON.stringify({ + channel, + data: event, + }); + + frame.contentWindow?.postMessage(message, "*"); + + return event; + } + + return { + sendData, + sendError, + sendEvent, + }; +} + +export interface IframeEventSender { + sendData(eventName: string, data: T): IframeEvent; // returns the event sent + sendError(eventName: string, error: any): IframeEvent; // returns the event sent + sendEvent(event: IframeEvent): IframeEvent; // returns the event sent +} diff --git a/src/lib/sdk/iframe/common/index.ts b/src/lib/sdk/iframe/common/index.ts new file mode 100644 index 0000000..eedf66f --- /dev/null +++ b/src/lib/sdk/iframe/common/index.ts @@ -0,0 +1,3 @@ +export * from "./IframeEventListener"; +export * from "./IframeEventSender"; +export * from "./types"; diff --git a/src/lib/sdk/iframe/common/types.ts b/src/lib/sdk/iframe/common/types.ts new file mode 100644 index 0000000..8055708 --- /dev/null +++ b/src/lib/sdk/iframe/common/types.ts @@ -0,0 +1,20 @@ +/** + * @param {IframeEvent} reqEvent The event request from the iframe + * @returns {IframeEvent} The response event to be sent + */ +export type Handler = (reqEvent: IframeEvent) => Promise; + +export type EventHandlers = Record; + +export interface IframeEvent { + eventName: string; + data?: T; + error?: any; +} + +export interface StringIframe { + load: () => HTMLIFrameElement; + destroy: () => void; + submitCard: () => Promise; + setStyle: (style: string) => void; +} diff --git a/src/lib/sdk/iframe/index.ts b/src/lib/sdk/iframe/index.ts new file mode 100644 index 0000000..cf0d8eb --- /dev/null +++ b/src/lib/sdk/iframe/index.ts @@ -0,0 +1,22 @@ +import type { Config } from "../config"; +import type { Services } from "../services"; +import { createCheckoutIframe } from "./checkout"; +import type { StringIframe } from "./common"; +import { createPaymentIframe } from "./payment"; + +export function createIframe(config: Config, services: Services): StringIframe { + const checkoutIframeElement = document.querySelector(".string-checkout-frame"); + const paymentIframeElement = document.querySelector(".string-payment-frame"); + + if (!checkoutIframeElement && !paymentIframeElement) throw new Error(".string-checkout-frame or .string-payment-frame has not been found id dom"); + + if (checkoutIframeElement) { + const stringIframe = createCheckoutIframe(config, services); + return stringIframe; + } + + const stringIframe = createPaymentIframe(config, services); + return stringIframe; +} + +export type * from "./checkout"; diff --git a/src/lib/sdk/iframe/payment/eventHandlers.ts b/src/lib/sdk/iframe/payment/eventHandlers.ts new file mode 100644 index 0000000..7b53151 --- /dev/null +++ b/src/lib/sdk/iframe/payment/eventHandlers.ts @@ -0,0 +1,39 @@ +import type { Services } from "$lib/sdk/services"; +import type { Config } from "../../config"; +import type { EventHandlers, IframeEvent, IframeEventSender } from "../common"; + +export function createEventHandlers(eventSender: IframeEventSender, config: Config, services: Services): EventHandlers { + const send = eventSender.sendEvent; + const events = services.events; + + const eventToHandlersMap: EventHandlers = { + iframe_loaded: onIframeLoaded, + card_tokenized: onCardTokenized, + fingerprint: onFingerprint, + card_tokenize_failed: onCardTokenizeFailed, + card_vendor_changed: onCardVendorChanged, + card_validation_changed: onCardValidationChanged, + }; + + async function onIframeLoaded(reqEvent: IframeEvent): Promise { + const resEvent: IframeEvent = { eventName: "res_" + reqEvent.eventName }; + + events.propagate(events.IFRAME_LOADED, "string-payment-iframe"); + return send(resEvent); + } + + async function onFingerprint(reqEvent: IframeEvent): Promise { + const visitorData = reqEvent.data; + services.location.setCachedVisitorData(visitorData); + } + + async function onCardTokenizeFailed(reqEvent: IframeEvent): Promise { + events.propagate(events.CARD_TOKENIZE_FAILED, reqEvent.data); + } + + async function onCardVendorChanged(reqEvent: IframeEvent): Promise { + events.propagate(events.CARD_VENDOR_CHANGED, reqEvent.data); + } + + return eventToHandlersMap; +} diff --git a/src/lib/sdk/iframe/payment/index.ts b/src/lib/sdk/iframe/payment/index.ts new file mode 100644 index 0000000..dfa3682 --- /dev/null +++ b/src/lib/sdk/iframe/payment/index.ts @@ -0,0 +1,65 @@ +import type { Services } from "$lib/sdk/services"; +import type { Config } from "../../config"; +import { createIframeEventListener, createIframeEventSender, type IframeEventListener, type StringIframe } from "../common"; +import { createEventHandlers } from "./eventHandlers"; + +export function createPaymentIframe(config: Config, services: Services): StringIframe { + const eventChannel = "STRING_PAY"; + + const container = document.querySelector(".string-payment-frame"); + const element = document.createElement("iframe"); + element.style.width = "100vh"; + element.style.height = "900px"; + element.style.overflow = "none"; + element.src = config.paymentIframeUrl; + + /* An iframe to sdk events protocol is composed of 3 components: + * sender: send events to the iframe + * handlers: handle events from the iframe + * listeners: receive iframe events and map them to handler functions + * */ + const eventSender = createIframeEventSender(eventChannel, element); + const handlers = createEventHandlers(eventSender, config, services); + const eventListener = createIframeEventListener(eventChannel, element, handlers); + + function load(): HTMLIFrameElement { + if (!container) throw new Error("Unable to load String Frame, element 'string-pay-frame' does not exist"); + + // Clear out any existing children + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + container.appendChild(element); + eventListener.startListening(); + + return element; + } + + function destroy() { + if (!container) throw new Error("Unable to load String Frame, element 'string-pay-frame' does not exist"); + + // Clear out any existing children + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + if (eventListener) eventListener.stopListening(); + } + + async function submitCard() { + eventSender.sendData("submit_card", {}); + } + + async function setStyle(style: any) { + // TODOX: define style interface to pass to the iframe + eventSender.sendData("set_style", style); + } + + return { + load, + destroy, + submitCard, + setStyle, + }; +} diff --git a/src/lib/sdk/index.ts b/src/lib/sdk/index.ts new file mode 100644 index 0000000..a338dfc --- /dev/null +++ b/src/lib/sdk/index.ts @@ -0,0 +1,32 @@ +import { createConfig, type UserOptions } from "./config"; +import { createServices } from "$lib/sdk/services"; +import { createIframe } from "./iframe"; +import { createActions, type StringPayActions } from "./actions"; + +export function init(options: UserOptions): StringPay { + const config = createConfig(options); + + // run some checks + if (!options.apiKeyPublic || !options.apiKeyPublic.startsWith("str.")) throw new Error("Invalid or missing API Key"); + + const services = createServices(config); + + if (!config.bypassDeviceCheck) { + // if enabled, init location service as soon as possible + services.location.getFPInstance().catch((e) => console.debug("getFPInstance error: ", e)); + } + + const iframe = createIframe(config, services); + const actions = createActions(iframe, config, services); + + return { + ...actions, + loadIframe: iframe.load, + }; +} + +export interface StringPay extends StringPayActions { + loadIframe: () => HTMLIFrameElement; +} + +export type * from "./config"; diff --git a/src/lib/services/apiClient.service.ts b/src/lib/sdk/services/apiClient.service.ts similarity index 96% rename from src/lib/services/apiClient.service.ts rename to src/lib/sdk/services/apiClient.service.ts index 3dbf429..b1f4761 100644 --- a/src/lib/services/apiClient.service.ts +++ b/src/lib/sdk/services/apiClient.service.ts @@ -2,7 +2,7 @@ import axios from "redaxios"; // TODO: Fix timeout issue -export function createApiClient({ baseUrl, apiKey }: ApiClientOptions): ApiClient { +export function createApiClient(baseUrl: string, apiKey: string): ApiClient { let _userWalletAddress = ""; const commonHeaders: any = { @@ -188,10 +188,10 @@ export function createApiClient({ baseUrl, apiKey }: ApiClientOptions): ApiClien fingerprint: { visitorId: "", requestId: "", - } - } + }, + }; try { - const { data } = await httpClient.post<{email: string}>(`/users/preview-email`, body, { + const { data } = await httpClient.post<{ email: string }>(`/users/preview-email`, body, { headers: authHeaders, }); @@ -211,7 +211,7 @@ export function createApiClient({ baseUrl, apiKey }: ApiClientOptions): ApiClien throw error; } } - + async function getQuote(payload: ExecutionRequest) { try { const request = () => httpClient.post(`/quotes`, payload); @@ -393,14 +393,14 @@ export interface TransactionResponse { } export interface SavedCardResponse { - type: string; - id: string; - scheme: string; - last4: string; - expiryMonth: number; - expiryYear: number; - expired: boolean; - cardType: string; + type: string; + id: string; + scheme: string; + last4: string; + expiryMonth: number; + expiryYear: number; + expired: boolean; + cardType: string; } export interface ApiClientOptions { diff --git a/src/lib/sdk/services/auth.service.ts b/src/lib/sdk/services/auth.service.ts new file mode 100644 index 0000000..8b5e9dc --- /dev/null +++ b/src/lib/sdk/services/auth.service.ts @@ -0,0 +1,188 @@ +import type { ApiClient, User, UserUpdate } from "./apiClient.service"; +import type { LocationService, VisitorData } from "./location.service"; + +export function createAuthService({ apiClient, locationService, bypassDeviceCheck }: AuthServiceParams): AuthService { + let emailCheckInterval: NodeJS.Timer | undefined; + let deviceCheckInterval: NodeJS.Timer | undefined; + const previousAttempt = { signature: "", nonce: "" }; + + const authorizeUser = async (walletAddress: string) => { + const { nonce } = await apiClient.requestLogin(walletAddress); + const signature = await requestSignature(walletAddress, nonce); + const visitorData = await locationService.getVisitorData(); + + // is there a better way to do this? I'm concerned about keeping this state in memory and not in local storage + previousAttempt.nonce = nonce; + previousAttempt.signature = signature; + + try { + const data = await apiClient.createUser(nonce, signature, visitorData); + return data; + } catch (err: any) { + // if user already exists, try to login + if (err.code === "CONFLICT") return apiClient.loginUser(nonce, signature, visitorData, bypassDeviceCheck); + throw err; + } + }; + + const getPreviousSignature = async () => { + // TODO: Use refresh token instead + // TODO: Modify refresh token endpoint to verify visitor data + if (!previousAttempt.signature) throw { code: "UNAUTHORIZED" }; + + const visitorData = await locationService.getVisitorData(); + + if (!visitorData) throw new Error("cannot get device data"); + + return { nonce: previousAttempt.nonce, signature: previousAttempt.signature, visitor: visitorData }; + }; + + /** + * Prompts the user to sign a message using their wallet + * @param userAddress - The user's wallet address + * @param encodedMessage - The nonce encoded in base64 + * @returns The signature of the message + */ + const requestSignature = async (userAddress: string, encodedMessage: string) => { + try { + const message = window.atob(encodedMessage); + const signature = await window.ethereum.request({ + method: "personal_sign", + params: [message, userAddress], + }); + + return signature; + } catch (error) { + console.log("SDK :: Wallet signature error: ", error); + } + }; + + const fetchLoggedInUser = async (walletAddress: string) => { + try { + const { user } = await apiClient.refreshToken(walletAddress); + return user; + } catch (e: any) { + return null; + } + }; + + const emailVerification = async (userId: string, email: string) => { + try { + await apiClient.requestEmailVerification(userId, email); + + clearInterval(emailCheckInterval); + emailCheckInterval = setInterval(async () => { + const { status } = await apiClient.getUserStatus(userId); + if (status == "email_verified") { + clearInterval(emailCheckInterval); + return status; + } + }, 5000); + } catch (error: any) { + return error; + } + }; + + const emailPreview = async (walletAddress: string) => { + try { + let nonce: string; + let signature: string; + + const previous = await getPreviousSignature(); + nonce = previous.nonce; + signature = previous.signature; + + if (!previous.signature) { + nonce = (await apiClient.requestLogin(walletAddress)).nonce; + signature = await requestSignature(walletAddress, nonce); + } + + const { email } = await apiClient.getUserEmailPreview(nonce, signature); + return email; + } catch (error: any) { + return error; + } + }; + + const deviceVerification = async (walletAddress: string) => { + try { + let nonce: string; + let signature: string; + + const previous = await getPreviousSignature(); + nonce = previous.nonce; + signature = previous.signature; + + if (!previous.signature) { + nonce = (await apiClient.requestLogin(walletAddress)).nonce; + signature = await requestSignature(walletAddress, nonce); + } + + await apiClient.requestDeviceVerification(nonce, signature, previous.visitor); + + clearInterval(deviceCheckInterval); + deviceCheckInterval = setInterval(async () => { + const { status } = await apiClient.getDeviceStatus(nonce, signature, previous.visitor); + if (status == "verified") { + clearInterval(deviceCheckInterval); + return status; + } + }, 5000); + } catch (error: any) { + return error; + } + }; + + const updateUser = async (userId: string, userUpdate: UserUpdate) => { + try { + await apiClient.updateUser(userId, userUpdate); + } catch (err: any) { + return err; + } + }; + + const logout = async () => { + try { + await apiClient.logoutUser(); + } catch (err: any) { + return err; + } + }; + + const cleanup = () => { + clearInterval(emailCheckInterval); + clearInterval(deviceCheckInterval); + }; + + return { + authorizeUser, + emailVerification, + emailPreview, + deviceVerification, + fetchLoggedInUser, + requestSignature, + getPreviousSignature, + updateUser, + logout, + cleanup, + }; +} + +export interface AuthService { + authorizeUser: (walletAddress: string) => Promise; + fetchLoggedInUser: (walletAddress: string) => Promise; + requestSignature: (userAddress: string, encodedMessage: string) => Promise; + getPreviousSignature: () => Promise<{ nonce: string; signature: string; visitor: VisitorData }>; + emailVerification: (userId: string, email: string) => Promise; + emailPreview: (walletAddress: string) => Promise; + deviceVerification: (walletAddress: string) => Promise; + updateUser: (userId: string, userUpdate: UserUpdate) => Promise; + logout: () => Promise; + cleanup: () => void; +} + +export interface AuthServiceParams { + apiClient: ApiClient; + locationService: LocationService; + bypassDeviceCheck: boolean; +} diff --git a/src/lib/sdk/services/events.service.ts b/src/lib/sdk/services/events.service.ts new file mode 100644 index 0000000..da3820c --- /dev/null +++ b/src/lib/sdk/services/events.service.ts @@ -0,0 +1,24 @@ +import { EventEmitter } from "../utils/EventEmitter"; + +export enum Events { + IFRAME_LOADED = "iframe_loaded", + CARD_TOKENIZED = "card_tokenized", + CARD_TOKENIZE_FAILED = "card_tokenize_failed", + TX_SUCCESS = "tx_success", + TX_ERROR = "tx_error", +} + +export function createEventsService() { + const emitter = new EventEmitter(); + + const on = emitter.on.bind(emitter); + const once = emitter.once.bind(emitter); + const propagate = emitter.emit.bind(emitter); + + return { + ...Events, + on, + once, + propagate, + }; +} diff --git a/src/lib/sdk/services/index.ts b/src/lib/sdk/services/index.ts new file mode 100644 index 0000000..b554afd --- /dev/null +++ b/src/lib/sdk/services/index.ts @@ -0,0 +1,43 @@ +import type { Config } from "../config"; +import { createApiClient } from "./apiClient.service"; +import { createLocationService } from "./location.service"; +import { createAuthService } from "./auth.service"; +import { createQuoteService } from "./quote.service"; +import { createWalletService } from "./wallet.service"; +import { createEventsService } from "./events.service"; + +export function createServices(config: Config): Services { + const apiClient = createApiClient(config.apiUrl, config.apiKeyPublic); + + const events = createEventsService(); + const locationService = createLocationService(); + const auth = createAuthService({ apiClient, locationService, bypassDeviceCheck: config.bypassDeviceCheck }); + const quote = createQuoteService(apiClient); + const wallet = createWalletService(apiClient); + + return { + events, + apiClient, + location: locationService, + auth, + quote, + wallet, + }; +} + +export interface ServiceParams { + baseUrl: string; + iframeUrl: string; + apiKey: string; + bypassDeviceCheck?: boolean; +} + +// services interface +export interface Services { + events: ReturnType; + apiClient: ReturnType; + location: ReturnType; + auth: ReturnType; + quote: ReturnType; + wallet: ReturnType; +} diff --git a/src/lib/sdk/services/location.service.ts b/src/lib/sdk/services/location.service.ts new file mode 100644 index 0000000..8caead5 --- /dev/null +++ b/src/lib/sdk/services/location.service.ts @@ -0,0 +1,71 @@ +import * as FingerprintJS from "@fingerprintjs/fingerprintjs-pro"; + +const CUSTOM_SUBDOMAIN = "https://metrics.string.xyz"; +const apiKey = import.meta.env.VITE_ANALYTICS_LIB_PK || ""; + +export function createLocationService(options = {}): LocationService { + let fpInstance: FingerprintJS.Agent | undefined; + let cachedData: VisitorData | undefined; + + async function getFPInstance() { + const loadOptions = { + apiKey, + endpoint: [ + CUSTOM_SUBDOMAIN, // This endpoint will be used primarily + FingerprintJS.defaultEndpoint, // The default endpoint will be used if the primary fails + ], + ...options, + }; + + if (!fpInstance) { + fpInstance = await FingerprintJS.load(loadOptions); + } + + return fpInstance; + } + + /** + * @param options extendedResult: boolean - if true, the result will contain additional data + * @returns VisitorData if the request was successful, undefined otherwise + */ + async function getVisitorData(options = { extendedResult: true }) { + try { + const fp = await getFPInstance(); + return await fp.get(options); + } catch (e) { + console.debug("analytics service error:", e); + return; + } + } + + async function getCachedVisitorData() { + if (!cachedData) { + cachedData = await getVisitorData(); + } + + return cachedData; + } + + function setCachedVisitorData(visitorData: VisitorData) { + cachedData = visitorData; + } + + return { + getFPInstance, + getVisitorData, + setCachedVisitorData, + getCachedVisitorData, + }; +} + +export interface VisitorData { + visitorId?: string; + requestId?: string; +} + +export interface LocationService { + getFPInstance: () => Promise; + getVisitorData: (options?: { extendedResult: boolean }) => Promise; + getCachedVisitorData: () => Promise; + setCachedVisitorData: (visitorData: VisitorData) => void; +} diff --git a/src/lib/sdk/services/quote.service.ts b/src/lib/sdk/services/quote.service.ts new file mode 100644 index 0000000..bbf36e7 --- /dev/null +++ b/src/lib/sdk/services/quote.service.ts @@ -0,0 +1,45 @@ +import type { ApiClient, Quote, ExecutionRequest } from "./apiClient.service"; + +export function createQuoteService(apiClient: ApiClient): QuoteService { + let interval: NodeJS.Timer | undefined; + + async function getQuote(payload: ExecutionRequest) { + return apiClient.getQuote(payload); + } + + async function startQuote(payload: ExecutionRequest, callback: (quote: Quote) => void) { + _refreshQuote(payload, callback); + + if (interval) { + clearInterval(interval); + } + + interval = setInterval(() => _refreshQuote(payload, callback), 10000); + } + + function stopQuote() { + clearInterval(interval); + interval = undefined; + } + + async function _refreshQuote(payload: ExecutionRequest, callback: (quote: Quote) => void) { + try { + const quote = await apiClient.getQuote(payload); + callback(quote); + } catch (err: any) { + console.debug("-- refresh quote error --", err); + } + } + + return { + getQuote, + startQuote, + stopQuote, + }; +} + +export interface QuoteService { + getQuote: (payload: ExecutionRequest) => Promise; + startQuote: (payload: ExecutionRequest, callback: (quote: Quote) => void) => Promise; + stopQuote: () => void; +} diff --git a/src/lib/sdk/services/wallet.service.ts b/src/lib/sdk/services/wallet.service.ts new file mode 100644 index 0000000..74af1a7 --- /dev/null +++ b/src/lib/sdk/services/wallet.service.ts @@ -0,0 +1,29 @@ +import type { ApiClient } from "./apiClient.service"; + +export function createWalletService(apiClient: ApiClient) { + let walletAddress = ""; + + function setWalletAddress(address: string) { + walletAddress = address; + } + + function getWalletAddress() { + return walletAddress; + } + + // Subscribe to wallet change events + window.ethereum.removeAllListeners("accountsChanged"); + window.ethereum.on("accountsChanged", (accounts: string[]) => { + setWalletAddress(accounts[0]); + + apiClient.setWalletAddress(walletAddress); + apiClient.logoutUser(); + + // cleanup(); + }); + + return { + setWalletAddress, + getWalletAddress, + }; +} diff --git a/src/lib/sdk/utils/EventEmitter.ts b/src/lib/sdk/utils/EventEmitter.ts new file mode 100644 index 0000000..3e84f64 --- /dev/null +++ b/src/lib/sdk/utils/EventEmitter.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-prototype-builtins */ +export class EventEmitter { + private listeners: any = {}; + + on(event: string, callback: Callback) { + if (!this.listeners.hasOwnProperty(event)) { + this.listeners[event] = []; + } + + this.listeners[event].push(callback); + + return this; + } + + once(event: string, callback: Callback) { + if (!this.listeners.hasOwnProperty(event)) { + this.listeners[event] = []; + } + + const onceCallback = (...data: any) => { + callback.call(this, ...data); + this.removeListener(event, onceCallback); + }; + + this.listeners[event].push(onceCallback); + + return this; + } + + emit(event: string, ...data: any) { + if (!this.listeners.hasOwnProperty(event)) { + return null; + } + + this.listeners[event].forEach((callback: Callback) => callback.call(this, ...data)); + } + + removeListener(event: string, callback: Callback) { + if (!this.listeners.hasOwnProperty(event)) { + return null; + } + + const index = this.listeners[event].indexOf(callback); + + if (index > -1) { + this.listeners[event].splice(index, 1); + } + } + + removeAllListeners(event: string) { + if (!this.listeners.hasOwnProperty(event)) { + return null; + } + + this.listeners[event] = []; + } + + getListeners(event: string) { + if (!this.listeners.hasOwnProperty(event)) { + return null; + } + + return this.listeners[event]; + } + + getEvents() { + return Object.keys(this.listeners); + } + + getListenerCount(event: string) { + if (!this.listeners.hasOwnProperty(event)) { + return null; + } + + return this.listeners[event].length; + } +} + +type Callback = (...args: any[]) => void; diff --git a/src/lib/sdk/utils/index.ts b/src/lib/sdk/utils/index.ts new file mode 100644 index 0000000..18b60ac --- /dev/null +++ b/src/lib/sdk/utils/index.ts @@ -0,0 +1 @@ +export * from "./EventEmitter"; diff --git a/src/lib/services/auth.service.ts b/src/lib/services/auth.service.ts deleted file mode 100644 index 2ca19fa..0000000 --- a/src/lib/services/auth.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { ApiClient, User } from './apiClient.service'; -import type { LocationService, VisitorData } from './location.service'; - -export function createAuthService({ apiClient, locationService, bypassDeviceCheck }: AuthServiceParams): AuthService { - const previousAttempt = { signature: "", nonce: "" }; - - const login = async (nonce: string, signature: string, visitorData?: VisitorData) => { - const data = await apiClient.loginUser(nonce, signature, visitorData, bypassDeviceCheck); - return data; - }; - - const loginOrCreateUser = async (walletAddress: string) => { - const { nonce } = await apiClient.requestLogin(walletAddress); - const signature = await requestSignature(walletAddress, nonce); - const visitorData = await locationService.getVisitorData(); - - // is there a better way to do this? I'm concerned about keeping this state in memory and not in local storage - previousAttempt.nonce = nonce; - previousAttempt.signature = signature; - - try { - const data = await apiClient.createUser(nonce, signature, visitorData); - return data; - } catch (err: any) { - // if user already exists, try to login - if (err.code === "CONFLICT") return login(nonce, signature, visitorData); - throw err; - } - }; - - const logout = async () => { - try { - await apiClient.logoutUser(); - } catch { - return; - } - } - - const getPreviousLogin = async () => { - // TODO: Use refresh token instead - // TODO: Modify refresh token endpoint to verify visitor data - if (!previousAttempt.signature) throw { code: "UNAUTHORIZED" }; - - const visitorData = await locationService.getVisitorData(); - - if (!visitorData) throw new Error("cannot get device data"); - - return { nonce: previousAttempt.nonce, signature: previousAttempt.signature, visitor: visitorData} - }; - - - /** - * Prompts the user to sign a message using their wallet - * @param userAddress - The user's wallet address - * @param encodedMessage - The nonce encoded in base64 - * @returns The signature of the message - */ - const requestSignature = async (userAddress: string, encodedMessage: string) => { - try { - const message = window.atob(encodedMessage); - const signature = await window.ethereum.request({ - method: 'personal_sign', - params: [message, userAddress], - }); - - return signature; - } catch (error) { - console.log("SDK :: Wallet signature error: ", error); - } - } - - const fetchLoggedInUser = async (walletAddress: string) => { - try { - const { user } = await apiClient.refreshToken(walletAddress); - return user; - } catch (err: any) { - return null; - } - } - - return { - loginOrCreateUser, - fetchLoggedInUser, - requestSignature, - getPreviousLogin, - logout - }; -} - -export interface AuthServiceParams { - apiClient: ApiClient; - locationService: LocationService; - bypassDeviceCheck: boolean; -} - -export interface AuthService { - loginOrCreateUser: (walletAddress: string) => Promise<{ user: User }>; - fetchLoggedInUser: (walletAddress: string) => Promise; - requestSignature: (userAddress: string, encodedMessage: string) => Promise; - getPreviousLogin: () => Promise<{nonce: string, signature: string, visitor: VisitorData}>; - logout: () => Promise; -} \ No newline at end of file diff --git a/src/lib/services/events.service.ts b/src/lib/services/events.service.ts deleted file mode 100644 index 0fbfe74..0000000 --- a/src/lib/services/events.service.ts +++ /dev/null @@ -1,371 +0,0 @@ -import type { StringPay, StringPayload } from "../StringPay"; -import type { ApiClient, ExecutionRequest, Quote, User, UserUpdate, TransactionRequest } from "./apiClient.service"; -import type { LocationService } from "./location.service"; -import type { QuoteService } from "./quote.service"; -import type { AuthService } from "./auth.service"; - -const CHANNEL = "STRING_PAY"; - -export enum Events { - LOAD_PAYLOAD = "load_payload", - IFRAME_READY = "ready", - IFRAME_RESIZE = "resize", - IFRAME_CLOSE = "close", - REQUEST_AUTHORIZE_USER = "request_authorize_user", - RECEIVE_AUTHORIZE_USER = "receive_authorize_user", - REQUEST_UPDATE_USER = 'request_update_user', - RECEIVE_UPDATE_USER = 'receive_update_user', - REQUEST_EMAIL_VERIFICATION = "request_email_verification", - RECEIVE_EMAIL_VERIFICATION = "receive_email_verification", - REQUEST_EMAIL_PREVIEW = "request_email_preview", - RECEIVE_EMAIL_PREVIEW = "receive_email_preview", - REQUEST_DEVICE_VERIFICATION = "request_device_verification", - RECEIVE_DEVICE_VERIFICATION = "receive_device_verification", - REQUEST_SAVED_CARDS = "request_saved_cards", - RECEIVE_SAVED_CARDS = "receive_saved_cards", - REQUEST_CONFIRM_TRANSACTION = "request_confirm_transaction", - RECEIVE_CONFIRM_TRANSACTION = "receive_confirm_transaction", - REQUEST_QUOTE_START = "request_quote_start", - QUOTE_CHANGED = "quote_changed", - REQUEST_QUOTE_STOP = "request_quote_stop", -} - -const eventHandlers: Record void> = {}; - -export function createEventsService(iframeUrl: string, authService: AuthService, quoteService: QuoteService, apiClient: ApiClient, locationService: LocationService) { - let emailCheckInterval: NodeJS.Timer | undefined; - let deviceCheckInterval: NodeJS.Timer | undefined; - - const sendEvent = (frame: HTMLIFrameElement, eventName: string, data?: T, error?: any) => { - if (!frame) { - err("a frame was not provided to sendEvent"); - } - - const stringEvent: StringEvent = { eventName, data, error }; - const message = JSON.stringify({ - channel: CHANNEL, - event: stringEvent, - }); - - frame.contentWindow?.postMessage(message, "*"); - }; - - const _handleEvent = async (e: any) => { - if (e.origin !== iframeUrl) return; - - try { - const payload = JSON.parse(e.data); - const channel = payload.channel; - const event = payload.event; - if (channel === CHANNEL) { - const handler = eventHandlers[event.eventName]; - if (handler) await handler(event, window.StringPay); - else console.debug("SDK :: Unhandled event: ", event); - } - } catch (error) { - console.error("sdk: _handleEvent error: ", error); - } - }; - - const registerEvents = () => { - unregisterEvents(); - - window.addEventListener("message", _handleEvent); - }; - - const unregisterEvents = () => { - window.removeEventListener("message", _handleEvent); - }; - - function cleanup() { - unregisterEvents(); - - clearInterval(emailCheckInterval); - clearInterval(deviceCheckInterval); - - const stringPay = window.StringPay; - - if (stringPay) { - stringPay.frame?.remove(); - stringPay.frame = undefined; - stringPay.isLoaded = false; - if (stringPay.onFrameClose) stringPay.onFrameClose(); - } - } - - // Hook event handlers to the events - eventHandlers[Events.IFRAME_READY] = onIframeReady; - eventHandlers[Events.IFRAME_CLOSE] = onIframeClose; - eventHandlers[Events.IFRAME_RESIZE] = onIframeResize; - eventHandlers[Events.REQUEST_AUTHORIZE_USER] = onAuthorizeUser; - eventHandlers[Events.REQUEST_UPDATE_USER] = onUpdateUser; - eventHandlers[Events.REQUEST_EMAIL_VERIFICATION] = onEmailVerification; - eventHandlers[Events.REQUEST_EMAIL_PREVIEW] = onEmailPreview; - eventHandlers[Events.REQUEST_DEVICE_VERIFICATION] = onDeviceVerification; - eventHandlers[Events.REQUEST_SAVED_CARDS] = onRequestSavedCards; - eventHandlers[Events.REQUEST_QUOTE_START] = onQuoteStart; - eventHandlers[Events.REQUEST_QUOTE_STOP] = onQuoteStop; - eventHandlers[Events.REQUEST_CONFIRM_TRANSACTION] = onConfirmTransaction; - - /** -------------- EVENT HANDLERS ---------------- */ - - async function onIframeReady(event: StringEvent, stringPay: StringPay) { - let user = null as User | null; - - if (!stringPay.frame || !stringPay.payload) throw new Error("Iframe not ready"); - - apiClient.setWalletAddress(stringPay.payload.userAddress); - - // init fp service - locationService.getFPInstance().catch((err) => console.debug("getFPInstance error: ", err)); - - try { - user = await authService.fetchLoggedInUser(stringPay.payload.userAddress); - } catch (e) { - console.debug("fetchLoggedInUser error", e); - } - - const iframePayload = createIframePayload(stringPay.payload, user); - sendEvent(stringPay.frame, Events.LOAD_PAYLOAD, iframePayload); - stringPay.isLoaded = true; - - if (stringPay.onFrameLoad) stringPay.onFrameLoad(); - } - - async function onIframeClose() { - console.debug("SDK :: onIframeClose"); - quoteService.stopQuote(); - cleanup(); - } - - async function onIframeResize(event: StringEvent, stringPay: StringPay) { - if (!stringPay.frame || !stringPay.payload) throw new Error("Iframe not ready"); - - const frame = stringPay.frame; - - if (event.data?.height != frame.scrollHeight) { - frame.style.height = (event.data?.height ?? frame.scrollHeight) + "px"; - } - } - - async function onAuthorizeUser(event: StringEvent, stringPay: StringPay) { - if (!stringPay.frame || !stringPay.payload) throw new Error("Iframe not ready"); - - try { - const { user } = await authService.loginOrCreateUser(stringPay.payload.userAddress); - sendEvent(stringPay.frame, Events.RECEIVE_AUTHORIZE_USER, { user }); - } catch (error: any) { - console.debug("SDK :: onAuthorizeUser error: ", error); - sendEvent(stringPay.frame, Events.RECEIVE_AUTHORIZE_USER, {}, error); - } - } - - async function onUpdateUser(event: StringEvent, { frame }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - try { - const data = <{ userId: string, update: UserUpdate }>event.data; - const user = await apiClient.updateUser(data.userId, data.update); - - sendEvent(frame, Events.RECEIVE_UPDATE_USER, { user }); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_UPDATE_USER, {}, error); - } - } - - async function onEmailVerification(event: StringEvent, { frame }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - try { - const data = <{ userId: string; email: string }>event.data; - - await apiClient.requestEmailVerification(data.userId, data.email); - - clearInterval(emailCheckInterval); - emailCheckInterval = setInterval(async () => { - const { status } = await apiClient.getUserStatus(data.userId); - if (status == "email_verified") { - sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, { status }); - clearInterval(emailCheckInterval); - } - }, 5000); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, {}, error); - } - } - - async function onDeviceVerification(event: StringEvent, { frame }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - try { - const data = <{ walletAddress: string }>event.data; - - let nonce: string; - let signature: string; - - const previous = await authService.getPreviousLogin(); - nonce = previous.nonce; - signature = previous.signature; - - const visitor = previous.visitor; - - if (!previous.signature) { - nonce = (await apiClient.requestLogin(data.walletAddress)).nonce; - signature = await authService.requestSignature(data.walletAddress, nonce); - } - - await apiClient.requestDeviceVerification(nonce, signature, visitor); - - clearInterval(deviceCheckInterval); - deviceCheckInterval = setInterval(async () => { - const { status } = await apiClient.getDeviceStatus(nonce, signature, visitor); - if (status == "verified") { - sendEvent(frame, Events.RECEIVE_DEVICE_VERIFICATION, { status }); - clearInterval(deviceCheckInterval); - } - }, 5000); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_DEVICE_VERIFICATION, {}, error); - } - } - - async function onEmailPreview(event: StringEvent, { frame }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - try { - const data = <{ walletAddress: string }>event.data; - - let nonce: string; - let signature: string; - - const previous = await authService.getPreviousLogin(); - nonce = previous.nonce; - signature = previous.signature; - - if (!previous.signature) { - nonce = (await apiClient.requestLogin(data.walletAddress)).nonce; - signature = await authService.requestSignature(data.walletAddress, nonce); - } - - const { email } = await apiClient.getUserEmailPreview(nonce, signature); - - sendEvent(frame, Events.RECEIVE_EMAIL_PREVIEW, { email }); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_EMAIL_PREVIEW, {}, error); - } - } - - async function onRequestSavedCards(event: StringEvent, { frame }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - try { - const cards = await apiClient.getSavedCards(); - - sendEvent(frame, Events.RECEIVE_SAVED_CARDS, { cards }); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_SAVED_CARDS, {}, error); - } - } - async function onQuoteStart(event: StringEvent, { frame, payload }: StringPay) { - if (!frame) throw new Error("Iframe not ready"); - - const quotePayload = payload; - - const callback = (quote: Quote | null, err: any) => sendEvent(frame, Events.QUOTE_CHANGED, { quote, err }); - quoteService.startQuote(quotePayload, callback); - } - - async function onQuoteStop() { - quoteService.stopQuote(); - } - - async function onConfirmTransaction(event: StringEvent, stringPay: StringPay) { - if (!stringPay.frame) throw new Error("Iframe not ready"); - - try { - const data = event.data; - - const txRes = await apiClient.transact(data); - sendEvent(stringPay.frame, Events.RECEIVE_CONFIRM_TRANSACTION, txRes); - - if (stringPay.onTxSuccess && stringPay.payload) { - stringPay.onTxSuccess(stringPay.payload, txRes); - } - } catch (error: any) { - sendEvent(stringPay.frame, Events.RECEIVE_CONFIRM_TRANSACTION, {}, error); - if (stringPay.onTxError && stringPay.payload) { - stringPay.onTxError(stringPay.payload, error); - } - } - } - - // Subscribe to wallet change events - window.ethereum.removeAllListeners("accountsChanged"); - window.ethereum.on("accountsChanged", (accounts: string[]) => { - apiClient.setWalletAddress(accounts[0]); - logout(); - cleanup(); - quoteService.stopQuote(); - }); - - function logout() { - apiClient.logoutUser(); - } - - return { - registerEvents, - unregisterEvents, - sendEvent, - }; -} - -// Parse payload before sending it to the iframe -function createIframePayload(payload: StringPayload, _user: User | null): IframePayload { - const nft: NFT = { - assetName: payload.assetName, - price: payload.price, - currency: payload.currency, - collection: payload.collection ?? "", - imageSrc: payload.imageSrc, - imageAlt: payload?.imageAlt ?? "NFT", - }; - - return { - nft, - user: { - walletAddress: payload.userAddress, - id: _user?.id ?? "", - status: _user?.status ?? "", - email: _user?.email ?? "", - }, - }; -} - -function err(msg: string) { - console.error("[String Pay] " + msg); -} - -export interface StringEvent { - eventName: string; - data?: any; - error: string; -} - -export interface NFT { - assetName: string; - price: string; - currency: string; - collection?: string; - imageSrc: string; - imageAlt?: string; -} - -export interface IframePayload { - nft: NFT; - user: { - id: string; - walletAddress: string; - status: string; - email: string; - }; -} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts deleted file mode 100644 index 092f468..0000000 --- a/src/lib/services/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createApiClient } from "./apiClient.service"; -import { createLocationService } from "./location.service"; -import { createAuthService } from "./auth.service"; -import { createQuoteService } from "./quote.service"; -import { createEventsService } from "./events.service"; - -export function createServices({ baseUrl, iframeUrl, apiKey, bypassDeviceCheck = false }: ServiceParams): Services { - const apiClient = createApiClient({ baseUrl, apiKey }); - - const locationService = createLocationService(); - const authService = createAuthService({ apiClient, locationService, bypassDeviceCheck }); - const quoteService = createQuoteService(apiClient); - const eventsService = createEventsService(iframeUrl, authService, quoteService, apiClient, locationService); - - return { - apiClient, - locationService, - authService, - quoteService, - eventsService, - }; -} - -export interface ServiceParams { - baseUrl: string; - iframeUrl: string; - apiKey: string; - bypassDeviceCheck?: boolean; -} - -// services interface -export interface Services { - apiClient: ReturnType; - locationService: ReturnType; - authService: ReturnType; - quoteService: ReturnType; - eventsService: ReturnType; -} diff --git a/src/lib/services/location.service.ts b/src/lib/services/location.service.ts deleted file mode 100644 index 434e9b8..0000000 --- a/src/lib/services/location.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as FingerprintJS from '@fingerprintjs/fingerprintjs-pro'; - -const CUSTOM_SUBDOMAIN = import.meta.env.VITE_ANALYTICS_SUBDOMAIN_URL || ""; -const apiKey = import.meta.env.VITE_ANALYTICS_LIB_PK || ""; - -export function createLocationService(options = {}): LocationService { - let fpInstance: FingerprintJS.Agent | undefined; - - - async function getFPInstance() { - const loadOptions = { - apiKey, - endpoint: [ - CUSTOM_SUBDOMAIN, // This endpoint will be used primarily - FingerprintJS.defaultEndpoint // The default endpoint will be used if the primary fails - ], - ...options - }; - - if (!fpInstance) { - fpInstance = await FingerprintJS.load(loadOptions); - } - - return fpInstance; - } - - - /** - * @param options extendedResult: boolean - if true, the result will contain additional data - * @returns VisitorData if the request was successful, undefined otherwise - */ - async function getVisitorData(options = { extendedResult: true }) { - try { - const fp = await getFPInstance(); - return await fp.get(options); - } catch (e) { - console.debug('analytics service error:', e); - return; - } - } - - return { getFPInstance, getVisitorData } -} - -export interface VisitorData { - visitorId?: string; - requestId?: string; -} - -export interface LocationService { - getFPInstance: () => Promise; - getVisitorData: (options?: { extendedResult: boolean }) => Promise; -} \ No newline at end of file diff --git a/src/lib/services/quote.service.ts b/src/lib/services/quote.service.ts deleted file mode 100644 index 8c9e4f3..0000000 --- a/src/lib/services/quote.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ApiClient, Quote, ExecutionRequest } from './apiClient.service'; - -export function createQuoteService(apiClient: ApiClient): QuoteService { - let interval: NodeJS.Timer | undefined; - - async function startQuote(payload: ExecutionRequest, callback: (quote: Quote | null, err: any) => void) { - _refreshQuote(payload, callback); - - if (interval) { - clearInterval(interval); - } - - interval = setInterval(() => _refreshQuote(payload, callback), 10000); - } - - function stopQuote() { - clearInterval(interval); - interval = undefined; - } - - async function _refreshQuote(payload: ExecutionRequest, callback: (quote: Quote | null, err: any) => void) { - try { - const quote = await apiClient.getQuote(payload); - callback(quote, null); - } catch (err: any) { - console.debug('-- refresh quote error --', err); - callback(null, err); - } - } - - return { - startQuote, - stopQuote, - }; -} - -export interface QuoteService { - startQuote: (payload: ExecutionRequest, callback: (quote: Quote | null, err: any) => void) => Promise; - stopQuote: () => void; -} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cd1ba7f..bc42734 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,68 +1,62 @@
- -
+ +
+ +