@xpayeg/react 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,568 @@
1
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/context.tsx
4
+ const XPayContext = createContext(null);
5
+ const ElementsContext = createContext(null);
6
+ /** Build a minimal XPayError for SDK-level failures (elements not initialized, etc.) */
7
+ function notInitializedError() {
8
+ return {
9
+ type: "api_error",
10
+ code: null,
11
+ message: "Elements not initialized",
12
+ param: null,
13
+ docUrl: null,
14
+ declineCode: null,
15
+ adviceCode: null,
16
+ chargeId: null,
17
+ paymentMethodId: null,
18
+ paymentMethodType: null,
19
+ paymentMethod: null
20
+ };
21
+ }
22
+ const CheckoutContext = createContext(null);
23
+ /**
24
+ * Provides XPay context to all child components.
25
+ *
26
+ * Accepts either a resolved XPay instance or a Promise (from `loadXPay()`).
27
+ * When `options` (with `clientSecret`) is provided, creates an Elements instance
28
+ * and fetches session data. Use `useCheckout()` in child components to access
29
+ * session data and action methods.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * import { loadXPay } from "@xpayeg/sdk";
34
+ * import { XPayProvider, useCheckout, PaymentElement } from "@xpayeg/react";
35
+ *
36
+ * const xpayPromise = loadXPay("pk_test_xxx");
37
+ *
38
+ * function App() {
39
+ * return (
40
+ * <XPayProvider xpay={xpayPromise} options={{ clientSecret }}>
41
+ * <CheckoutForm />
42
+ * </XPayProvider>
43
+ * );
44
+ * }
45
+ * ```
46
+ */
47
+ function XPayProvider({ xpay: xpayProp, options, children }) {
48
+ const [resolvedXPay, setResolvedXPay] = useState(xpayProp && !(xpayProp instanceof Promise) ? xpayProp : null);
49
+ const [xpayError, setXpayError] = useState(null);
50
+ useEffect(() => {
51
+ if (!xpayProp) return;
52
+ if (!(xpayProp instanceof Promise)) {
53
+ setResolvedXPay(xpayProp);
54
+ return;
55
+ }
56
+ let cancelled = false;
57
+ xpayProp.then((instance) => {
58
+ if (!cancelled) setResolvedXPay(instance);
59
+ }).catch((err) => {
60
+ if (!cancelled) setXpayError(err instanceof Error ? err.message : "Failed to load XPay");
61
+ });
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [xpayProp]);
66
+ const elements = useMemo(() => {
67
+ if (!resolvedXPay || !options?.clientSecret) return null;
68
+ return resolvedXPay.elements(options);
69
+ }, [resolvedXPay, options?.clientSecret]);
70
+ const [session, setSession] = useState(null);
71
+ const [sessionError, setSessionError] = useState(null);
72
+ useEffect(() => {
73
+ if (!elements) return;
74
+ const onReady = (data) => {
75
+ setSession(data.session);
76
+ };
77
+ const onChange = (data) => {
78
+ const newSession = data;
79
+ if (newSession) setSession(newSession);
80
+ };
81
+ const onError = (data) => {
82
+ const d = data;
83
+ setSessionError(d.message || d.error || "Failed to load session");
84
+ };
85
+ elements.on("ready", onReady);
86
+ elements.on("change", onChange);
87
+ elements.on("loaderror", onError);
88
+ }, [elements]);
89
+ const checkoutState = useMemo(() => {
90
+ const errorMsg = xpayError || sessionError;
91
+ if (errorMsg) return {
92
+ type: "error",
93
+ error: { message: errorMsg }
94
+ };
95
+ if (!resolvedXPay || !elements || !session) return { type: "loading" };
96
+ return {
97
+ type: "success",
98
+ session,
99
+ elements,
100
+ xpay: resolvedXPay
101
+ };
102
+ }, [
103
+ resolvedXPay,
104
+ elements,
105
+ session,
106
+ xpayError,
107
+ sessionError
108
+ ]);
109
+ const xpayValue = useMemo(() => resolvedXPay, [resolvedXPay]);
110
+ const elementsValue = useMemo(() => elements, [elements]);
111
+ return /* @__PURE__ */ jsx(XPayContext.Provider, {
112
+ value: xpayValue,
113
+ children: /* @__PURE__ */ jsx(ElementsContext.Provider, {
114
+ value: elementsValue,
115
+ children: /* @__PURE__ */ jsx(CheckoutContext.Provider, {
116
+ value: checkoutState,
117
+ children
118
+ })
119
+ })
120
+ });
121
+ }
122
+ /**
123
+ * Access the checkout state — a tagged union of `loading`, `error`, or `success`.
124
+ *
125
+ * On success, returns a `checkout` object (type `Checkout`) that merges
126
+ * session data with action methods (confirm, promo codes, quantities, etc.).
127
+ *
128
+ * @returns `UseCheckoutResult` — narrow the type by checking `result.type`
129
+ * @throws Error if used outside of `<XPayProvider>`
130
+ *
131
+ * @example
132
+ * ```tsx
133
+ * function CheckoutForm() {
134
+ * const state = useCheckout();
135
+ *
136
+ * if (state.type === "loading") return <Skeleton />;
137
+ * if (state.type === "error") return <p>{state.error.message}</p>;
138
+ *
139
+ * const { checkout } = state;
140
+ * return <button onClick={() => checkout.confirm()}>
141
+ * Pay {checkout.currency} {checkout.amountTotal}
142
+ * </button>;
143
+ * }
144
+ * ```
145
+ */
146
+ function useCheckout() {
147
+ const ctx = useContext(CheckoutContext);
148
+ if (!ctx) throw new Error("Could not find XPay context. Wrap the part of your app that calls useCheckout() in an <XPayProvider> provider.");
149
+ const { confirmFn, applyPromoFn, removePromoFn, updateLineItemQtyFn, submitFn, fetchUpdatesFn, changeAppearanceFn, onChangeFn, getElementsFn } = useCheckoutActions(ctx);
150
+ return useMemo(() => {
151
+ if (ctx.type === "loading") return { type: "loading" };
152
+ if (ctx.type === "error") return {
153
+ type: "error",
154
+ error: ctx.error
155
+ };
156
+ return {
157
+ type: "success",
158
+ checkout: {
159
+ ...ctx.session,
160
+ confirm: confirmFn,
161
+ applyPromotionCode: applyPromoFn,
162
+ removePromotionCode: removePromoFn,
163
+ updateLineItemQuantity: updateLineItemQtyFn,
164
+ submit: submitFn,
165
+ fetchUpdates: fetchUpdatesFn,
166
+ changeAppearance: changeAppearanceFn,
167
+ on: onChangeFn,
168
+ getElements: getElementsFn
169
+ }
170
+ };
171
+ }, [
172
+ ctx,
173
+ confirmFn,
174
+ applyPromoFn,
175
+ removePromoFn,
176
+ updateLineItemQtyFn,
177
+ submitFn,
178
+ fetchUpdatesFn,
179
+ changeAppearanceFn,
180
+ onChangeFn,
181
+ getElementsFn
182
+ ]);
183
+ }
184
+ /** Build stable action callbacks from internal state */
185
+ function useCheckoutActions(ctx) {
186
+ const elements = ctx.type === "success" ? ctx.elements : null;
187
+ const xpay = ctx.type === "success" ? ctx.xpay : null;
188
+ return {
189
+ confirmFn: useCallback(async (opts) => {
190
+ if (!xpay || !elements) return {
191
+ type: "error",
192
+ error: {
193
+ type: "invalid_request_error",
194
+ code: null,
195
+ declineCode: null,
196
+ adviceCode: null,
197
+ message: "XPay not initialized",
198
+ docUrl: null,
199
+ chargeId: null,
200
+ paymentMethodId: null,
201
+ paymentMethodType: null,
202
+ paymentMethod: null
203
+ }
204
+ };
205
+ return xpay.confirmPayment({
206
+ ...opts,
207
+ elements
208
+ });
209
+ }, [xpay, elements]),
210
+ applyPromoFn: useCallback(async (code) => {
211
+ if (!elements) return {
212
+ type: "error",
213
+ error: notInitializedError()
214
+ };
215
+ return elements.applyPromotionCode(code);
216
+ }, [elements]),
217
+ removePromoFn: useCallback(async () => {
218
+ if (!elements) return {
219
+ type: "error",
220
+ error: notInitializedError()
221
+ };
222
+ return elements.removePromotionCode();
223
+ }, [elements]),
224
+ updateLineItemQtyFn: useCallback(async (args) => {
225
+ if (!elements) return {
226
+ type: "error",
227
+ error: notInitializedError()
228
+ };
229
+ return elements.updateLineItemQuantity(args);
230
+ }, [elements]),
231
+ submitFn: useCallback(async () => {
232
+ if (!elements) return { error: notInitializedError() };
233
+ return elements.submit();
234
+ }, [elements]),
235
+ fetchUpdatesFn: useCallback(async () => {
236
+ if (!elements) return {
237
+ type: "error",
238
+ error: notInitializedError()
239
+ };
240
+ return elements.fetchUpdates();
241
+ }, [elements]),
242
+ changeAppearanceFn: useCallback((appearance) => {
243
+ elements?.changeAppearance(appearance);
244
+ }, [elements]),
245
+ onChangeFn: useCallback(((event, handler) => {
246
+ elements?.on(event, handler);
247
+ }), [elements]),
248
+ getElementsFn: useCallback(() => {
249
+ if (!elements) throw new Error("Elements not initialized");
250
+ return elements;
251
+ }, [elements])
252
+ };
253
+ }
254
+ /**
255
+ * Access the raw XPay SDK instance. Returns `null` while the SDK is loading.
256
+ *
257
+ * Most merchants should use `useCheckout()` instead. Use `useXPay()` only when
258
+ * you need direct access to `xpay.confirmPayment()` or `xpay.checkout()`.
259
+ */
260
+ function useXPay() {
261
+ return useContext(XPayContext);
262
+ }
263
+ /**
264
+ * Access the Elements instance. Returns `null` while the session is loading.
265
+ *
266
+ * Use this for direct element management. In most cases, use `useCheckout()` instead
267
+ * which provides both session data and action methods.
268
+ */
269
+ function useElements() {
270
+ return useContext(ElementsContext);
271
+ }
272
+ //#endregion
273
+ //#region src/hooks.ts
274
+ /**
275
+ * Convenience hook for payment confirmation.
276
+ *
277
+ * @example
278
+ * ```tsx
279
+ * const { confirmPayment, isConfirming } = useConfirmPayment();
280
+ * ```
281
+ */
282
+ function useConfirmPayment() {
283
+ const result = useCheckout();
284
+ if (result.type !== "success") return {
285
+ confirmPayment: async () => ({
286
+ type: "error",
287
+ error: {
288
+ message: "Checkout not ready",
289
+ code: null
290
+ }
291
+ }),
292
+ isConfirming: false
293
+ };
294
+ return {
295
+ confirmPayment: result.checkout.confirm,
296
+ isConfirming: false
297
+ };
298
+ }
299
+ //#endregion
300
+ //#region src/utils/useAttachEvent.ts
301
+ /**
302
+ * Attach an event listener to an element without causing listener churn on re-renders.
303
+ *
304
+ * Stores the callback in a ref so the actual listener remains stable.
305
+ * Inspired by Stripe's useAttachEvent pattern.
306
+ */
307
+ function useAttachEvent(element, event, cb) {
308
+ const cbDefined = !!cb;
309
+ const cbRef = useRef(cb);
310
+ useEffect(() => {
311
+ cbRef.current = cb;
312
+ }, [cb]);
313
+ useEffect(() => {
314
+ if (!cbDefined || !element) return;
315
+ const decoratedCb = (...args) => {
316
+ if (cbRef.current) cbRef.current(...args);
317
+ };
318
+ element.on(event, decoratedCb);
319
+ return () => {
320
+ element.off(event, decoratedCb);
321
+ };
322
+ }, [
323
+ cbDefined,
324
+ event,
325
+ element
326
+ ]);
327
+ }
328
+ //#endregion
329
+ //#region src/utils/usePrevious.ts
330
+ /**
331
+ * Returns the previous value of a variable.
332
+ * Useful for detecting prop changes between renders.
333
+ */
334
+ function usePrevious(value) {
335
+ const ref = useRef(void 0);
336
+ useEffect(() => {
337
+ ref.current = value;
338
+ }, [value]);
339
+ return ref.current;
340
+ }
341
+ //#endregion
342
+ //#region src/utils/extractAllowedOptionsUpdates.ts
343
+ /**
344
+ * Extract only the changed, mutable options from a new options object.
345
+ *
346
+ * Returns null if nothing changed. Warns if immutable keys are modified.
347
+ * Inspired by Stripe's extractAllowedOptionsUpdates pattern.
348
+ */
349
+ function extractAllowedOptionsUpdates(options, prevOptions, immutableKeys) {
350
+ if (!options) return null;
351
+ let updates = null;
352
+ for (const key of Object.keys(options)) {
353
+ const isUpdated = !prevOptions || !isEqual(options[key], prevOptions[key]);
354
+ if (immutableKeys.includes(key)) {
355
+ if (isUpdated && prevOptions) console.warn(`Unsupported prop change: options.${key} is not a mutable property.`);
356
+ continue;
357
+ }
358
+ if (!isUpdated) continue;
359
+ updates = {
360
+ ...updates || {},
361
+ [key]: options[key]
362
+ };
363
+ }
364
+ return updates;
365
+ }
366
+ function isEqual(a, b) {
367
+ if (a === b) return true;
368
+ if (a == null || b == null) return false;
369
+ if (typeof a !== typeof b) return false;
370
+ if (Array.isArray(a) && Array.isArray(b)) {
371
+ if (a.length !== b.length) return false;
372
+ return a.every((v, i) => isEqual(v, b[i]));
373
+ }
374
+ if (typeof a === "object" && typeof b === "object") {
375
+ const aObj = a;
376
+ const bObj = b;
377
+ const aKeys = Object.keys(aObj);
378
+ const bKeys = Object.keys(bObj);
379
+ if (aKeys.length !== bKeys.length) return false;
380
+ return aKeys.every((key) => isEqual(aObj[key], bObj[key]));
381
+ }
382
+ return false;
383
+ }
384
+ //#endregion
385
+ //#region src/utils/isServer.ts
386
+ /** True when running in a server environment (SSR/RSC) */
387
+ const isServer = typeof window === "undefined";
388
+ //#endregion
389
+ //#region src/PaymentElement.tsx
390
+ const IMMUTABLE_OPTS$1 = [];
391
+ const PaymentElementClient = ({ options, onReady, onChange, onLoaderStart, onLoadError, className, id }) => {
392
+ const elements = useElements();
393
+ const containerRef = useRef(null);
394
+ const elementRef = useRef(null);
395
+ const [element, setElement] = useState(null);
396
+ const prevOptions = usePrevious(options);
397
+ useLayoutEffect(() => {
398
+ if (elementRef.current !== null || !elements || !containerRef.current) return;
399
+ const el = elements.create("payment", options);
400
+ elementRef.current = el;
401
+ setElement(el);
402
+ el.mount(containerRef.current);
403
+ }, [elements]);
404
+ useLayoutEffect(() => {
405
+ return () => {
406
+ if (elementRef.current) {
407
+ try {
408
+ elementRef.current.destroy();
409
+ } catch {}
410
+ elementRef.current = null;
411
+ }
412
+ setElement(null);
413
+ };
414
+ }, []);
415
+ useEffect(() => {
416
+ if (!element || !options) return;
417
+ const updates = extractAllowedOptionsUpdates(options, prevOptions, IMMUTABLE_OPTS$1);
418
+ if (updates && "update" in element) element.update(updates);
419
+ }, [
420
+ options,
421
+ prevOptions,
422
+ element
423
+ ]);
424
+ useAttachEvent(element, "ready", onReady);
425
+ useAttachEvent(element, "change", onChange);
426
+ useAttachEvent(element, "loaderstart", onLoaderStart);
427
+ useAttachEvent(element, "loaderror", onLoadError);
428
+ return /* @__PURE__ */ jsx("div", {
429
+ ref: containerRef,
430
+ className,
431
+ id
432
+ });
433
+ };
434
+ /** SSR placeholder — renders an empty div for hydration */
435
+ const PaymentElementServer = ({ className, id }) => {
436
+ return /* @__PURE__ */ jsx("div", {
437
+ className,
438
+ id
439
+ });
440
+ };
441
+ /**
442
+ * Renders the XPay Payment Element — a full payment method selector with card form.
443
+ *
444
+ * Must be used inside `<XPayProvider>` with an `options` prop containing `clientSecret`.
445
+ * Handles mount/unmount lifecycle, StrictMode, and SSR automatically.
446
+ *
447
+ * @example
448
+ * ```tsx
449
+ * <XPayProvider xpay={xpayPromise} options={{ clientSecret }}>
450
+ * <PaymentElement
451
+ * onChange={(e) => setPaymentReady(e.complete)}
452
+ * onLoadError={(e) => console.error(e.message)}
453
+ * />
454
+ * </XPayProvider>
455
+ * ```
456
+ */
457
+ const PaymentElement = isServer ? PaymentElementServer : PaymentElementClient;
458
+ //#endregion
459
+ //#region src/CardElement.tsx
460
+ const IMMUTABLE_OPTS = [];
461
+ const CardElementClient = ({ options, onReady, onChange, onLoaderStart, onLoadError, className, id }) => {
462
+ const elements = useElements();
463
+ const containerRef = useRef(null);
464
+ const elementRef = useRef(null);
465
+ const [element, setElement] = useState(null);
466
+ const prevOptions = usePrevious(options);
467
+ useLayoutEffect(() => {
468
+ if (elementRef.current !== null || !elements || !containerRef.current) return;
469
+ const el = elements.create("card", options);
470
+ elementRef.current = el;
471
+ setElement(el);
472
+ el.mount(containerRef.current);
473
+ }, [elements]);
474
+ useLayoutEffect(() => {
475
+ return () => {
476
+ if (elementRef.current) {
477
+ try {
478
+ elementRef.current.destroy();
479
+ } catch {}
480
+ elementRef.current = null;
481
+ }
482
+ setElement(null);
483
+ };
484
+ }, []);
485
+ useEffect(() => {
486
+ if (!element || !options) return;
487
+ const updates = extractAllowedOptionsUpdates(options, prevOptions, IMMUTABLE_OPTS);
488
+ if (updates && "update" in element) element.update(updates);
489
+ }, [
490
+ options,
491
+ prevOptions,
492
+ element
493
+ ]);
494
+ useAttachEvent(element, "ready", onReady);
495
+ useAttachEvent(element, "change", onChange);
496
+ useAttachEvent(element, "loaderstart", onLoaderStart);
497
+ useAttachEvent(element, "loaderror", onLoadError);
498
+ return /* @__PURE__ */ jsx("div", {
499
+ ref: containerRef,
500
+ className,
501
+ id
502
+ });
503
+ };
504
+ const CardElementServer = ({ className, id }) => {
505
+ return /* @__PURE__ */ jsx("div", {
506
+ className,
507
+ id
508
+ });
509
+ };
510
+ /**
511
+ * Renders the XPay Card Element — a card-only form (number, expiry, CVV).
512
+ *
513
+ * Use this when you handle payment method selection yourself.
514
+ * Must be used inside `<XPayProvider>` with an `options` prop containing `clientSecret`.
515
+ *
516
+ * @example
517
+ * ```tsx
518
+ * <CardElement
519
+ * onChange={(e) => setCardReady(e.complete)}
520
+ * />
521
+ * ```
522
+ */
523
+ const CardElement = isServer ? CardElementServer : CardElementClient;
524
+ //#endregion
525
+ //#region src/CheckoutButton.tsx
526
+ /**
527
+ * Button that opens the drop-in checkout modal on click.
528
+ *
529
+ * Must be inside XPayProvider.
530
+ *
531
+ * @example
532
+ * ```tsx
533
+ * <XPayProvider xpay={xpay}>
534
+ * <CheckoutButton
535
+ * clientSecret="cs_test_abc_secret_xyz"
536
+ * checkoutOptions={{
537
+ * onComplete: (result) => router.push('/success'),
538
+ * onClose: () => console.log('Closed'),
539
+ * }}
540
+ * >
541
+ * Pay Now
542
+ * </CheckoutButton>
543
+ * </XPayProvider>
544
+ * ```
545
+ */
546
+ const CheckoutButton = ({ clientSecret, children = "Pay", checkoutOptions, className, disabled }) => {
547
+ const xpay = useXPay();
548
+ return /* @__PURE__ */ jsx("button", {
549
+ type: "button",
550
+ onClick: useCallback(() => {
551
+ if (!xpay) return;
552
+ xpay.checkout({
553
+ clientSecret,
554
+ mode: "modal",
555
+ ...checkoutOptions
556
+ }).open();
557
+ }, [
558
+ xpay,
559
+ clientSecret,
560
+ checkoutOptions
561
+ ]),
562
+ disabled: disabled || !xpay,
563
+ className,
564
+ children
565
+ });
566
+ };
567
+ //#endregion
568
+ export { CardElement, CheckoutButton, PaymentElement, XPayProvider, useCheckout, useConfirmPayment, useElements, useXPay };
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@xpayeg/react",
3
+ "version": "1.0.0",
4
+ "description": "XPay React components — PaymentElement, CardElement, CheckoutButton, and hooks",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "main": "./dist/index.cjs",
12
+ "module": "./dist/index.mjs",
13
+ "types": "./dist/index.d.mts",
14
+ "exports": {
15
+ ".": {
16
+ "import": {
17
+ "types": "./dist/index.d.mts",
18
+ "default": "./dist/index.mjs"
19
+ },
20
+ "require": {
21
+ "types": "./dist/index.d.cts",
22
+ "default": "./dist/index.cjs"
23
+ }
24
+ }
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsdown && rollup -c rollup.dts.config.mjs",
37
+ "lint:pkg": "publint && attw --pack .",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "peerDependencies": {
41
+ "@xpayeg/sdk": "workspace:*",
42
+ "react": "^18 || ^19",
43
+ "react-dom": "^18 || ^19"
44
+ },
45
+ "devDependencies": {
46
+ "@arethetypeswrong/cli": "^0.18.2",
47
+ "@rollup/plugin-node-resolve": "^16.0.3",
48
+ "@types/react": "catalog:",
49
+ "@types/react-dom": "catalog:",
50
+ "@xpay/tsconfig": "workspace:*",
51
+ "@xpayeg/sdk": "workspace:*",
52
+ "publint": "^0.3.21",
53
+ "react": "catalog:",
54
+ "react-dom": "catalog:",
55
+ "rollup": "^4.60.4",
56
+ "rollup-plugin-dts": "^6.4.1",
57
+ "tsdown": "^0.22.0",
58
+ "typescript": "catalog:"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/xpayeg/xpay-react.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/xpayeg/xpay-react/issues"
66
+ },
67
+ "homepage": "https://github.com/xpayeg/xpay-react#readme",
68
+ "keywords": [
69
+ "xpay",
70
+ "react",
71
+ "payments",
72
+ "egypt",
73
+ "checkout",
74
+ "sdk"
75
+ ]
76
+ }