UNPKG

react-klasha

Version:

React hooks and components for accepting payments with the Klasha payment gateway

312 lines (299 loc) 13.3 kB
'use strict'; var React = require('react'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var React__default = /*#__PURE__*/_interopDefault(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] = React.useState({ loaded: false, error: false, }); React.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 = React.useRef(null); if (generated.current === null) generated.current = nextKlashaContainerId(); const id = containerId || generated.current; React.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; React.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__default.default.createElement("button", { type: type, className: className, disabled: disabled, onClick: () => initializePayment(callBack) }, text || children)); }; const KlashaContext = React.createContext({ initializePayment: () => null, callBack: () => null, }); const KlashaProvider = ({ children, callBack, ...others }) => { const initializePayment = useKlashaPayment(others); return (React__default.default.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 } = React.useContext(KlashaContext); const completeInitializePayment = () => initializePayment(callBack); return children({ initializePayment: completeInitializePayment, ref: forwardedRef }); }; const KlashaConsumer = React.forwardRef(({ children, callBack: paraCallBack, ...others }, ref) => { const callBack = paraCallBack ? paraCallBack : () => null; return (React__default.default.createElement(KlashaProvider, { ...others, callBack: callBack }, React__default.default.createElement(KlashaConsumerChild, { forwardedRef: ref }, children))); }); KlashaConsumer.displayName = 'KlashaConsumer'; exports.KLASHA_SDK_URL = KLASHA_SDK_URL; exports.KlashaButton = KlashaButton; exports.KlashaConsumer = KlashaConsumer; exports.KlashaProvider = KlashaProvider; exports.ensureKlashaContainer = ensureKlashaContainer; exports.getKlashaScriptUrl = getKlashaScriptUrl; exports.removeKlashaContainer = removeKlashaContainer; exports.useKlashaContainer = useKlashaContainer; exports.useKlashaPayment = useKlashaPayment; exports.useKlashaScript = useKlashaScript; //# sourceMappingURL=index.cjs.map