@frak-labs/components
Version:
Frak Wallet components, helping any person to interact with the Frak wallet.
354 lines (343 loc) • 12.9 kB
JavaScript
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 };