UNPKG

@frak-labs/components

Version:

Frak Wallet components, helping any person to interact with the Frak wallet.

368 lines (357 loc) 12.8 kB
import register from "preact-custom-element"; import * as coreSdkIndex from "@frak-labs/core-sdk"; import { decompressJsonFromB64, sdkConfigStore, setupClient, trackEvent, withCache } from "@frak-labs/core-sdk"; import * as coreSdkActions from "@frak-labs/core-sdk/actions"; import { displaySharingPage } from "@frak-labs/core-sdk/actions"; import { useEffect, useMemo, useState } from "preact/hooks"; //#region src/actions/sharingPage.ts async function openSharingPage(targetInteraction, placement, options) { if (!window.FrakSetup?.client) { console.error("Frak client not found"); return; } await displaySharingPage(window.FrakSetup.client, { ...options?.link && { link: options.link }, ...options?.products?.length && { products: options.products }, ...targetInteraction && { metadata: { targetInteraction } } }, placement); } //#endregion //#region src/utils/sharingPageProducts.ts /** * Whether `value` is a syntactically valid URL with an `http(s):` scheme. * * Used to gate `imageUrl` / `link` fields coming from untrusted inputs (the * public `products` prop on `<frak-post-purchase>`, decoded query params for * Klaviyo / email share links, etc.) — the listener-side sharing-page builder * calls `new URL(...)` on the incoming product link, and a `javascript:` URL * would be a XSS sink in any consumer that binds the value to an `href`. */ function isHttpUrl(value) { try { const parsed = new URL(value); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } /** * Coerce a raw `products` value into a candidate array suitable for * per-item normalisation, or null when it cannot be reduced to one. * * Accepts: * - Real arrays (JS-property surface, decompressed query payloads). * - JSON-stringified arrays (HTML-attribute surface — WP / Magento * server-render delivers attribute values as raw strings). * * Anything else (non-array non-string, JSON parse failure, JSON that * decodes to a non-array) is treated as "no products" so the share still * works without the product card section. */ function coerceProductCandidates(products) { if (!products) return null; if (Array.isArray(products)) return products; if (typeof products !== "string") return null; try { const parsed = JSON.parse(products); return Array.isArray(parsed) ? parsed : null; } catch { return null; } } /** * Normalise one untrusted candidate into a {@link SharingPageProduct}, or * return null when the candidate has no usable title. * * The `products` payload is a public API boundary — merchants can set it * server-side via WP / Magento, imperatively from arbitrary JS, or via * email-template query params built by Klaviyo. Each entry is validated * structurally so a malformed `link` reaching `new URL(...)` downstream * would not crash the sharing-page builder, and so a `javascript:` URL * cannot slip through as `imageUrl` / `link`. */ function normalizeProductCandidate(candidate) { if (!candidate || typeof candidate !== "object") return null; const item = candidate; const title = typeof item.title === "string" ? item.title.trim() : ""; if (title === "") return null; const entry = { title }; if (typeof item.imageUrl === "string" && isHttpUrl(item.imageUrl)) entry.imageUrl = item.imageUrl; if (typeof item.link === "string" && isHttpUrl(item.link)) entry.link = item.link; if (typeof item.utmContent === "string" && item.utmContent !== "") entry.utmContent = item.utmContent; return entry; } /** * Pipe `coerceProductCandidates` + `normalizeProductCandidate` over an * untrusted value and return a non-empty {@link SharingPageProduct}[] or * `undefined` when nothing usable came out. * * The undefined sentinel is what `openSharingPage` / `displaySharingPage` * expect when the caller has no products to show — the sharing page just * skips the product card section. */ function sanitizeProductList(input) { const candidates = coerceProductCandidates(input); if (!candidates) return void 0; const sanitized = []; for (const candidate of candidates) { const entry = normalizeProductCandidate(candidate); if (entry) sanitized.push(entry); } return sanitized.length > 0 ? sanitized : void 0; } /** * Decode a `products` URL query param produced by * `compressJsonToB64(productsArray)` — the encoding Klaviyo (and any * other email tool) uses when embedding the product list of an order * confirmation into a Frak share CTA. * * The result is run through `sanitizeProductList` so every link / image * URL is structurally validated before reaching `new URL(...)` downstream. * Malformed / tampered payloads degrade gracefully to `undefined` — the * share still works, just without the product card section. */ function decodeProductsParam(value) { if (!value) return void 0; let decoded; try { decoded = decompressJsonFromB64(value); } catch { return; } if (decoded === null) return void 0; return sanitizeProductList(decoded); } //#endregion //#region src/bootstrap/clientReady.ts const CUSTOM_EVENT_NAME = "frak:client"; /** * Dispatch a custom event when the Frak client is ready */ function dispatchClientReadyEvent() { const event = new CustomEvent(CUSTOM_EVENT_NAME); window.dispatchEvent(event); } /** * Add or remove an event listener for when the Frak client is ready * @param action * @param callback */ function onClientReady(action, callback) { if (window.FrakSetup?.client && action === "add") { callback(); return; } (action === "add" ? window.addEventListener : window.removeEventListener)(CUSTOM_EVENT_NAME, callback, false); } //#endregion //#region src/bootstrap/initFrakSdk.ts /** * Initializes the Frak SDK client and sets up necessary configurations. * Uses withCache for inflight dedup — concurrent callers share the same promise. * Failures are not cached, allowing retry on next call. * * @returns {Promise<void>} */ function initFrakSdk() { window.FrakSetup.core = { ...coreSdkIndex, ...coreSdkActions }; if (window.FrakSetup?.client) return Promise.resolve(); return withCache(() => doInit(), { cacheKey: "frak-sdk-init", cacheTime: Number.POSITIVE_INFINITY }).catch((err) => { trackEvent(window.FrakSetup?.client, "sdk_init_failed", { reason: err instanceof Error ? err.message : typeof err === "string" ? err : "unknown", config_missing: !window.FrakSetup?.config }); }); } /** * Performs the actual SDK initialization. * Throws on failure so withCache doesn't cache failed attempts. */ async function doInit() { if (!window.FrakSetup?.config) throw new Error("[Frak SDK] Configuration not found. Please ensure window.FrakSetup.config is set."); console.log("[Frak SDK] Starting initialization"); const client = await setupClient({ config: window.FrakSetup.config }); if (!client) throw new Error("[Frak SDK] Failed to create client"); window.FrakSetup.client = client; console.log("[Frak SDK] Client initialized successfully"); dispatchClientReadyEvent(); coreSdkActions.setupReferral(client); handleActionQueryParam(); } /** * Check the query param for an auto-opening of the Frak sharing page. * * Supported params (all optional except `frakAction`): * - `frakAction=share` triggers the auto-open. * - `link` overrides the URL the sharing page generates outbound shares for. * When omitted, the listener falls back to the merchant domain. * - `products` is a base64-encoded compressed JSON payload of * `SharingPageProduct[]` — produced by `compressJsonToB64(productsArray)` * on the sender side (e.g. a Klaviyo email template). Used by * post-purchase emails to surface the items the customer just bought as * product cards on the sharing page. * - `placement` lets the caller scope backend-driven CSS / config to a * specific placement (mirrors the prop on the components). * * The four params are stripped from the URL via `history.replaceState` as * soon as they are read, so refreshes / shares of the current URL do not * re-trigger the auto-open. Matches the `fmt` (merge token) and `sso` * cleanup patterns elsewhere in the SDK. */ function handleActionQueryParam() { const url = new URL(window.location.href); if (url.searchParams.get("frakAction") !== "share") return; console.log("[Frak SDK] Auto open share via query param"); const link = url.searchParams.get("link") ?? void 0; const placement = url.searchParams.get("placement") ?? void 0; const products = decodeProductsParam(url.searchParams.get("products")); url.searchParams.delete("frakAction"); url.searchParams.delete("link"); url.searchParams.delete("placement"); url.searchParams.delete("products"); window.history.replaceState({}, "", url.toString()); openSharingPage(void 0, placement, { link, products }); } //#endregion //#region src/utils/browser/onDocumentReady.ts /** * When the document is ready, run the callback * @param callback */ function onDocumentReady(callback) { if (document.readyState === "complete" || document.readyState === "interactive") setTimeout(callback, 1); else document.addEventListener("DOMContentLoaded", callback); } //#endregion //#region src/webcomponent/registerWebComponent.ts /** * Registers a Preact component as a custom web component * * @param component - The Preact component to register * @param tagName - The custom element tag name (e.g., "frak-button-wallet") * @param observedAttributes - Array of attribute names to observe for changes * @param options - Registration options (e.g., { shadow: true }) */ function registerWebComponent(component, tagName, observedAttributes = [], options = { shadow: true }) { if (typeof window !== "undefined") { onDocumentReady(initFrakSdk); if (!customElements.get(tagName)) register(component, tagName, observedAttributes, options); } } //#endregion //#region src/hooks/useClientReady.ts function useClientReady() { const [shouldRender, setShouldRender] = useState(() => { if (!(window.FrakSetup?.config?.waitForBackendConfig !== false)) return true; return sdkConfigStore.isResolved; }); const [isHidden, setIsHidden] = useState(() => sdkConfigStore.getConfig().hidden ?? false); const [isClientReady, setIsClientReady] = useState(() => !!window.FrakSetup?.client); useEffect(() => { const currentConfig = sdkConfigStore.getConfig(); if (currentConfig.isResolved) { setShouldRender(true); setIsHidden(currentConfig.hidden ?? false); } if (window.FrakSetup?.client) setIsClientReady(true); const onConfig = (e) => { const config = e.detail; if (config.isResolved) setShouldRender(true); setIsHidden(config.hidden ?? false); }; window.addEventListener("frak:config", onConfig); const handleReady = () => setIsClientReady(true); onClientReady("add", handleReady); return () => { window.removeEventListener("frak:config", onConfig); onClientReady("remove", handleReady); }; }, []); return { shouldRender, isHidden, isClientReady }; } //#endregion //#region src/styles/sharedCss.ts const sharedCss = ` :host { display: contents; } :host([hidden]) { display: none; } .button:disabled { opacity: 0.7; cursor: default; } .button__fadeIn { animation: frak-fadeIn 300ms ease-in; } @keyframes frak-fadeIn { from { opacity: 0; } to { opacity: 1; } } `; function buildStyleContent(componentCss, placementCss) { return placementCss ? `${sharedCss}\n${componentCss}\n${placementCss}` : `${sharedCss}\n${componentCss}`; } const lightDomBaseCss = ` :where(frak-button-share, frak-open-in-app) { display: contents; } :where(frak-button-share .button, frak-open-in-app .button) { display: flex; align-items: center; justify-content: center; gap: 10px; } :where(frak-button-share .button:disabled, frak-open-in-app .button:disabled) { opacity: 0.7; cursor: default; } :where(frak-button-share .button__fadeIn, frak-open-in-app .button__fadeIn) { animation: frak-fadeIn 300ms ease-in; } @keyframes frak-fadeIn { from { opacity: 0; } to { opacity: 1; } } `; //#endregion //#region src/hooks/usePlacement.ts function getPlacement(id) { return sdkConfigStore.getConfig().placements?.[id]; } function usePlacement(placementId) { const [configVersion, setConfigVersion] = useState(0); useEffect(() => { const onConfig = (_e) => { setConfigVersion((v) => v + 1); }; window.addEventListener("frak:config", onConfig); setConfigVersion((v) => v + 1); return () => window.removeEventListener("frak:config", onConfig); }, []); return useMemo(() => placementId ? getPlacement(placementId) : void 0, [placementId, configVersion]); } //#endregion export { registerWebComponent as a, useClientReady as i, buildStyleContent as n, sanitizeProductList as o, lightDomBaseCss as r, openSharingPage as s, usePlacement as t };