@dekin_dev/react-checkout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Veqta Space
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,332 @@
1
+ # @dekin_dev/react-checkout
2
+
3
+ A lightweight, provider-agnostic React modal for embedding a **gateway-hosted checkout page** in an iframe — built for Hubtel, Moolre, PaySwitch, and any other payment gateway with a hosted checkout URL.
4
+
5
+ > **This is a community/third-party tool. It is not an official Hubtel, Moolre, or PaySwitch SDK**, and it is not affiliated with or endorsed by any of them.
6
+
7
+ This package does **not** implement any payment gateway's API. You obtain a checkout URL from your gateway's own server-side integration (using your account credentials on your backend), and this package gives you a polished, accessible, responsive modal to display it in — so you stop rebuilding the same iframe modal for every project.
8
+
9
+ ```tsx
10
+ import { Checkout } from "@dekin_dev/react-checkout";
11
+ import "@dekin_dev/react-checkout/styles.css";
12
+
13
+ <Checkout
14
+ provider="hubtel"
15
+ checkoutUrl={checkoutUrl}
16
+ open={open}
17
+ onClose={() => setOpen(false)}
18
+ />;
19
+ ```
20
+
21
+ - ðŸŠķ Lightweight — ~4KB gzipped JS, ~1.3KB gzipped CSS, React as the only peer dependency
22
+ - ðŸ§Đ Provider-agnostic core — built-in presets for Hubtel/Moolre/PaySwitch, plus a fully custom provider shape for anything else
23
+ - ðŸŽĻ Works with plain CSS out of the box, and just as well with Tailwind utility classes
24
+ - â™ŋ Accessible — `role="dialog"`, focus trapping/restoration, Escape to close, keyboard support
25
+ - ðŸ“ą Mobile-first — goes full-screen under 640px so mobile money/OTP/card flows have room to breathe
26
+ - 🔒 Secure by default — URL validation, strict `postMessage` origin checks, no gateway credentials anywhere near the frontend
27
+ - ðŸ–Ĩïļ SSR-safe — no `window`/`document` access outside effects; works in Next.js, Remix, CRA, plain Vite
28
+
29
+ ---
30
+
31
+ ## Table of contents
32
+
33
+ - [Installation](#installation)
34
+ - [Quick start](#quick-start)
35
+ - [Provider guides](#provider-guides)
36
+ - [Custom providers](#custom-providers)
37
+ - [Payment success handling — read this](#payment-success-handling--read-this)
38
+ - [Styling — plain CSS or Tailwind](#styling--plain-css-or-tailwind)
39
+ - [`useCheckout()` hook](#usecheckout-hook)
40
+ - [`CheckoutButton`](#checkoutbutton)
41
+ - [Props reference](#props-reference)
42
+ - [Accessibility](#accessibility)
43
+ - [SSR](#ssr)
44
+ - [Security notes](#security-notes)
45
+ - [Gateway research notes](#gateway-research-notes)
46
+ - [Roadmap](#roadmap)
47
+ - [Contributing](#contributing)
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ npm install @dekin_dev/react-checkout
53
+ ```
54
+
55
+ React 18+ (and React 19) are peer dependencies — install them yourself if you haven't already. Nothing else is required; you do **not** need Tailwind.
56
+
57
+ ## Quick start
58
+
59
+ ```tsx
60
+ import { useState } from "react";
61
+ import { Checkout } from "@dekin_dev/react-checkout";
62
+ import "@dekin_dev/react-checkout/styles.css";
63
+
64
+ function PaymentPage() {
65
+ const [open, setOpen] = useState(false);
66
+ const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
67
+
68
+ async function handlePay() {
69
+ // Your backend calls the gateway's API and returns a checkout URL.
70
+ const res = await fetch("/api/create-checkout", { method: "POST" });
71
+ const { checkoutUrl } = await res.json();
72
+ setCheckoutUrl(checkoutUrl);
73
+ setOpen(true);
74
+ }
75
+
76
+ return (
77
+ <>
78
+ <button onClick={handlePay}>Pay now</button>
79
+ {checkoutUrl && (
80
+ <Checkout
81
+ provider="hubtel"
82
+ checkoutUrl={checkoutUrl}
83
+ open={open}
84
+ onClose={() => setOpen(false)}
85
+ />
86
+ )}
87
+ </>
88
+ );
89
+ }
90
+ ```
91
+
92
+ Never call a gateway's API directly from the browser with a secret key — `checkoutUrl` must come from your own backend.
93
+
94
+ ## Provider guides
95
+
96
+ For all three built-in providers, the pattern is the same: **your backend creates the checkout URL using that gateway's own API and your account credentials; this component only displays it.**
97
+
98
+ ### Hubtel
99
+
100
+ Your backend integrates with Hubtel's Online Checkout API (their `payproxyapi.hubtel.com` initiate endpoint) to create a checkout URL, configuring:
101
+
102
+ - a `callbackUrl` — a server-to-server webhook your backend exposes to receive the final payment status
103
+ - a `returnUrl` / `cancellationUrl` — where the customer's browser is redirected after paying/cancelling
104
+
105
+ ```tsx
106
+ <Checkout
107
+ provider="hubtel"
108
+ checkoutUrl={checkoutUrl}
109
+ open={open}
110
+ onClose={() => setOpen(false)}
111
+ />
112
+ ```
113
+
114
+ Hubtel's documented flow is redirect + webhook based, not a `postMessage` protocol for a plain embedded URL — see [Gateway research notes](#gateway-research-notes).
115
+
116
+ ### Moolre
117
+
118
+ Your backend uses Moolre's Collections/Checkout API to generate a hosted checkout URL, then passes it straight through:
119
+
120
+ ```tsx
121
+ <Checkout
122
+ provider="moolre"
123
+ checkoutUrl={checkoutUrl}
124
+ open={open}
125
+ onClose={() => setOpen(false)}
126
+ />
127
+ ```
128
+
129
+ ### PaySwitch
130
+
131
+ Your backend uses PaySwitch's API (PayLink or TheTeller) to obtain a checkout/payment-link URL:
132
+
133
+ ```tsx
134
+ <Checkout
135
+ provider="payswitch"
136
+ checkoutUrl={checkoutUrl}
137
+ open={open}
138
+ onClose={() => setOpen(false)}
139
+ />
140
+ ```
141
+
142
+ ## Custom providers
143
+
144
+ Not using Hubtel/Moolre/PaySwitch, or want different defaults? Pass an inline provider config instead of a string:
145
+
146
+ ```tsx
147
+ <Checkout
148
+ provider={{
149
+ name: "my-gateway",
150
+ allowedOrigins: ["https://checkout.mygateway.com"],
151
+ defaultTitle: "Complete your payment",
152
+ }}
153
+ checkoutUrl={checkoutUrl}
154
+ open={open}
155
+ onClose={() => setOpen(false)}
156
+ />
157
+ ```
158
+
159
+ If you control the checkout page yourself and want it to talk back to the parent window, add a `messageHandler` to normalize its `postMessage` events:
160
+
161
+ ```tsx
162
+ provider={{
163
+ name: "my-gateway",
164
+ allowedOrigins: ["https://checkout.mygateway.com"],
165
+ messageHandler: (event) => {
166
+ if (event.data?.kind !== "payment-event") return null;
167
+ return { type: event.data.status, provider: "my-gateway", origin: event.origin };
168
+ },
169
+ }}
170
+ ```
171
+
172
+ Messages are only ever accepted from origins you list in `allowedOrigins` — everything else is dropped before your `messageHandler` ever sees it.
173
+
174
+ ## Payment success handling — read this
175
+
176
+ **This component's frontend events (including `onSuccess`) must never be treated as final proof of payment.** A modal closing, or a message arriving, only means something happened in the browser — it says nothing about whether the payment actually settled.
177
+
178
+ The right architecture:
179
+
180
+ ```text
181
+ Checkout UI (this package)
182
+ │
183
+ ▾
184
+ Gateway
185
+ │
186
+ ▾
187
+ Gateway webhook ─────────────â–ķ Merchant backend
188
+ │
189
+ ▾
190
+ Verify with the gateway's API
191
+ │
192
+ ▾
193
+ Update the order, notify the frontend
194
+ ```
195
+
196
+ Concretely:
197
+
198
+ - None of the built-in providers (Hubtel, Moolre, PaySwitch) have a **documented** `postMessage` protocol for a plain embedded checkout URL, so `onSuccess`/`onFailure`/`onCancel` never fire for them today — this package does not invent gateway behavior that isn't verified. Use `onClose` to prompt the user, then poll or subscribe for your backend's own confirmation (which it gets from the gateway's webhook).
199
+ - For a `custom` provider where you control the checkout page and its `postMessage` contract, `onSuccess`/`onFailure`/`onCancel` fire based on your own `messageHandler` — but still verify server-side before fulfilling an order. A compromised or buggy checkout page could otherwise "confirm" a payment that never happened.
200
+
201
+ ## Styling — plain CSS or Tailwind
202
+
203
+ Import the stylesheet once:
204
+
205
+ ```tsx
206
+ import "@dekin_dev/react-checkout/styles.css";
207
+ ```
208
+
209
+ Every class is namespaced (`.vq-checkout-*`), so it won't collide with your app's CSS or a Tailwind `preflight` reset, and colors/spacing are exposed as CSS variables on `.vq-checkout-root` for easy re-theming:
210
+
211
+ ```css
212
+ .vq-checkout-root {
213
+ --vq-radius: 8px;
214
+ --vq-focus-ring: #16a34a;
215
+ }
216
+ ```
217
+
218
+ Every visual piece also accepts a `className` so you can layer on **Tailwind utilities** exactly like any other class list — no extra configuration needed:
219
+
220
+ ```tsx
221
+ <Checkout
222
+ checkoutUrl={checkoutUrl}
223
+ open={open}
224
+ onClose={() => setOpen(false)}
225
+ className="rounded-3xl ring-4 ring-indigo-200"
226
+ overlayClassName="backdrop-blur-sm"
227
+ />
228
+ ```
229
+
230
+ `theme="light" | "dark" | "auto"` switches the built-in palette; `"auto"` follows `prefers-color-scheme`.
231
+
232
+ ## `useCheckout()` hook
233
+
234
+ An imperative alternative for when you don't want to manage `open` state yourself:
235
+
236
+ ```tsx
237
+ import { useCheckout } from "@dekin_dev/react-checkout";
238
+
239
+ function Payment() {
240
+ const { openCheckout, CheckoutModal } = useCheckout();
241
+
242
+ return (
243
+ <>
244
+ <button onClick={() => openCheckout({ provider: "hubtel", checkoutUrl })}>Pay now</button>
245
+ <CheckoutModal />
246
+ </>
247
+ );
248
+ }
249
+ ```
250
+
251
+ ## `CheckoutButton`
252
+
253
+ For the simplest possible integration — a trigger button and the modal in one component:
254
+
255
+ ```tsx
256
+ import { CheckoutButton } from "@dekin_dev/react-checkout";
257
+
258
+ <CheckoutButton provider="hubtel" checkoutUrl={checkoutUrl}>
259
+ Pay with Hubtel
260
+ </CheckoutButton>;
261
+ ```
262
+
263
+ ## Props reference
264
+
265
+ The primary component is `<Checkout />`:
266
+
267
+ | Prop | Type | Default | Notes |
268
+ | --- | --- | --- | --- |
269
+ | `open` | `boolean` | — | required |
270
+ | `checkoutUrl` | `string` | — | required; validated before use |
271
+ | `provider` | `"hubtel" \| "moolre" \| "payswitch" \| "custom" \| CheckoutProviderConfig` | `"custom"` | |
272
+ | `providerConfig` | `Partial<CheckoutProviderConfig>` | — | overrides merged over the preset |
273
+ | `onOpen` / `onClose` / `onLoad` / `onError` | functions | — | lifecycle callbacks |
274
+ | `onMessage` / `onSuccess` / `onFailure` / `onCancel` | functions | — | only fire for origin-validated, provider-normalized messages |
275
+ | `title` / `description` | `string` | provider default / — | |
276
+ | `showCloseButton` | `boolean` | `true` | |
277
+ | `closeOnOverlayClick` | `boolean` | `true` | |
278
+ | `closeOnEscape` | `boolean` | `true` | |
279
+ | `width` / `height` | `string \| number` | `min(95vw,500px)` / `min(90vh,750px)` | any valid CSS size |
280
+ | `maxWidth` / `maxHeight` | `string \| number` | `500px` / `750px` | |
281
+ | `className` / `overlayClassName` / `iframeClassName` | `string` | — | |
282
+ | `theme` | `"light" \| "dark" \| "auto"` | `"light"` | |
283
+ | `zIndex` | `number` | — | |
284
+ | `allow` | `string` | `"payment *"` | iframe `allow` attribute |
285
+ | `sandbox` | `string` | unset | iframe `sandbox` attribute — see note below |
286
+ | `loadingComponent` / `errorComponent` | `ReactNode` \| function | built-in views | |
287
+ | `allowInsecureHttp` | `boolean` | `false` | dev-only override for non-HTTPS URLs |
288
+
289
+ `sandbox` is left unset by default because an overly restrictive sandbox (e.g. missing `allow-forms` or `allow-popups` for a bank redirect) can silently break a gateway's checkout flow. Only set it if you've confirmed your gateway's checkout page works within the restrictions you choose.
290
+
291
+ All exported types (`CheckoutProps`, `CheckoutProvider`, `CheckoutProviderConfig`, `CheckoutError`, `CheckoutMessage`, `CheckoutTheme`) and the `validateCheckoutUrl` utility are available from the package root.
292
+
293
+ ## Accessibility
294
+
295
+ - `role="dialog"` + `aria-modal="true"`, labeled by the title (or an `aria-label` fallback)
296
+ - Focus moves into the modal on open, is trapped there with Tab/Shift+Tab, and returns to the previously focused element on close
297
+ - Escape closes the modal (configurable)
298
+ - Background scroll is locked while open and restored on close
299
+
300
+ ## SSR
301
+
302
+ The package never touches `window`/`document` at module scope — only inside effects and event handlers — so importing it is safe under Next.js, Remix, or any other SSR framework. The modal itself renders via a portal and only mounts client-side.
303
+
304
+ ## Security notes
305
+
306
+ - `checkoutUrl` is validated before it's ever placed in an iframe: it must parse as a URL, must not use `javascript:`/`data:`/`vbscript:`/`file:`/`blob:`, and must be HTTPS unless you explicitly pass `allowInsecureHttp` (intended for local development only).
307
+ - Incoming `postMessage` events are checked against a provider's `allowedOrigins` before anything else happens. If a provider declares no allow-list and doesn't explicitly set `supportsPostMessage: true`, every message is dropped — an unconfigured provider is trusted with nothing by default.
308
+ - No built-in provider ships a `messageHandler` that fabricates a "success" message — see [Payment success handling](#payment-success-handling--read-this).
309
+ - Never put a payment gateway's secret/API key in frontend code. This package never asks for one.
310
+
311
+ ## Gateway research notes
312
+
313
+ Verified from Hubtel's public API reference: their checkout-URL flow is redirect + server-webhook based (`callbackUrl`, `returnUrl`, `cancellationUrl` parameters on the checkout initiation endpoint). No documented `postMessage` protocol exists for a checkout URL embedded via a plain iframe (their separate `@hubteljs/checkout` SDK manages its own iframe and bridge internally — a different integration path from "embed a URL you already have").
314
+
315
+ **Empirically checked (2026-09-11)** against a live `pay.hubtel.com` checkout link: the response carries no `X-Frame-Options` header and its `Content-Security-Policy` has no `frame-ancestors` directive, so nothing at the HTTP level blocks embedding it in an iframe. The initial HTML also shows no obvious frame-busting script. This is a live-server observation, not a documented commitment from Hubtel — the checkout app is a client-rendered SPA (Nuxt) and its bundled JS wasn't fully audited for frame-busting, and Hubtel could add blocking headers at any time without notice. Treat iframe embedding of Hubtel's checkout as "currently unblocked," not "officially supported."
316
+
317
+ Moolre's and PaySwitch's public documentation did not yield a confirmable `postMessage` protocol, `X-Frame-Options`/CSP frame policy, or iframe embedding guidance at the time this package was written. Rather than guess, `supportsPostMessage` is left `false` for all three built-in providers, and no provider-specific success/failure detection is implemented. If you have first-hand, documented knowledge of any of these gateways' embedding or messaging behavior, contributions with citations are very welcome.
318
+
319
+ ## Roadmap
320
+
321
+ - `0.1.x` — core iframe modal (this release)
322
+ - `0.2.x` — provider adapters and improved events, as gateway behavior gets verified
323
+ - `0.3.x` — hook and `CheckoutButton` refinements
324
+ - `1.0.0` — stable public API
325
+
326
+ ## Contributing
327
+
328
+ See [CONTRIBUTING.md](./CONTRIBUTING.md).
329
+
330
+ ## License
331
+
332
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,253 @@
1
+ import { ButtonHTMLAttributes } from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * A modal/iframe checkout for embedding a gateway-hosted checkout page.
6
+ *
7
+ * `checkoutUrl` must come from your backend's own integration with the
8
+ * payment provider's API — this component never talks to a gateway
9
+ * directly and never needs API keys. See the README for why frontend
10
+ * events here (including `onSuccess`) must not be treated as final proof
11
+ * of payment.
12
+ */
13
+ export declare function Checkout({ open, checkoutUrl, provider, providerConfig: providerConfigOverrides, onClose, onOpen, onLoad, onError, onMessage, onSuccess, onFailure, onCancel, title, description, showCloseButton, closeOnOverlayClick, closeOnEscape, width, height, maxWidth, maxHeight, className, iframeClassName, overlayClassName, theme, zIndex, allow, sandbox, loadingComponent, errorComponent, allowInsecureHttp, id, children, }: CheckoutProps): JSX.Element | null;
14
+
15
+ /**
16
+ * Convenience wrapper that owns its own `open` state: renders a trigger
17
+ * button and the {@link Checkout} modal together, for when you don't need
18
+ * to control the open state yourself.
19
+ */
20
+ export declare function CheckoutButton({ children, buttonClassName, buttonProps, onClose, ...checkoutProps }: CheckoutButtonProps): JSX.Element;
21
+
22
+ export declare interface CheckoutButtonProps extends Omit<CheckoutProps, "open"> {
23
+ children: ReactNode;
24
+ buttonClassName?: string;
25
+ buttonProps?: Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "type" | "className">;
26
+ }
27
+
28
+ export declare interface CheckoutError {
29
+ code: CheckoutErrorCode;
30
+ message: string;
31
+ provider?: string;
32
+ originalError?: unknown;
33
+ }
34
+
35
+ export declare type CheckoutErrorCode = "INVALID_URL" | "IFRAME_LOAD_ERROR" | "PROVIDER_ERROR" | "MESSAGE_ERROR" | "UNKNOWN";
36
+
37
+ /** A normalized, origin-validated message received from the checkout iframe. */
38
+ export declare interface CheckoutMessage<TPayload = unknown> {
39
+ type: string;
40
+ provider?: string;
41
+ payload?: TPayload;
42
+ origin: string;
43
+ raw?: MessageEvent;
44
+ }
45
+
46
+ export declare interface CheckoutProps {
47
+ /** Controls whether the modal is rendered/visible. */
48
+ open: boolean;
49
+ /**
50
+ * The gateway-hosted checkout URL, obtained from your backend's
51
+ * integration with the payment provider's API. Never put secret API keys
52
+ * in the frontend to construct this URL yourself.
53
+ */
54
+ checkoutUrl: string;
55
+ /** Provider preset name, or an inline {@link CheckoutProviderConfig}. */
56
+ provider?: CheckoutProvider;
57
+ /** Overrides merged on top of the resolved provider preset. */
58
+ providerConfig?: Partial<CheckoutProviderConfig>;
59
+ onClose?: () => void;
60
+ onOpen?: () => void;
61
+ onLoad?: () => void;
62
+ onError?: (error: CheckoutError) => void;
63
+ /** Fires for every origin-validated, normalized message from the iframe. */
64
+ onMessage?: (message: CheckoutMessage) => void;
65
+ /**
66
+ * Fires only when a provider's `messageHandler` normalizes a message to
67
+ * type `"success"`. No built-in provider does this today — see
68
+ * `src/providers/*.ts`. Never treat this as final proof of payment;
69
+ * verify with your backend.
70
+ */
71
+ onSuccess?: (message: CheckoutMessage) => void;
72
+ /** Fires only when a provider normalizes a message to type `"failure"`. */
73
+ onFailure?: (message: CheckoutMessage) => void;
74
+ /** Fires only when a provider normalizes a message to type `"cancel"`. */
75
+ onCancel?: (message: CheckoutMessage) => void;
76
+ title?: string;
77
+ description?: string;
78
+ showCloseButton?: boolean;
79
+ closeOnOverlayClick?: boolean;
80
+ closeOnEscape?: boolean;
81
+ width?: string | number;
82
+ height?: string | number;
83
+ maxWidth?: string | number;
84
+ maxHeight?: string | number;
85
+ className?: string;
86
+ iframeClassName?: string;
87
+ overlayClassName?: string;
88
+ theme?: CheckoutTheme;
89
+ zIndex?: number;
90
+ /** Value for the iframe's `allow` attribute. Defaults to `"payment *"`. */
91
+ allow?: string;
92
+ /** Value for the iframe's `sandbox` attribute. Unset by default. */
93
+ sandbox?: string;
94
+ loadingComponent?: ReactNode;
95
+ errorComponent?: ReactNode | ((error: CheckoutError) => ReactNode);
96
+ /**
97
+ * Allow non-HTTPS checkout URLs. Intended for local development only —
98
+ * never enable this in production.
99
+ */
100
+ allowInsecureHttp?: boolean;
101
+ /** Accessible id used to associate the dialog with its title. */
102
+ id?: string;
103
+ children?: ReactNode;
104
+ }
105
+
106
+ /** A provider preset name, or a full inline provider configuration. */
107
+ export declare type CheckoutProvider = CheckoutProviderName | CheckoutProviderConfig;
108
+
109
+ /**
110
+ * Gateway-specific behavior. Built-in presets (hubtel/moolre/payswitch) fill
111
+ * this in with only what is actually documented by that gateway — see
112
+ * `src/providers/*.ts` for citations and known-unknowns.
113
+ */
114
+ export declare interface CheckoutProviderConfig {
115
+ /** Identifier used in error/message payloads and default titles. */
116
+ name: string;
117
+ /**
118
+ * Origins allowed to send this provider's iframe `postMessage` events.
119
+ * When empty/undefined, incoming messages are ignored unless
120
+ * `supportsPostMessage` is explicitly true.
121
+ */
122
+ allowedOrigins?: string[];
123
+ defaultTitle?: string;
124
+ defaultWidth?: string | number;
125
+ defaultHeight?: string | number;
126
+ /**
127
+ * Whether this gateway has a documented postMessage protocol for a
128
+ * generically embedded checkout URL. Defaults to `false` for all built-in
129
+ * providers because none currently publish one — see provider source
130
+ * files. Do not flip this to `true` without a verified source.
131
+ */
132
+ supportsPostMessage?: boolean;
133
+ /**
134
+ * Normalizes a raw `MessageEvent` into a {@link CheckoutMessage}, or
135
+ * returns `null` to ignore it. Only called after origin validation.
136
+ */
137
+ messageHandler?: (event: MessageEvent) => CheckoutMessage | null;
138
+ /** Value for the iframe's `allow` attribute. */
139
+ iframeAllow?: string;
140
+ /** Value for the iframe's `sandbox` attribute, if any. */
141
+ iframeSandbox?: string;
142
+ }
143
+
144
+ /**
145
+ * Built-in provider identifiers. These are convenience presets only —
146
+ * `provider` also accepts a full {@link CheckoutProviderConfig} object for
147
+ * gateways that aren't built in.
148
+ */
149
+ export declare type CheckoutProviderName = "hubtel" | "moolre" | "payswitch" | "custom";
150
+
151
+ export declare type CheckoutTheme = "light" | "dark" | "auto";
152
+
153
+ /**
154
+ * Fallback used for `provider="custom"` with no inline config, and as the
155
+ * base merged under any user-supplied {@link CheckoutProviderConfig}.
156
+ * Intentionally trusts nothing by default: no allowed origins and
157
+ * `supportsPostMessage: false`, so postMessage events are ignored unless the
158
+ * developer explicitly configures otherwise.
159
+ */
160
+ export declare const customProviderDefaults: CheckoutProviderConfig;
161
+
162
+ /**
163
+ * Hubtel — Online Checkout.
164
+ *
165
+ * Verified (from Hubtel's public API reference for the checkout-URL
166
+ * initiation endpoint): the checkout URL flow is redirect + server webhook
167
+ * based. The merchant backend calls Hubtel's initiate endpoint with a
168
+ * `callbackUrl` (server-to-server webhook) and a `returnUrl` /
169
+ * `cancellationUrl` (browser redirect targets), then sends the customer to
170
+ * the resulting checkout URL.
171
+ *
172
+ * UNVERIFIED: Hubtel's public docs do not document a `window.postMessage`
173
+ * protocol for a checkout URL embedded via a plain iframe (as opposed to
174
+ * their separate `@hubteljs/checkout` SDK, which manages its own iframe and
175
+ * bridge internally and is a different integration path from "embed a URL
176
+ * you already have"). `supportsPostMessage` is therefore left `false` here
177
+ * — do not flip it on without a citation. Rely on `returnUrl`/`callbackUrl`
178
+ * plus backend verification instead of any frontend "success" signal.
179
+ */
180
+ export declare const hubtelProvider: CheckoutProviderConfig;
181
+
182
+ /**
183
+ * Moolre — Checkout / Collections.
184
+ *
185
+ * UNVERIFIED: Moolre's public API docs (docs.moolre.com) were not
186
+ * accessible as static, readable content at the time this adapter was
187
+ * written, so no `postMessage` protocol, allowed origins, or iframe
188
+ * embedding policy could be confirmed. `supportsPostMessage` is left
189
+ * `false` and no `messageHandler` is provided — do not assume behavior that
190
+ * hasn't been verified against Moolre's own documentation. Rely on the
191
+ * redirect/callback URLs you configure server-side plus backend
192
+ * verification instead of any frontend "success" signal.
193
+ */
194
+ export declare const moolreProvider: CheckoutProviderConfig;
195
+
196
+ export declare type OpenCheckoutOptions = Omit<CheckoutProps, "open">;
197
+
198
+ /**
199
+ * PaySwitch — PayLink / hosted checkout.
200
+ *
201
+ * UNVERIFIED: PaySwitch's public docs describe PayLink (payment links/QR)
202
+ * and the TheTeller processing API, but no `postMessage` protocol, allowed
203
+ * origins, or iframe embedding policy for a generically embedded checkout
204
+ * URL could be confirmed from accessible documentation. `supportsPostMessage`
205
+ * is left `false` — do not assume behavior that hasn't been verified. Rely
206
+ * on the redirect/callback URLs you configure server-side plus backend
207
+ * verification instead of any frontend "success" signal.
208
+ */
209
+ export declare const payswitchProvider: CheckoutProviderConfig;
210
+
211
+ /**
212
+ * Resolves the `provider` + `providerConfig` props into one concrete
213
+ * {@link CheckoutProviderConfig}. Accepts either a built-in preset name or
214
+ * an inline config object (for gateways that aren't built in), and merges
215
+ * any `providerConfig` overrides on top.
216
+ */
217
+ export declare function resolveProviderConfig(provider: CheckoutProvider | undefined, overrides?: Partial<CheckoutProviderConfig>): CheckoutProviderConfig;
218
+
219
+ export declare interface UrlValidationOptions {
220
+ /** Allow `http:` URLs. Intended for local development only. */
221
+ allowInsecureHttp?: boolean;
222
+ }
223
+
224
+ export declare interface UrlValidationResult {
225
+ valid: boolean;
226
+ reason?: string;
227
+ }
228
+
229
+ /**
230
+ * Imperative alternative to rendering `<Checkout open={...} />` yourself.
231
+ * Keeps the simple `<Checkout />` component as the primary API — this is an
232
+ * optional convenience for call-site-driven flows (e.g. "Pay Now" inside a
233
+ * list where mounting a modal per row would be wasteful).
234
+ */
235
+ export declare function useCheckout(defaultOptions?: Partial<OpenCheckoutOptions>): UseCheckoutResult;
236
+
237
+ export declare interface UseCheckoutResult {
238
+ /** Opens the checkout modal with the given options. */
239
+ openCheckout: (options: OpenCheckoutOptions) => void;
240
+ closeCheckout: () => void;
241
+ isOpen: boolean;
242
+ /** Render this once, wherever the modal should live in the tree. */
243
+ CheckoutModal: () => JSX.Element | null;
244
+ }
245
+
246
+ /**
247
+ * Validates a gateway checkout URL before it is ever placed in an iframe's
248
+ * `src`. Rejects non-URLs, script-injection protocols, and (by default)
249
+ * plain `http:` URLs.
250
+ */
251
+ export declare function validateCheckoutUrl(url: string, options?: UrlValidationOptions): UrlValidationResult;
252
+
253
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const a=require("react/jsx-runtime"),n=require("react"),me=require("react-dom"),C={name:"hubtel",defaultTitle:"Complete Payment",supportsPostMessage:!1,iframeAllow:"payment *"},F={name:"moolre",defaultTitle:"Complete Payment",supportsPostMessage:!1,iframeAllow:"payment *"},K={name:"payswitch",defaultTitle:"Complete Payment",supportsPostMessage:!1,iframeAllow:"payment *"},x={name:"custom",defaultTitle:"Complete Payment",supportsPostMessage:!1},ye={hubtel:C,moolre:F,payswitch:K,custom:x};function V(e,o){let t;return e?typeof e=="string"?t=ye[e]??{...x,name:e}:t={...x,...e}:t=x,o?{...t,...o}:t}const pe=new Set(["javascript:","data:","vbscript:","file:","blob:"]);function G(e,o={}){if(!e||typeof e!="string")return{valid:!1,reason:"A checkoutUrl is required."};let t;try{t=new URL(e)}catch{return{valid:!1,reason:`"${e}" is not a valid URL.`}}return pe.has(t.protocol)?{valid:!1,reason:`Unsafe URL protocol "${t.protocol}" is not allowed.`}:t.protocol!=="https:"&&t.protocol!=="http:"?{valid:!1,reason:`Unsupported URL protocol "${t.protocol}".`}:t.protocol==="http:"&&!o.allowInsecureHttp?{valid:!1,reason:"checkoutUrl must use HTTPS. Pass allowInsecureHttp to override for local development only."}:{valid:!0}}function ve(e,o){return!o||o.length===0?!1:o.some(t=>{try{return new URL(t).origin===e}catch{return t===e}})}function ke(e,o){return function(r){if(!!e.allowedOrigins&&e.allowedOrigins.length>0){if(!ve(r.origin,e.allowedOrigins))return}else if(!e.supportsPostMessage)return;let c=null;if(e.messageHandler)try{c=e.messageHandler(r)}catch{return}else if(r.data&&typeof r.data=="object"){const s=r.data;c={type:typeof s.type=="string"?s.type:"message",provider:e.name,payload:r.data,origin:r.origin}}c&&o({...c,origin:r.origin,raw:r})}}const H=typeof window<"u"&&typeof document<"u";function N(...e){return e.filter(Boolean).join(" ")}let L=0,X="",J="";function be(){if(H){if(L===0){const e=window.innerWidth-document.documentElement.clientWidth;X=document.body.style.overflow,J=document.body.style.paddingRight,document.body.style.overflow="hidden",e>0&&(document.body.style.paddingRight=`${e}px`)}L+=1}}function ge(){H&&(L=Math.max(0,L-1),L===0&&(document.body.style.overflow=X,document.body.style.paddingRight=J))}const we=["a[href]","button:not([disabled])","textarea:not([disabled])","input:not([disabled])","select:not([disabled])","iframe",'[tabindex]:not([tabindex="-1"])'].join(",");function z(e){return Array.from(e.querySelectorAll(we))}function xe(e){return function(t){if(t.key!=="Tab")return;const r=z(e);if(r.length===0){t.preventDefault();return}const i=r[0],c=r[r.length-1],s=document.activeElement;t.shiftKey?(s===i||!e.contains(s))&&(t.preventDefault(),c.focus()):(s===c||!e.contains(s))&&(t.preventDefault(),i.focus())}}function q(e){return typeof e=="number"?`${e}px`:e}function Le({id:e,title:o,description:t,showCloseButton:r,closeOnOverlayClick:i,closeOnEscape:c,onClose:s,width:l,height:f,maxWidth:u,maxHeight:p,className:h,overlayClassName:b,theme:g,zIndex:R,children:I}){const w=n.useRef(null),j=n.useRef(null);n.useEffect(()=>{be(),j.current=document.activeElement;const d=w.current;return d&&(z(d)[0]??d).focus(),()=>{var m,T;ge(),(T=(m=j.current)==null?void 0:m.focus)==null||T.call(m)}},[]),n.useEffect(()=>{if(!c)return;function d(m){m.key==="Escape"&&(m.stopPropagation(),s==null||s())}return document.addEventListener("keydown",d),()=>document.removeEventListener("keydown",d)},[c,s]);function U(d){d.key==="Tab"&&w.current&&xe(w.current)(d.nativeEvent)}function M(d){i&&d.target===d.currentTarget&&(s==null||s())}const E=`${e}-title`,P=t?`${e}-description`:void 0;return a.jsx("div",{className:"vq-checkout-root","data-theme":g,children:a.jsx("div",{className:N("vq-checkout-overlay",b),onMouseDown:M,style:R!==void 0?{zIndex:R}:void 0,children:a.jsxs("div",{ref:w,id:e,role:"dialog","aria-modal":"true","aria-labelledby":o?E:void 0,"aria-label":o?void 0:"Checkout","aria-describedby":P,className:N("vq-checkout-modal",h),style:{width:q(l),height:q(f),maxWidth:q(u),maxHeight:q(p)},tabIndex:-1,onKeyDown:U,children:[(o||t||r)&&a.jsxs("div",{className:"vq-checkout-header",children:[a.jsxs("div",{className:"vq-checkout-heading",children:[o&&a.jsx("h2",{id:E,className:"vq-checkout-title",children:o}),t&&a.jsx("p",{id:P,className:"vq-checkout-description",children:t})]}),r&&a.jsx("button",{type:"button",className:"vq-checkout-close","aria-label":"Close checkout",onClick:()=>s==null?void 0:s(),children:a.jsx("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":"true",children:a.jsx("path",{d:"M2 2L14 14M14 2L2 14",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round"})})})]}),a.jsx("div",{className:"vq-checkout-body",children:I})]})})})}function Re({src:e,title:o,className:t,allow:r,sandbox:i,hidden:c,onLoad:s,onError:l}){const f=n.useRef(null),u=n.useRef(s),p=n.useRef(l);return u.current=s,p.current=l,n.useEffect(()=>{const h=f.current;if(!h)return;const b=()=>u.current(),g=()=>p.current();return h.addEventListener("load",b),h.addEventListener("error",g),()=>{h.removeEventListener("load",b),h.removeEventListener("error",g)}},[e]),a.jsx("iframe",{ref:f,src:e,title:o,className:N("vq-checkout-iframe",t),allow:r,sandbox:i,style:c?{position:"absolute",width:0,height:0,opacity:0}:void 0})}function je(){return a.jsxs("div",{className:"vq-checkout-state",role:"status","aria-live":"polite",children:[a.jsx("div",{className:"vq-checkout-spinner","aria-hidden":"true"}),a.jsx("span",{children:"Loading secure checkout..."})]})}function Ee({error:e}){return a.jsxs("div",{className:"vq-checkout-state",role:"alert",children:[a.jsx("div",{className:"vq-checkout-error-icon","aria-hidden":"true",children:"!"}),a.jsx("p",{className:"vq-checkout-error-message",children:e.message})]})}const Pe="min(95vw, 500px)",Te="min(90vh, 750px)",Ae="500px",De="750px",qe="payment *";function B({open:e,checkoutUrl:o,provider:t,providerConfig:r,onClose:i,onOpen:c,onLoad:s,onError:l,onMessage:f,onSuccess:u,onFailure:p,onCancel:h,title:b,description:g,showCloseButton:R=!0,closeOnOverlayClick:I=!0,closeOnEscape:w=!0,width:j=Pe,height:U=Te,maxWidth:M=Ae,maxHeight:E=De,className:P,iframeClassName:d,overlayClassName:m,theme:T="light",zIndex:Q,allow:Y,sandbox:Z,loadingComponent:ee,errorComponent:S,allowInsecureHttp:W,id:te,children:re}){const se=n.useId(),oe=te??`vq-checkout-${se}`,[ne,ae]=n.useState(!1),[A,D]=n.useState("loading"),[_,O]=n.useState(null),$=n.useRef(!1),v=n.useMemo(()=>V(t,r),[t,JSON.stringify(r??{})]);n.useEffect(()=>{ae(!0)},[]),n.useEffect(()=>{e&&!$.current&&(c==null||c()),$.current=e},[e]),n.useEffect(()=>{if(!e)return;const k=G(o,{allowInsecureHttp:W});if(!k.valid){const y={code:"INVALID_URL",message:k.reason??"The provided checkoutUrl is invalid.",provider:v.name};D("error"),O(y),l==null||l(y);return}D("loading"),O(null)},[e,o,W]),n.useEffect(()=>{if(!e||!H)return;const k=ke(v,y=>{f==null||f(y),y.type==="success"?u==null||u(y):y.type==="failure"?p==null||p(y):y.type==="cancel"&&(h==null||h(y))});return window.addEventListener("message",k),()=>window.removeEventListener("message",k)},[e,v,f,u,p,h]);const ce=n.useCallback(()=>{D("loaded"),s==null||s()},[s]),ie=n.useCallback(()=>{const k={code:"IFRAME_LOAD_ERROR",message:"The checkout page could not be loaded.",provider:v.name};D("error"),O(k),l==null||l(k)},[l,v.name]),le=n.useCallback(()=>{i==null||i()},[i]);if(!e||!ne)return null;const ue=b??v.defaultTitle??"Checkout",de=Y??v.iframeAllow??qe,fe=Z??v.iframeSandbox,he=a.jsxs(Le,{id:oe,title:b??v.defaultTitle,description:g,showCloseButton:R,closeOnOverlayClick:I,closeOnEscape:w,onClose:le,width:j,height:U,maxWidth:M,maxHeight:E,className:P,overlayClassName:m,theme:T,zIndex:Q,children:[A==="loading"&&(ee??a.jsx(je,{})),A==="error"&&_&&(typeof S=="function"?S(_):S??a.jsx(Ee,{error:_})),A!=="error"&&a.jsx(Re,{src:o,title:ue,className:d,allow:de,sandbox:fe,hidden:A==="loading",onLoad:ce,onError:ie}),re]});return me.createPortal(he,document.body)}function Ne({children:e,buttonClassName:o,buttonProps:t,onClose:r,...i}){const[c,s]=n.useState(!1);return a.jsxs(a.Fragment,{children:[a.jsx("button",{type:"button",className:N("vq-checkout-trigger",o),onClick:()=>s(!0),...t,children:e}),a.jsx(B,{...i,open:c,onClose:()=>{s(!1),r==null||r()}})]})}function Ie(e){const[o,t]=n.useState(!1),[r,i]=n.useState(null),c=n.useRef(e);c.current=e;const s=n.useCallback(u=>{i({...c.current,...u}),t(!0)},[]),l=n.useCallback(()=>t(!1),[]),f=n.useCallback(()=>r?a.jsx(B,{...r,open:o,onClose:()=>{var u;(u=r.onClose)==null||u.call(r),t(!1)}}):null,[r,o]);return n.useMemo(()=>({openCheckout:s,closeCheckout:l,isOpen:o,CheckoutModal:f}),[s,l,o,f])}exports.Checkout=B;exports.CheckoutButton=Ne;exports.customProviderDefaults=x;exports.hubtelProvider=C;exports.moolreProvider=F;exports.payswitchProvider=K;exports.resolveProviderConfig=V;exports.useCheckout=Ie;exports.validateCheckoutUrl=G;
2
+ //# sourceMappingURL=index.js.map