diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1299a71 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "always", + "printWidth": 160 +} \ No newline at end of file diff --git a/src/lib/StringPay.ts b/src/lib/StringPay.ts index 41bede3..72153e4 100644 --- a/src/lib/StringPay.ts +++ b/src/lib/StringPay.ts @@ -1,94 +1,101 @@ -import { createEventsService } from '$lib/events'; -import { createServices } from './services'; +import { createServices, type Services } from "./services"; export interface StringPayload { - apiKey: string; - name: string; - collection?: string; - currency: string; - price: number; - imageSrc: string; - imageAlt?: string; - chainID: number; - userAddress: string; - contractAddress: string; - contractFunction: string; - contractReturn: string, - contractParameters: string[]; - txValue: string; - gasLimit?: string; + apiKey: string; + name: string; + collection?: string; + currency: string; + price: number; + imageSrc: string; + imageAlt?: string; + chainID: number; + userAddress: string; + contractAddress: string; + contractFunction: string; + contractReturn: string; + contractParameters: string[]; + txValue: string; + gasLimit?: string; } const IFRAME_URL = import.meta.env.VITE_IFRAME_URL; const API_URL = import.meta.env.VITE_API_URL; const err = (msg: string) => { - console.error("[String Pay] " + msg) -} + console.error("[String Pay] " + msg); +}; export class StringPay { - container?: Element; - frame?: HTMLIFrameElement; - payload?: StringPayload; - isLoaded = false; - - onFrameLoad = () => { }; - onFrameClose = () => { }; - 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.apiKey) return err("You must have an api key in your payload"); - if (payload.apiKey.slice(0, 4) !== "str.") return err(`Invalid API Key: ${payload.apiKey}`); - if (!payload.userAddress) return err("No user address found, please connect wallet") - if (!IFRAME_URL) return err("IFRAME_URL not specified"); - - // Set payload - this.payload = payload; - - // Create iframe in dom - const iframe = document.createElement('iframe'); - iframe.style.width = "100vh"; - iframe.style.height = "700px"; - iframe.style.overflow = "none"; - iframe.src = 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? - - - // Create services - const services = createServices({ - apiKey: this.payload.apiKey, - walletAddress: this.payload.userAddress, - apiUrl: API_URL, - }); - - // since apiClient is a singleton, we can `globally` set the user address - services.apiClient.setWalletAddress(this.payload.userAddress); - - // Register events - const eventsService = createEventsService(this, services); - eventsService.registerEvents(); - eventsService.watchWalletChange(); - - // init fp service - services.locationService.getFPInstance().catch(err => console.debug('getFPInstance error: ', err)); - } + container?: Element; + frame?: HTMLIFrameElement; + payload?: StringPayload; + isLoaded = false; + services: Services; + private _loadIframeCallback = () => {}; + + constructor(loadIframeCallback: () => void) { + this._loadIframeCallback = loadIframeCallback; + } + + onFrameLoad = () => {}; + onFrameClose = () => {}; + + 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.apiKey) return err("You must have an api key in your payload"); + if (payload.apiKey.slice(0, 4) !== "str.") return err(`Invalid API Key: ${payload.apiKey}`); + if (!payload.userAddress) return err("No user address found, please connect wallet"); + if (!IFRAME_URL) return err("IFRAME_URL not specified"); + + // Set payload + this.payload = payload; + + // Create iframe in dom + const iframe = document.createElement("iframe"); + iframe.style.width = "100vh"; + iframe.style.height = "700px"; + iframe.style.overflow = "none"; + iframe.src = 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._loadIframeCallback(); + } } -(window).StringPay = new StringPay() +function main() { + // This is the starting point of the library + // We create services that contain all the logic for the library + // StringPay is the main class that is exported to the user + // We also create a callback that is called when the iframe is loaded. This is used to register events + + const services = createServices({ + apiUrl: API_URL, + }); + + const loadIframeCallback = () => { + services.eventsService.unregisterEvents(); + services.eventsService.registerEvents(); + }; + + const stringPay = new StringPay(loadIframeCallback); + (window).StringPay = stringPay; +} +main(); diff --git a/src/lib/events.ts b/src/lib/events.ts deleted file mode 100644 index d110800..0000000 --- a/src/lib/events.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { StringPay, StringPayload } from './StringPay'; -import type { Services } from './services'; -import type { QuoteRequestPayload, TransactPayload, User } from './services/apiClient.service'; - -const CHANNEL = "STRING_PAY" -const IFRAME_URL = new URL(import.meta.env.VITE_IFRAME_URL).origin; - -export function createEventsService(stringPay: StringPay, services: Services) { - const { authService, quoteService } = services; - - if (!stringPay.frame || !stringPay.payload) { - throw new Error("No frame found"); - } - const stringPayload = stringPay.payload; - const frame = stringPay.frame; - - const eventHandlers: Record void> = { - [Events.IFRAME_READY]: onIframeReady, - [Events.IFRAME_CLOSE]: onIframeClose, - [Events.IFRAME_RESIZE]: onIframeResize, - [Events.REQUEST_AUTHORIZE_USER]: onAuthorizeUser, - [Events.REQUEST_RETRY_LOGIN]: onRetryLogin, - [Events.REQUEST_EMAIL_VERIFICATION]: onEmailVerification, - [Events.REQUEST_QUOTE_START]: onQuoteStart, - [Events.REQUEST_QUOTE_STOP]: onQuoteStop, - [Events.REQUEST_CONFIRM_TRANSACTION]: onConfirmTransaction, - }; - - 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 !== IFRAME_URL) 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); - 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); - }; - - /** -------------- EVENT HANDLERS ---------------- */ - - async function onIframeReady() { - let user = null as User | null; - try { - user = await authService.fetchLoggedInUser(stringPayload.userAddress); - } catch (e) { - console.debug("fetchLoggedInUser error", e); - } - - const iframePayload = createIframePayload(stringPayload, user); - sendEvent(frame, Events.LOAD_PAYLOAD, iframePayload); - stringPay.isLoaded = true; - stringPay.onFrameLoad(); - } - - async function onIframeClose() { - console.debug('SDK :: onIframeClose'); - stringPay.frame?.remove(); - stringPay.frame = undefined; - stringPay.isLoaded = false; - unregisterEvents(); - stringPay.onFrameClose(); - quoteService.stopQuote(); - } - - async function onIframeResize(event: StringEvent) { - if (event.data?.height != frame.scrollHeight) { - frame.style.height = (event.data?.height ?? frame.scrollHeight) + "px"; - } - } - - async function onAuthorizeUser() { - try { - const { user } = await authService.loginOrCreateUser(stringPayload.userAddress); - sendEvent(frame, Events.RECEIVE_AUTHORIZE_USER, { user }); - } catch (error: any) { - console.debug('SDK :: onAuthorizeUser error: ', error); - sendEvent(frame, Events.RECEIVE_AUTHORIZE_USER, {}, error); - } - } - - async function onRetryLogin() { - try { - const { user } = await authService.retryLogin(); - sendEvent(frame, Events.RECEIVE_RETRY_LOGIN, { user }); - } catch (error) { - sendEvent(frame, Events.RECEIVE_RETRY_LOGIN, {}, error); - } - } - - async function onEmailVerification(event: StringEvent) { - try { - const data = <{ userId: string, email: string }>event.data; - await services.apiClient.requestEmailVerification(data.userId, data.email); - sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, { status: 'email_verified' }); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, {}, error); - } - } - - async function onQuoteStart() { - const payload = stringPayload; - - const callback = (quote: TransactPayload) => sendEvent(frame, Events.QUOTE_CHANGED, { quote }); - quoteService.startQuote(payload, callback); - } - - async function onQuoteStop() { - quoteService.stopQuote(); - } - - async function onConfirmTransaction(event: StringEvent) { - try { - const data = event.data; - const txHash = await services.apiClient.transact(data); - sendEvent(frame, Events.RECEIVE_CONFIRM_TRANSACTION, txHash); - } catch (error: any) { - sendEvent(frame, Events.RECEIVE_CONFIRM_TRANSACTION, {}, error); - - } - } - - const watchWalletChange = () => { - window.ethereum.removeAllListeners('accountsChanged'); - window.ethereum.on('accountsChanged', (accounts: string[]) => { - services.apiClient.setWalletAddress(accounts[0]); - onIframeClose(); - logout(); - }); - - } - - function logout() { - services.apiClient.logoutUser(); - } - - return { - registerEvents, - unregisterEvents, - sendEvent, - watchWalletChange - } -} - -// Parse payload before sending it to the iframe -function createIframePayload(payload: StringPayload, _user: User | null): IframePayload { - const nft: NFT = { - name: payload.name, - 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 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_RETRY_LOGIN = 'request_retry_login', - RECEIVE_RETRY_LOGIN = 'receive_retry_login', - REQUEST_EMAIL_VERIFICATION = "request_email_verification", - RECEIVE_EMAIL_VERIFICATION = "receive_email_verification", - 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" -} - -export interface StringEvent { - eventName: string; - data?: any; - error: string; -} - -export interface NFT { - name: string; - price: number; - 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/apiClient.service.ts b/src/lib/services/apiClient.service.ts index 7c9a59e..d14f0cc 100644 --- a/src/lib/services/apiClient.service.ts +++ b/src/lib/services/apiClient.service.ts @@ -1,314 +1,336 @@ -import axios from 'redaxios'; +import axios from "redaxios"; // TODO: Fix timeout issue -export function createApiClient({ apiKey, baseUrl, walletAddress }: ApiClientOptions): ApiClient { - let _userWalletAddress = walletAddress; - - const commonHeaders: any = { - 'Content-Type': 'application/json', - }; - - const httpClient = axios.create({ - baseURL: baseUrl, - headers: commonHeaders, - withCredentials: true, // send cookies, - // fetch: fetchWithTimeout(10000), - }); - - const setWalletAddress = (addr: string) => _userWalletAddress = addr; - - async function createApiKey() { - const { data } = await httpClient.post<{ apiKey: string }>('/apikeys'); - return data; - } - - async function getApiKeys(limit = 10) { - const { data } = await httpClient.get('/apikeys', { params: { limit } }); - return data; - } - - async function validateApiKey(keyId: string) { - const { data } = await httpClient.post<{ Status: string }>(`/apikeys/${keyId}/approve`); - return data; - } - - async function requestLogin(walletAddress: string) { - setWalletAddress(walletAddress); - try { - const headers = { 'X-Api-Key': apiKey }; - const { data } = await httpClient.get<{ nonce: string }>(`/login`, { params: { walletAddress: _userWalletAddress }, headers }); - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function createUser(nonce: string, signature: string, visitor?: VisitorData) { - const headers = { 'X-Api-Key': apiKey }; - const body = { - nonce, - signature, - fingerprint: visitor - }; - - try { - const { data } = await httpClient.post<{ authToken: AuthToken, user: User }>(`/users`, body, { headers }); - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function updateUser(userId: string, update: UserUpdate) { - const request = () => httpClient.put(`/users/${userId}`, update, { - headers: { 'X-Api-Key': apiKey }, - }); - - const { data } = await authInterceptor<{ data: User }>(request); - return data; - } - - async function requestEmailVerification(userId: string, email: string) { - try { - const request = () => httpClient.get(`/users/${userId}/verify-email`, { - headers: { 'X-Api-Key': apiKey }, - params: { email }, - // timeout: 15 * 60 * 1000 // 15 minutes - }); - - await authInterceptor(request); - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function loginUser(nonce: string, signature: string, visitor?: VisitorData) { - const headers = { 'X-Api-Key': apiKey }; - const body = { - nonce, - signature, - fingerprint: visitor - }; - - try { - const { data } = await httpClient.post<{ authToken: AuthToken, user: User }>(`/login/sign`, body, { headers }); - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function refreshToken(walletAddress: string) { - const headers = { 'X-Api-Key': apiKey }; - try { - const { data } = await httpClient.post<{ authToken: AuthToken, user: User }>(`/login/refresh`, { walletAddress }, { headers }); - console.log(' - Token was refreshed') - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function logoutUser() { - const headers = { 'X-Api-Key': apiKey }; - try { - const { status } = await httpClient.post(`/login/logout`, {}, { headers }); - if (status === 204) return; - else throw new Error("logout failed"); - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function getUserStatus(userId: string) { - if (!userId) throw new Error("userId is required"); - const headers = { 'X-Api-Key': apiKey }; - try { - const request = () => httpClient.get(`/users/${userId}/status`, { headers }); - const { data } = await authInterceptor<{ data: { status: string } }>(request); - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function getQuote(payload: QuoteRequestPayload) { - const headers = { 'X-Api-Key': apiKey }; - try { - const request = () => httpClient.post(`/quotes`, payload, { headers }); - const { data } = await authInterceptor<{ data: TransactPayload }>(request); - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - async function transact(transactPayload: TransactPayload) { - const headers = { 'X-Api-Key': apiKey }; - try { - const request = () => httpClient.post(`/transactions`, transactPayload, { headers }); - const { data } = await authInterceptor<{ data: TransactionResponse }>(request); - return data; - } catch (e: any) { - const error = _getErrorFromAxiosError(e); - throw error; - } - } - - function _getErrorFromAxiosError(e: any) { - if (e.data) return e.data; - if (e.response) return e.response.data; - else if (e.request) return e.request; - if (e.message) return e.message; - return e; - } - - async function authInterceptor(request: any): Promise { - try { - const res = await request(); - return res; - } catch (e: any) { - if (e.status === 401 && e.data?.code === 'MISSING_TOKEN' || e.data?.code === 'TOKEN_EXPIRED') { - const data = await refreshToken(_userWalletAddress); - if (data) { - // request again - return request(); - } - } - throw e; - } - } - - return { - createApiKey, - getApiKeys, - validateApiKey, - requestLogin, - createUser, - updateUser, - requestEmailVerification, - loginUser, - refreshToken, - logoutUser, - getUserStatus, - getQuote, - transact, - setWalletAddress - }; +export function createApiClient({ baseUrl }: ApiClientOptions): ApiClient { + let _userWalletAddress = ""; + let apiKey = ""; + + const commonHeaders: any = { + "Content-Type": "application/json", + }; + + const httpClient = axios.create({ + baseURL: baseUrl, + headers: commonHeaders, + withCredentials: true, // send cookies, + // fetch: fetchWithTimeout(10000), + }); + + const setWalletAddress = (addr: string) => (_userWalletAddress = addr); + const setApiKey = (key: string) => (apiKey = key); + + async function createApiKey() { + const { data } = await httpClient.post<{ apiKey: string }>("/apikeys"); + return data; + } + + async function getApiKeys(limit = 10) { + const { data } = await httpClient.get("/apikeys", { + params: { limit }, + }); + return data; + } + + async function validateApiKey(keyId: string) { + const { data } = await httpClient.post<{ Status: string }>(`/apikeys/${keyId}/approve`); + return data; + } + + async function requestLogin(walletAddress: string) { + setWalletAddress(walletAddress); + try { + const headers = { "X-Api-Key": apiKey }; + const { data } = await httpClient.get<{ nonce: string }>(`/login`, { + params: { walletAddress: _userWalletAddress }, + headers, + }); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function createUser(nonce: string, signature: string, visitor?: VisitorData) { + const headers = { "X-Api-Key": apiKey }; + const body = { + nonce, + signature, + fingerprint: visitor, + }; + + try { + const { data } = await httpClient.post<{ + authToken: AuthToken; + user: User; + }>(`/users`, body, { headers }); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function updateUser(userId: string, update: UserUpdate) { + const request = () => + httpClient.put(`/users/${userId}`, update, { + headers: { "X-Api-Key": apiKey }, + }); + + const { data } = await authInterceptor<{ data: User }>(request); + return data; + } + + async function requestEmailVerification(userId: string, email: string) { + try { + const request = () => + httpClient.get(`/users/${userId}/verify-email`, { + headers: { "X-Api-Key": apiKey }, + params: { email }, + // timeout: 15 * 60 * 1000 // 15 minutes + }); + + await authInterceptor(request); + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function loginUser(nonce: string, signature: string, visitor?: VisitorData) { + const headers = { "X-Api-Key": apiKey }; + const body = { + nonce, + signature, + fingerprint: visitor, + }; + + try { + const { data } = await httpClient.post<{ + authToken: AuthToken; + user: User; + }>(`/login/sign`, body, { headers }); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function refreshToken(walletAddress: string) { + const headers = { "X-Api-Key": apiKey }; + try { + const { data } = await httpClient.post<{ + authToken: AuthToken; + user: User; + }>(`/login/refresh`, { walletAddress }, { headers }); + console.log(" - Token was refreshed"); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function logoutUser() { + const headers = { "X-Api-Key": apiKey }; + try { + const { status } = await httpClient.post(`/login/logout`, {}, { headers }); + if (status === 204) return; + else throw new Error("logout failed"); + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function getUserStatus(userId: string) { + if (!userId) throw new Error("userId is required"); + const headers = { "X-Api-Key": apiKey }; + try { + const request = () => httpClient.get(`/users/${userId}/status`, { headers }); + const { data } = await authInterceptor<{ + data: { status: string }; + }>(request); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function getQuote(payload: QuoteRequestPayload) { + const headers = { "X-Api-Key": apiKey }; + try { + const request = () => httpClient.post(`/quotes`, payload, { headers }); + const { data } = await authInterceptor<{ data: TransactPayload }>(request); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + async function transact(transactPayload: TransactPayload) { + const headers = { "X-Api-Key": apiKey }; + try { + const request = () => httpClient.post(`/transactions`, transactPayload, { headers }); + const { data } = await authInterceptor<{ + data: TransactionResponse; + }>(request); + return data; + } catch (e: any) { + const error = _getErrorFromAxiosError(e); + throw error; + } + } + + function _getErrorFromAxiosError(e: any) { + if (e.data) return e.data; + if (e.response) return e.response.data; + else if (e.request) return e.request; + if (e.message) return e.message; + return e; + } + + async function authInterceptor(request: any): Promise { + try { + const res = await request(); + return res; + } catch (e: any) { + if ((e.status === 401 && e.data?.code === "MISSING_TOKEN") || e.data?.code === "TOKEN_EXPIRED") { + const data = await refreshToken(_userWalletAddress); + if (data) { + // request again + return request(); + } + } + throw e; + } + } + + return { + createApiKey, + getApiKeys, + validateApiKey, + requestLogin, + createUser, + updateUser, + requestEmailVerification, + loginUser, + refreshToken, + logoutUser, + getUserStatus, + getQuote, + transact, + setWalletAddress, + setApiKey, + }; } export interface ApiClient { - createApiKey: () => Promise<{ apiKey: string }>; - getApiKeys: () => Promise; - validateApiKey: (keyId: string) => Promise<{ Status: string }>; - requestLogin: (walletAddress: string) => Promise<{ nonce: string }>; - createUser: (nonce: string, signature: string, visitor?: VisitorData) => Promise<{ authToken: AuthToken, user: User }>; - updateUser: (userId: string, userUpdate: UserUpdate) => Promise; - requestEmailVerification: (userId: string, email: string) => Promise; - loginUser: (nonce: string, signature: string, visitor?: VisitorData) => Promise<{ authToken: AuthToken, user: User }>; - refreshToken: (walletAddress: string) => Promise<{ authToken: AuthToken, user: User }>; - logoutUser: () => Promise; - getUserStatus: (userId: string) => Promise<{ status: string }>; - getQuote: (payload: QuoteRequestPayload) => Promise; - transact: (quote: TransactPayload) => Promise; - setWalletAddress: (walletAddress: string) => void; + createApiKey: () => Promise<{ apiKey: string }>; + getApiKeys: () => Promise; + validateApiKey: (keyId: string) => Promise<{ Status: string }>; + requestLogin: (walletAddress: string) => Promise<{ nonce: string }>; + createUser: (nonce: string, signature: string, visitor?: VisitorData) => Promise<{ authToken: AuthToken; user: User }>; + updateUser: (userId: string, userUpdate: UserUpdate) => Promise; + requestEmailVerification: (userId: string, email: string) => Promise; + loginUser: (nonce: string, signature: string, visitor?: VisitorData) => Promise<{ authToken: AuthToken; user: User }>; + refreshToken: (walletAddress: string) => Promise<{ authToken: AuthToken; user: User }>; + logoutUser: () => Promise; + getUserStatus: (userId: string) => Promise<{ status: string }>; + getQuote: (payload: QuoteRequestPayload) => Promise; + transact: (quote: TransactPayload) => Promise; + setWalletAddress: (walletAddress: string) => void; + setApiKey: (apiKey: string) => void; } interface ApiKeyResponse { - id: string; - status: string; - authType: string; - data: string; - createdAt: string; - updatedAt: string; + id: string; + status: string; + authType: string; + data: string; + createdAt: string; + updatedAt: string; } interface RefreshToken { - token: string; - expAt: Date; + token: string; + expAt: Date; } interface AuthToken { - token: string; - refreshToken: RefreshToken; - issuedAt: string; - expAt: string; + token: string; + refreshToken: RefreshToken; + issuedAt: string; + expAt: string; } interface UserUpdate { - walletAddress?: string; - firstName?: string; - middleName?: string; - lastName?: string; + walletAddress?: string; + firstName?: string; + middleName?: string; + lastName?: string; } export interface User { - id: string; - firstName: string; - middleName: string; - lastName: string; - status: string; - type: string; - tags: object; - createdAt: string; - updateAt: string; - email?: string; + id: string; + firstName: string; + middleName: string; + lastName: string; + status: string; + type: string; + tags: object; + createdAt: string; + updateAt: string; + email?: string; } export interface VisitorData { - visitorId?: string; - requestId?: string; + visitorId?: string; + requestId?: string; } export interface Quote { - timestamp: number; - baseUSD: number; - gasUSD: number; - tokenUSD: number; - serviceUSD: number; - totalUSD: number; - signature: string; + timestamp: number; + baseUSD: number; + gasUSD: number; + tokenUSD: number; + serviceUSD: number; + totalUSD: number; + signature: string; } export interface TransactPayload extends Quote { - userAddress: string; - chainID: number; - contractAddress: string; - contractFunction: string; - contractReturn: string; - contractParameters: string[]; - txValue: string; - gasLimit: string; - cardToken: string; + userAddress: string; + chainID: number; + contractAddress: string; + contractFunction: string; + contractReturn: string; + contractParameters: string[]; + txValue: string; + gasLimit: string; + cardToken: string; } export interface TransactionResponse { - txID: string; - txUrl: string; + txID: string; + txUrl: string; } export interface QuoteRequestPayload { - chainID: number; - userAddress: string; - contractAddress: string; - contractFunction: string; - contractReturn: string, - contractParameters: string[]; - txValue: string; - gasLimit: string; + chainID: number; + userAddress: string; + contractAddress: string; + contractFunction: string; + contractReturn: string; + contractParameters: string[]; + txValue: string; + gasLimit: string; } export interface ApiClientOptions { - apiKey: string; - baseUrl: string; - walletAddress: string; + baseUrl: string; } diff --git a/src/lib/services/events.service.ts b/src/lib/services/events.service.ts new file mode 100644 index 0000000..fe62513 --- /dev/null +++ b/src/lib/services/events.service.ts @@ -0,0 +1,268 @@ +import type { StringPay, StringPayload } from "../StringPay"; +import type { ApiClient, QuoteRequestPayload, TransactPayload, User } from "./apiClient.service"; +import type { AuthService } from "./auth.service"; +import type { LocationService } from "./location.service"; +import type { QuoteService } from "./quote.service"; + +const CHANNEL = "STRING_PAY"; +const IFRAME_URL = new URL(import.meta.env.VITE_IFRAME_URL).origin; + +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_RETRY_LOGIN = "request_retry_login", + RECEIVE_RETRY_LOGIN = "receive_retry_login", + REQUEST_EMAIL_VERIFICATION = "request_email_verification", + RECEIVE_EMAIL_VERIFICATION = "receive_email_verification", + 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> = {}; + +const _handleEvent = async (e: any) => { + if (e.origin !== IFRAME_URL) return; + + const stringPay: StringPay = (window).StringPay; + + 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, 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(); + + if ((window).StringPay) { + (window).StringPay.frame?.remove(); + (window).StringPay.frame = undefined; + (window).StringPay.isLoaded = false; + (window).StringPay.onFrameClose(); + } +} + +export function createEventsService(authService: AuthService, quoteService: QuoteService, apiClient: ApiClient, locationService: LocationService) { + 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, "*"); + }; + + // + eventHandlers[Events.IFRAME_READY] = onIframeReady; + eventHandlers[Events.IFRAME_CLOSE] = onIframeClose; + eventHandlers[Events.IFRAME_RESIZE] = onIframeResize; + eventHandlers[Events.REQUEST_AUTHORIZE_USER] = onAuthorizeUser; + eventHandlers[Events.REQUEST_RETRY_LOGIN] = onRetryLogin; + eventHandlers[Events.REQUEST_EMAIL_VERIFICATION] = onEmailVerification; + 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); + apiClient.setApiKey(stringPay.payload.apiKey); + + // 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; + 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 onRetryLogin(event: StringEvent, { frame }: StringPay) { + if (!frame) throw new Error("Iframe not ready"); + + try { + const { user } = await authService.retryLogin(); + sendEvent(frame, Events.RECEIVE_RETRY_LOGIN, { user }); + } catch (error) { + sendEvent(frame, Events.RECEIVE_RETRY_LOGIN, {}, 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); + sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, { + status: "email_verified", + }); + } catch (error: any) { + sendEvent(frame, Events.RECEIVE_EMAIL_VERIFICATION, {}, error); + } + } + + async function onQuoteStart(event: StringEvent, { frame, payload }: StringPay) { + if (!frame) throw new Error("Iframe not ready"); + + const quotePayload = payload; + + const callback = (quote: TransactPayload) => sendEvent(frame, Events.QUOTE_CHANGED, { quote }); + quoteService.startQuote(quotePayload, callback); + } + + async function onQuoteStop() { + quoteService.stopQuote(); + } + + async function onConfirmTransaction(event: StringEvent, { frame }: StringPay) { + if (!frame) throw new Error("Iframe not ready"); + + try { + const data = event.data; + const txHash = await apiClient.transact(data); + sendEvent(frame, Events.RECEIVE_CONFIRM_TRANSACTION, txHash); + } catch (error: any) { + sendEvent(frame, Events.RECEIVE_CONFIRM_TRANSACTION, {}, 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 = { + name: payload.name, + 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 { + name: string; + price: number; + 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 index 3ba4add..a81e215 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -2,35 +2,37 @@ 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 interface Services { - apiClient: ReturnType; - locationService: ReturnType; - authService: ReturnType; - quoteService: ReturnType; + apiClient: ReturnType; + locationService: ReturnType; + authService: ReturnType; + quoteService: ReturnType; } -interface ServiceParams { - apiKey: string; - apiUrl: string; - walletAddress: string; -} +export function createServices({ apiUrl }: { apiUrl: string }): Services { + const apiClient = createApiClient({ baseUrl: apiUrl }); -export function createServices({ apiKey, apiUrl, walletAddress }: ServiceParams): Services { - const apiClient = createApiClient({ - apiKey, - baseUrl: apiUrl, - walletAddress - }); + const locationService = createLocationService(); + const authService = createAuthService({ apiClient, locationService }); + const quoteService = createQuoteService(apiClient); + const eventsService = createEventsService(authService, quoteService, apiClient, locationService); - const locationService = createLocationService(); - const authService = createAuthService({ apiClient, locationService }); - const quoteService = createQuoteService(apiClient); + return { + apiClient, + locationService, + authService, + quoteService, + eventsService, + }; +} - return { - apiClient, - locationService, - authService, - quoteService - }; -} \ No newline at end of file +// services interface +export interface Services { + apiClient: ReturnType; + locationService: ReturnType; + authService: ReturnType; + quoteService: ReturnType; + eventsService: ReturnType; +} diff --git a/src/test/Events.spec.ts b/src/test/Events.spec.ts index df30287..8af54a2 100644 --- a/src/test/Events.spec.ts +++ b/src/test/Events.spec.ts @@ -1,34 +1,33 @@ -import { testPayload } from './mock' -import { StringPay } from '$lib/StringPay' -import { Events } from '$lib/events'; +import { testPayload } from "./mock"; +import { StringPay } from "$lib/StringPay"; +import { Events } from "$lib/services/events.service"; -describe.skip('Events.ts', () => { - beforeEach(() => { - (window).StringPay = new StringPay() +describe.skip("Events.ts", () => { + beforeEach(() => { + (window).StringPay = new StringPay(() => {}); - window.StringPay.frame = document.createElement("iframe") - window.StringPay.payload = testPayload - }) + window.StringPay.frame = document.createElement("iframe"); + window.StringPay.payload = testPayload; + }); - it('handles iframe_ready event', () => { - const event = { eventName: Events.IFRAME_READY } + it("handles iframe_ready event", () => { + const event = { eventName: Events.IFRAME_READY }; - // comment out the following line till we fix these tests - // In order to fix these tests, we need to expose some other functions and create services mock - // handleEvent(event) + // comment out the following line till we fix these tests + // In order to fix these tests, we need to expose some other functions and create services mock + // handleEvent(event) - expect(window.StringPay.isLoaded).toBeTruthy() - }); + expect(window.StringPay.isLoaded).toBeTruthy(); + }); - it('handles iframe_resize event', () => { - const height = 400 - const event = { eventName: Events.IFRAME_RESIZE, data: { height } } + it("handles iframe_resize event", () => { + const height = 400; + const event = { eventName: Events.IFRAME_RESIZE, data: { height } }; - // comment out the following line till we fix these tests - // In order to fix these tests, we need to expose some other functions and create services mock - // handleEvent(event) - - expect(window.StringPay?.frame?.style.height).toBe(height + "px") - }); + // comment out the following line till we fix these tests + // In order to fix these tests, we need to expose some other functions and create services mock + // handleEvent(event) + expect(window.StringPay?.frame?.style.height).toBe(height + "px"); + }); }); diff --git a/src/test/StringPayButton.spec.ts b/src/test/StringPayButton.spec.ts index 7a86ef1..19787d6 100644 --- a/src/test/StringPayButton.spec.ts +++ b/src/test/StringPayButton.spec.ts @@ -1,30 +1,29 @@ -import { render, fireEvent, screen } from '@testing-library/svelte'; -import { StringPay } from '$lib/StringPay' -import { StringPayButton } from '$lib'; -import { testPayload } from './mock' +import { render, fireEvent, screen } from "@testing-library/svelte"; +import { StringPay } from "$lib/StringPay"; +import { StringPayButton } from "$lib"; +import { testPayload } from "./mock"; -describe('StringPayButton.svelte', () => { - it('shows proper text when rendered', () => { - render(StringPayButton, {payload: testPayload}) - const button = screen.getByRole('button') - expect(button).toHaveTextContent('Mint with Card') +describe("StringPayButton.svelte", () => { + it("shows proper text when rendered", () => { + render(StringPayButton, { payload: testPayload }); + const button = screen.getByRole("button"); + expect(button).toHaveTextContent("Mint with Card"); + }); - }); + it("loads iframe when clicked", async () => { + const container = document.createElement("div"); + container.classList.add("string-pay-frame"); + document.body.appendChild(container); - it('loads iframe when clicked', async () => { - const container = document.createElement('div'); - container.classList.add('string-pay-frame'); - document.body.appendChild(container); + (window).StringPay = new StringPay(() => {}); - (window).StringPay = new StringPay() + render(StringPayButton, { payload: testPayload }); + const button = screen.getByRole("button"); - render(StringPayButton, {payload: testPayload}) - const button = screen.getByRole('button') + await fireEvent.click(button); + const frame = document.getElementsByTagName("iframe")[0]; - await fireEvent.click(button) - const frame = document.getElementsByTagName("iframe")[0] - - expect(frame).toBeTruthy() - expect(container).toContainElement(frame) - }); + expect(frame).toBeTruthy(); + expect(container).toContainElement(frame); + }); });