react-klasha
Version:
React hooks and components for accepting payments with the Klasha payment gateway
297 lines (287 loc) • 12.8 kB
JavaScript
import React, { useState, useEffect, useRef, createContext, forwardRef, useContext } from 'react';
/**
* The Klasha inline checkout script.
*
* There is a single URL for both test and live traffic — the environment is
* chosen by the `isTestMode` constructor argument, not by the script.
*
* The URLs used by 0.0.x
* (`https://klastatic.fra1.digitaloceanspaces.com/{test,prod}/js/klasha-integration.js`)
* no longer exist: the bucket was deleted and both return a 404, which is why
* every 0.0.x integration is dead.
*/
const KLASHA_SDK_URL = 'https://js.klasha.com/pay.js';
/**
* Kept for symmetry with the rest of the API. Both modes resolve to the same
* script; `isTestMode` is forwarded to the client constructor instead.
*/
const getKlashaScriptUrl = (_isTestMode = false) => KLASHA_SDK_URL;
/**
* One in-flight load per URL, shared across every hook consumer, so mounting
* several payment components never injects several script tags.
*/
const pendingLoads = new Map();
const loadScript = (src) => {
const existing = pendingLoads.get(src);
if (existing)
return existing;
const pending = new Promise((resolve, reject) => {
if (typeof document === 'undefined') {
reject(new Error('react-klasha: no document available (server-side render?)'));
return;
}
// A previous instance of the app may already have injected the tag.
const mounted = document.querySelector(`script[src="${src}"]`);
if (mounted && mounted.dataset.klashaLoaded === 'true') {
resolve();
return;
}
const script = mounted || document.createElement('script');
script.src = src;
script.async = true;
function cleanup() {
script.removeEventListener('load', onLoad);
script.removeEventListener('error', onError);
}
const onLoad = () => {
script.dataset.klashaLoaded = 'true';
cleanup();
resolve();
};
const onError = () => {
cleanup();
script.remove();
// Drop the cached promise so a later mount can retry the load.
pendingLoads.delete(src);
reject(new Error(`react-klasha: unable to load the Klasha inline script from ${src}`));
};
script.addEventListener('load', onLoad);
script.addEventListener('error', onError);
if (!mounted)
document.body.appendChild(script);
});
pendingLoads.set(src, pending);
return pending;
};
/**
* Loads the Klasha inline checkout script.
*
* @param isTestMode accepted for API symmetry; the same script serves both
* environments.
* @returns `[loaded, error]` — `loaded` only turns true once the Klasha script
* itself has executed. 0.0.x also loaded jQuery and resolved on whichever
* script won the race, so `loaded` regularly reported true while
* `window.KlashaClient` was still undefined.
*/
function useKlashaScript(isTestMode = false) {
const src = getKlashaScriptUrl(isTestMode);
const [state, setState] = useState({
loaded: false,
error: false,
});
useEffect(() => {
let active = true;
loadScript(src).then(() => {
if (active)
setState({ loaded: true, error: false });
}, () => {
// An error must never report `loaded: true` — callers would go on to
// start a payment against a client that was never defined.
if (active)
setState({ loaded: false, error: true });
});
return () => {
active = false;
};
}, [src]);
return [state.loaded, state.error];
}
/** Marks the containers this library owns, so merchant-supplied nodes survive. */
const KLASHA_CONTAINER_ATTRIBUTE = 'data-react-klasha-container';
let containerCount = 0;
/** A container id that is unique for the lifetime of the page. */
const nextKlashaContainerId = () => `react-klasha-container-${++containerCount}`;
/**
* Returns the element with `id`, creating it only when it is missing.
*
* 0.0.x appended a `<div id="ktest">` on every effect run, so remounting or
* rendering two payment components produced duplicate DOM ids and the checkout
* rendered into whichever one the browser happened to return first.
*/
const ensureKlashaContainer = (id) => {
if (typeof document === 'undefined')
return null;
const existing = document.getElementById(id);
if (existing)
return existing;
const container = document.createElement('div');
container.id = id;
container.setAttribute(KLASHA_CONTAINER_ATTRIBUTE, 'true');
document.body.appendChild(container);
return container;
};
/** Removes a container, but only if this library created it. */
const removeKlashaContainer = (id) => {
if (typeof document === 'undefined')
return;
const container = document.getElementById(id);
if (container && container.getAttribute(KLASHA_CONTAINER_ATTRIBUTE) === 'true') {
container.remove();
}
};
/**
* Owns the checkout container for one hook instance.
*
* @param containerId use a specific element instead of a generated one. The
* element is reused if it already exists and is left in place on unmount.
* @returns the id to hand to the Klasha client as `containerId`.
*/
function useKlashaContainer(containerId) {
const generated = useRef(null);
if (generated.current === null)
generated.current = nextKlashaContainerId();
const id = containerId || generated.current;
useEffect(() => {
ensureKlashaContainer(id);
return () => {
removeKlashaContainer(id);
};
}, [id]);
return id;
}
const getKlashaClient = () => typeof window === 'undefined' ? undefined : window.KlashaClient;
/**
* Constructs `window.KlashaClient` and opens the checkout.
*
* The argument order matches the shipped `pay.js` exactly. Nothing is logged —
* 0.0.x printed the whole argument object, `merchantKey` included, to the
* browser console.
*/
const callKlashaClient = (klashaArgs) => {
const KlashaClient = getKlashaClient();
if (typeof KlashaClient !== 'function') {
throw new Error('react-klasha: window.KlashaClient is not available. ' +
'Wait for the Klasha script to finish loading before starting a payment.');
}
const client = new KlashaClient(klashaArgs.merchantKey, klashaArgs.businessId, klashaArgs.amount, klashaArgs.containerId, klashaArgs.callbackUrl,
// `pay.js` calls this parameter `countryCode`, but it is the destination
// currency that belongs here.
klashaArgs.destinationCurrency, klashaArgs.sourceCurrency, klashaArgs.kit,
// The 9th argument. `pay.js` picks its base URL, iframe URL and redirect URL
// from it, and it is the ONLY thing that selects the environment. 0.0.x
// stopped at 8 arguments, leaving it `undefined` — so every "test" payment
// ran against the live gateway. Coerced because a string like "false" would
// be truthy.
Boolean(klashaArgs.isTestMode));
client.init();
};
/** Strips null/undefined so the client never receives empty optional fields. */
const clean = (obj) => {
const out = {};
for (const key of Object.keys(obj)) {
if (obj[key] !== null && obj[key] !== undefined)
out[key] = obj[key];
}
return out;
};
/**
* `kit.tx_ref` is interpolated into the gateway's redirect URL, so it must never
* be empty.
*/
const generateTxRef = () => `klasha-${Date.now()}-${Math.floor(Math.random() * 1000000000 + 1)}`;
function useKlashaPayment(options) {
const [scriptLoaded, scriptError] = useKlashaScript(options.isTestMode);
const containerId = useKlashaContainer(options.containerId);
const { isTestMode, merchantKey, businessId, amount, tx_ref, sourceCurrency, destinationCurrency, fullname, email, phone, phone_number, paymentDescription, callbackUrl, metadata, kit, } = options;
useEffect(() => {
// Report the failure without tearing down the host app — throwing from an
// effect would unmount the whole tree for a recoverable condition.
if (scriptError) {
// eslint-disable-next-line no-console
console.error('react-klasha: the Klasha inline script failed to load. Payments cannot start.');
}
}, [scriptError]);
function initializePayment(callBack) {
if (scriptError) {
throw new Error('react-klasha: unable to load the Klasha inline script');
}
// 0.0.x returned silently here, so a click before the script was ready
// looked like nothing had happened at all.
if (!scriptLoaded) {
throw new Error('react-klasha: the Klasha inline script is still loading. ' +
'Disable your pay button until it is ready.');
}
// The container normally exists already (created on mount), but make sure —
// `ensure` reuses the existing node rather than appending a duplicate.
ensureKlashaContainer(containerId);
const kitOptions = kit || {};
// `pay.js` reads `kit.phone`; `phone_number` was never read at all.
const customerPhone = phone || phone_number || kitOptions.phone || kitOptions.phone_number;
const reference = kitOptions.tx_ref || tx_ref || generateTxRef();
// `pay.js` mutates the kit it is handed, so build a fresh object per call.
const paymentKit = clean({
...kitOptions,
currency: kitOptions.currency || destinationCurrency || 'NGN',
email: kitOptions.email || email,
fullname: kitOptions.fullname || fullname,
phone: customerPhone,
// Kept alongside `phone` purely for backwards compatibility.
phone_number: customerPhone,
productType: kitOptions.productType || kitOptions.paymentType,
amount: kitOptions.amount === undefined ? amount : kitOptions.amount,
tx_ref: reference,
// An explicit argument wins over one baked into the config.
callBack: callBack || kitOptions.callBack || (() => null),
});
const klashaArgs = {
isTestMode: Boolean(isTestMode),
merchantKey,
businessId,
amount,
containerId,
tx_ref: reference,
destinationCurrency: destinationCurrency || 'NGN',
sourceCurrency: sourceCurrency || 'NGN',
fullname: fullname || '',
email: email || '',
phone: customerPhone || '',
paymentDescription: paymentDescription || '',
callbackUrl: callbackUrl || '',
metadata: metadata || {},
kit: paymentKit,
'data-custom-button': options['data-custom-button'] || '',
};
callKlashaClient(clean(klashaArgs));
}
return initializePayment;
}
const KlashaButton = ({ text, className, children, disabled, type = 'button', callBack, ...others }) => {
const initializePayment = useKlashaPayment(others);
return (React.createElement("button", { type: type, className: className, disabled: disabled, onClick: () => initializePayment(callBack) }, text || children));
};
const KlashaContext = createContext({
initializePayment: () => null,
callBack: () => null,
});
const KlashaProvider = ({ children, callBack, ...others }) => {
const initializePayment = useKlashaPayment(others);
return (React.createElement(KlashaContext.Provider, { value: { initializePayment, callBack } }, children));
};
/**
* `forwardedRef` is deliberately a plain prop rather than `ref`. A function
* component cannot receive a `ref` prop on React 16-18 — React strips it and
* warns — which is why the ref handed to the render prop used to be undefined.
*/
const KlashaConsumerChild = ({ children, forwardedRef, }) => {
const { initializePayment, callBack } = useContext(KlashaContext);
const completeInitializePayment = () => initializePayment(callBack);
return children({ initializePayment: completeInitializePayment, ref: forwardedRef });
};
const KlashaConsumer = forwardRef(({ children, callBack: paraCallBack, ...others }, ref) => {
const callBack = paraCallBack ? paraCallBack : () => null;
return (React.createElement(KlashaProvider, { ...others, callBack: callBack },
React.createElement(KlashaConsumerChild, { forwardedRef: ref }, children)));
});
KlashaConsumer.displayName = 'KlashaConsumer';
export { KLASHA_SDK_URL, KlashaButton, KlashaConsumer, KlashaProvider, ensureKlashaContainer, getKlashaScriptUrl, removeKlashaContainer, useKlashaContainer, useKlashaPayment, useKlashaScript };
//# sourceMappingURL=index.mjs.map