@habit.analytics/habit-smartlink-reactcomponent
Version:
A React component for Habit SmartLink integration.
248 lines (212 loc) • 8.96 kB
JavaScript
/**
* smartlink-script.js
*
* Framework-agnostic Smartlink embed — drop this file in your project and
* include it with a <script> tag. Exposes window.createSmartlink globally.
*
* No build step, no npm install, no framework required.
*/
(function (global) {
"use strict";
/*──────────────────────────── CONSTANTS ────────────────────────────*/
var SMARTLINK_BASE_URL = {
int: "https://distributors.integrations.habit.io/habit-smart-link/",
qa: "https://distributors.qa.habit.io/habit-smart-link/",
default: "https://distributors.habit.io/habit-smart-link/",
};
var STRUCTURED_ANNOUNCEMENT = "SMARTLINK_STRUCTURED";
var ANNOUNCEMENT_INTERVAL_MS = 500;
var ANNOUNCEMENT_MAX_ATTEMPTS = 10;
/*─────────────────────────── HELPERS ───────────────────────────────*/
function detectEnv(href) {
if (href.indexOf("localhost") !== -1) return "int";
if (href.indexOf("integrations") !== -1) return "int";
if (href.indexOf("qa") !== -1) return "qa";
return "default";
}
function assembleURL(baseURL, hash, pin) {
return baseURL + "?hash=" + hash + (pin ? "&pin=" + pin : "");
}
function buildMessage(type, payload, requestId) {
var msg = { type: type };
if (payload !== undefined) msg.payload = payload;
if (requestId !== undefined) msg.requestId = requestId;
return msg;
}
/*──────────────────────────── FACTORY ──────────────────────────────*/
/**
* createSmartlink(options) → { destroy }
*
* options:
* container {HTMLElement} Where the iframe is appended. Required.
* hash {string} Smartlink hash. Required.
* pin {string} Smartlink pin. Optional.
* env {string} 'localhost' | 'int' | 'qa' | 'default'.
* Auto-detected from window.location if omitted.
* prePaymentMethod {Function} async (quoteId) => { success, payment_id }. Required.
* onPaymentSuccess {Function} (paymentData) => void. Optional.
* onCancelled {Function} () => void. Optional.
* onError {Function} ({ message, details }) => void. Optional.
* customStyle {Object} Key/value pairs applied to iframe.style. Optional.
*/
function createSmartlink(options) {
var container = options.container;
var hash = options.hash;
var pin = options.pin;
var env = options.env || detectEnv(window.location.href);
var prePaymentMethod = options.prePaymentMethod;
var onPaymentSuccess = options.onPaymentSuccess || function () {};
var onCancelled = options.onCancelled || function () {};
var onError = options.onError || function () {};
var customStyle = options.customStyle || {};
if (!container)
throw new Error("[Smartlink] options.container is required.");
if (!hash) throw new Error("[Smartlink] options.hash is required.");
if (typeof prePaymentMethod !== "function")
throw new Error(
"[Smartlink] options.prePaymentMethod must be a function.",
);
var baseUrl = SMARTLINK_BASE_URL[env] || SMARTLINK_BASE_URL["default"];
var src = assembleURL(baseUrl, hash, pin);
var pluginOrigin = new URL(baseUrl).origin;
/*── Create & mount iframe ──*/
var iframe = document.createElement("iframe");
iframe.id = "paymentIframe";
iframe.src = src;
iframe.title = "smart component";
iframe.setAttribute("scrolling", "no");
// Default dimensions and styles
iframe.style.width = "320px";
iframe.style.height = "650px";
iframe.style.overflow = "hidden";
iframe.style.display = "block";
iframe.style.border = "none";
// Apply any caller overrides.
Object.keys(customStyle).forEach(function (prop) {
iframe.style[prop] = customStyle[prop];
});
container.appendChild(iframe);
function send(msg) {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(msg, pluginOrigin);
}
}
function sendInit(requestId) {
send(buildMessage("SMARTLINK_INIT", undefined, requestId));
}
function handleMessage(event) {
if (event.origin !== pluginOrigin) return;
var msg = event.data;
// Ignore the plain-string legacy announcement echoes from the child.
if (!msg || typeof msg !== "object" || !msg.type) return;
switch (msg.type) {
/*
* Child is mounted and ready — send SMARTLINK_INIT to start the flow.
* Mirrors: subscribeTo('SMARTLINK_READY', ...) in useSmartlinkComponent.
*/
case "SMARTLINK_READY":
sendInit(msg.requestId);
break;
/*
* Child requests the iframe to be resized.
* Mirrors: SMARTLINK_RESIZE handler in useSmartlinkParentMessaging.
*/
case "SMARTLINK_RESIZE":
var resize = msg.payload || {};
if (typeof resize.height === "number") {
iframe.style.height = resize.height + "px";
}
if (typeof resize.width === "number") {
iframe.style.width = resize.width + "px";
}
break;
/*
* Child needs a payment_id before proceeding.
* Mirrors: subscribeTo('SMARTLINK_PREPAYMENT_METHOD', ...) in useSmartlinkComponent.
*/
case "SMARTLINK_PREPAYMENT_METHOD":
var requestId = msg.requestId;
var quoteId = (msg.payload && msg.payload.quote_id) || "";
Promise.resolve()
.then(function () {
return prePaymentMethod(quoteId);
})
.then(function (result) {
send(
buildMessage(
"SMARTLINK_PREPAYMENT_METHOD_COMPLETE",
{ success: result.success, payment_id: result.payment_id },
requestId,
),
);
})
.catch(function (err) {
send(
buildMessage(
"SMARTLINK_PREPAYMENT_METHOD_COMPLETE",
{
success: false,
payment_id: "",
error: err instanceof Error ? err.message : String(err),
},
requestId,
),
);
});
break;
/*
* A step completed — if it was the payments step, fire onPaymentSuccess.
* Mirrors: subscribeTo('SMARTLINK_STEP_COMPLETE', ...) in useSmartlinkComponent.
*/
case "SMARTLINK_STEP_COMPLETE":
var stepPayload = msg.payload || {};
if (stepPayload.success && stepPayload.source === "payments") {
onPaymentSuccess(stepPayload.payment_data || JSON.stringify({}));
}
break;
case "SMARTLINK_CANCELLED":
onCancelled();
break;
case "SMARTLINK_ERROR":
onError(msg.payload || {});
break;
}
}
window.addEventListener("message", handleMessage);
/*
* Legacy structured announcement — mirrors the announcement useEffect.
* Tells older iframe builds that this parent speaks the structured protocol.
* Can be removed once all iframe builds respond to SMARTLINK_READY.
*/
var announcementAttempts = 0;
var announcementInterval = setInterval(function () {
announcementAttempts++;
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(STRUCTURED_ANNOUNCEMENT, pluginOrigin);
}
if (announcementAttempts >= ANNOUNCEMENT_MAX_ATTEMPTS) {
clearInterval(announcementInterval);
}
}, ANNOUNCEMENT_INTERVAL_MS);
// Fire once immediately (interval waits one tick before first call).
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(STRUCTURED_ANNOUNCEMENT, pluginOrigin);
}
/*── Public API ──*/
return {
/**
* Remove the iframe from the DOM and clean up all event listeners.
* Call this when navigating away or when the component is no longer needed.
*/
destroy: function () {
clearInterval(announcementInterval);
window.removeEventListener("message", handleMessage);
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
},
};
}
/*──────────────────────── GLOBAL EXPORT ────────────────────────────*/
global.createSmartlink = createSmartlink;
})(typeof window !== "undefined" ? window : this);