UNPKG

@frak-labs/components

Version:

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

354 lines (343 loc) 12.9 kB
import register from "preact-custom-element"; import * as coreSdkIndex from "@frak-labs/core-sdk"; import { decodeProductsParam, deleteQueryParamCaseInsensitive, detectPageLanguage, getQueryParamCaseInsensitive, 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 { useCallback, useEffect, useState } from "preact/hooks"; import { useSyncExternalStore } from "preact/compat"; //#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/dom/detectListenerPreloads.ts /** * Tags that count as "Frak components" for the purpose of preload detection. * * Kept in sync with the registry in `bootstrap/loader.ts#COMPONENTS_MAP` — * any new public custom element should be added here too so the iframe * preload hash reflects the user's actual page surface. */ const FRAK_COMPONENT_SELECTOR = [ "frak-button-share", "frak-button-wallet", "frak-open-in-app", "frak-post-purchase", "frak-banner" ].join(","); /** * Dynamically compute the iframe preload list based on which Frak components * are present in the current document. * * Behaviour: * - No `frak-*` element on the page → `[]` (caller should skip the * `#preload=...` hash entirely so the listener doesn't warm chunks no one * will use). * - At least one `frak-*` element → `["sharing"]`. Every public component * eventually opens the sharing flow (directly via `<frak-button-share>` or * indirectly via wallet/post-purchase/banner CTAs), so a single hint * covers the whole surface without bloating the iframe URL. * * Called once during {@link initFrakSdk}, before {@link setupClient} creates * the iframe. Dynamically-mounted components (added after init) still work — * the listener loads handlers on demand — they just skip the warm-up. */ function detectListenerPreloads() { if (typeof document === "undefined") return []; return document.querySelector(FRAK_COMPONENT_SELECTOR) !== null ? ["sharing"] : []; } //#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: withDynamicPreload(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(); } /** * Inject a dynamically-computed `preload` list when the caller hasn't set * one explicitly. * * Rationale: the listener iframe warms Ring 1/Ring 2 chunks based on the * `#preload=...` hash. The components CDN entry can detect which Frak * components are actually on the page and avoid the warm-up cost when none * are mounted. An explicit `config.preload` (including `[]`) is respected * as an escape hatch. */ function withDynamicPreload(config) { if (config.preload !== void 0) return config; return { ...config, preload: detectListenerPreloads() }; } /** * 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. * * Param keys and the `frakAction` keyword value are matched case-insensitively * because some email tools and browsers lowercase the whole URL in transit * (e.g. `?FrakAction=Share` → `?frakaction=share`). */ function handleActionQueryParam() { const url = new URL(window.location.href); if (getQueryParamCaseInsensitive(url.searchParams, "frakAction")?.toLowerCase() !== "share") return; console.log("[Frak SDK] Auto open share via query param"); const link = getQueryParamCaseInsensitive(url.searchParams, "link") ?? void 0; const placement = getQueryParamCaseInsensitive(url.searchParams, "placement") ?? void 0; const products = decodeProductsParam(getQueryParamCaseInsensitive(url.searchParams, "products")); deleteQueryParamCaseInsensitive(url.searchParams, "frakAction"); deleteQueryParamCaseInsensitive(url.searchParams, "link"); deleteQueryParamCaseInsensitive(url.searchParams, "placement"); deleteQueryParamCaseInsensitive(url.searchParams, "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/hooks/sdkConfigSubscription.ts /** * Shared `useSyncExternalStore` plumbing for the SDK config store. * * The resolved merchant config lives on `window.__frakSdkConfig` and notifies * via the `frak:config` CustomEvent (see `@frak-labs/core-sdk` `sdkConfigStore`). * A single module-level `subscribe` keeps the reference stable across renders * so `useSyncExternalStore` never re-subscribes, and lets every config-driven * hook (`useLang`, `usePlacement`, `useGlobalComponents`) share one listener * shape instead of each hand-rolling an effect + version counter. */ /** * Subscribe to resolved-config changes. Stable reference (module-level) so * `useSyncExternalStore` treats it as a constant store subscription. */ function subscribeSdkConfig(onStoreChange) { if (typeof window === "undefined") return () => {}; window.addEventListener("frak:config", onStoreChange); return () => window.removeEventListener("frak:config", onStoreChange); } //#endregion //#region src/hooks/useLang.ts /** * Resolve the active display language for the Web Components. * * Precedence: resolved SDK/backend config `lang` (driven by `metadata.lang` * or the backend `/resolve` response) → page `<html lang>` → browser * language → `en`. Backed by `useSyncExternalStore` with a bare-string * snapshot, so a `frak:config` dispatch only re-renders the component when * the resolved language actually changes (see `@/i18n/defaults`). */ function useLang() { return useSyncExternalStore(subscribeSdkConfig, () => sdkConfigStore.getConfig().lang ?? detectPageLanguage() ?? "en"); } //#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]; } /** * Subscribe to a single resolved placement from the SDK config store. * * Backed by `useSyncExternalStore`: the snapshot is the stored placement * object reference (stable between `frak:config` dispatches), so the component * only re-renders when that placement actually changes. */ function usePlacement(placementId) { return useSyncExternalStore(subscribeSdkConfig, useCallback(() => placementId ? getPlacement(placementId) : void 0, [placementId])); } //#endregion export { subscribeSdkConfig as a, openSharingPage as c, useLang as i, buildStyleContent as n, useClientReady as o, lightDomBaseCss as r, registerWebComponent as s, usePlacement as t };