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 @@
-
-
+
+
+
+