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