@reevit/react 0.5.9 → 0.7.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 CHANGED
@@ -253,7 +253,7 @@ var ReevitAPIClient = class {
253
253
  allowed_providers: options?.allowedProviders
254
254
  };
255
255
  }
256
- const idempotencyKey = generateIdempotencyKey({
256
+ const idempotencyKey = config.idempotencyKey || generateIdempotencyKey({
257
257
  amount: config.amount,
258
258
  currency: config.currency,
259
259
  customer: config.email || config.metadata?.customerId || "",
@@ -432,6 +432,72 @@ function normalizeBranding(branding) {
432
432
  setIf("selectedBorderColor", getString(raw.selectedBorderColor ?? raw.selected_border_color));
433
433
  return theme;
434
434
  }
435
+ var INTENT_CACHE_TTL_MS = 10 * 60 * 1e3;
436
+ var intentCache = /* @__PURE__ */ new Map();
437
+ function pruneIntentCache(now = Date.now()) {
438
+ for (const [key, entry] of intentCache) {
439
+ if (entry.expiresAt <= now) {
440
+ intentCache.delete(key);
441
+ }
442
+ }
443
+ }
444
+ function getIntentCacheEntry(key) {
445
+ const entry = intentCache.get(key);
446
+ if (!entry) {
447
+ return void 0;
448
+ }
449
+ if (entry.expiresAt <= Date.now()) {
450
+ intentCache.delete(key);
451
+ return void 0;
452
+ }
453
+ return entry;
454
+ }
455
+ function setIntentCacheEntry(key, update) {
456
+ const now = Date.now();
457
+ const existing = getIntentCacheEntry(key);
458
+ const next = {
459
+ ...existing,
460
+ ...update,
461
+ expiresAt: now + INTENT_CACHE_TTL_MS
462
+ };
463
+ intentCache.set(key, next);
464
+ return next;
465
+ }
466
+ function clearIntentCacheEntry(key) {
467
+ intentCache.delete(key);
468
+ }
469
+ function buildIdempotencyPayload(config, method, options) {
470
+ const payload = {
471
+ amount: config.amount,
472
+ currency: config.currency,
473
+ email: config.email || "",
474
+ phone: config.phone || "",
475
+ customerName: config.customerName || "",
476
+ paymentLinkCode: config.paymentLinkCode || "",
477
+ paymentMethods: config.paymentMethods || [],
478
+ metadata: config.metadata || {},
479
+ customFields: config.customFields || {},
480
+ method: method || "",
481
+ preferredProvider: options?.preferredProvider || "",
482
+ allowedProviders: options?.allowedProviders || [],
483
+ publicKey: config.publicKey || ""
484
+ };
485
+ if (config.reference) {
486
+ payload.reference = config.reference;
487
+ }
488
+ return payload;
489
+ }
490
+ function resolveIntentIdentity(config, method, options) {
491
+ pruneIntentCache();
492
+ const idempotencyKey = config.idempotencyKey || generateIdempotencyKey(buildIdempotencyPayload(config, method, options));
493
+ const existing = getIntentCacheEntry(idempotencyKey);
494
+ const reference = config.reference || existing?.reference || generateReference();
495
+ const cacheEntry = setIntentCacheEntry(idempotencyKey, { reference });
496
+ return { idempotencyKey, reference, cacheEntry };
497
+ }
498
+ function isPaymentError(error) {
499
+ return typeof error === "object" && error !== null && "code" in error && "message" in error;
500
+ }
435
501
  function mapToPaymentIntent(response, config) {
436
502
  return {
437
503
  id: response.id,
@@ -465,14 +531,22 @@ function useReevit(options) {
465
531
  selectedMethod: config.initialPaymentIntent?.availableMethods?.length === 1 ? config.initialPaymentIntent.availableMethods[0] : null
466
532
  });
467
533
  const apiClientRef = useRef(null);
468
- const initializingRef = useRef(!!config.initialPaymentIntent);
534
+ const stateRef = useRef(state);
535
+ useEffect(() => {
536
+ stateRef.current = state;
537
+ }, [state]);
538
+ const currentIntentKeyRef = useRef(
539
+ config.initialPaymentIntent ? `initial:${config.initialPaymentIntent.id}` : null
540
+ );
469
541
  const initRequestIdRef = useRef(0);
470
542
  useEffect(() => {
471
543
  if (config.initialPaymentIntent) {
472
544
  if (!state.paymentIntent || state.paymentIntent.id !== config.initialPaymentIntent.id) {
473
545
  dispatch({ type: "INIT_SUCCESS", payload: config.initialPaymentIntent });
474
- initializingRef.current = true;
546
+ currentIntentKeyRef.current = `initial:${config.initialPaymentIntent.id}`;
475
547
  }
548
+ } else if (currentIntentKeyRef.current?.startsWith("initial:")) {
549
+ currentIntentKeyRef.current = null;
476
550
  }
477
551
  }, [config.initialPaymentIntent, state.paymentIntent?.id]);
478
552
  if (!apiClientRef.current) {
@@ -486,61 +560,60 @@ function useReevit(options) {
486
560
  }, [state.status, onStateChange]);
487
561
  const initialize = useCallback(
488
562
  async (method, options2) => {
489
- if (initializingRef.current) {
563
+ if (config.initialPaymentIntent) {
490
564
  return;
491
565
  }
492
- initializingRef.current = true;
493
- const requestId = ++initRequestIdRef.current;
494
- dispatch({ type: "INIT_START" });
566
+ let requestId = 0;
567
+ let intentKey = null;
495
568
  try {
496
569
  const apiClient = apiClientRef.current;
497
570
  if (!apiClient) {
498
571
  throw new Error("API client not initialized");
499
572
  }
500
- const reference = config.reference || generateReference();
501
573
  const country = detectCountryFromCurrency(config.currency);
502
574
  const defaultMethod = config.paymentMethods && config.paymentMethods.length === 1 ? config.paymentMethods[0] : void 0;
503
575
  const paymentMethod = method ?? defaultMethod;
504
- let data;
505
- let error;
506
- if (config.paymentLinkCode) {
507
- const idempotencyKey = generateIdempotencyKey({
508
- paymentLinkCode: config.paymentLinkCode,
509
- amount: config.amount,
510
- email: config.email || "",
511
- phone: config.phone || "",
512
- method: paymentMethod || "",
513
- provider: options2?.preferredProvider || options2?.allowedProviders?.[0] || ""
514
- });
515
- const response = await fetch(
516
- `${apiBaseUrl || DEFAULT_PUBLIC_API_BASE_URL}/v1/pay/${config.paymentLinkCode}/pay`,
517
- {
518
- method: "POST",
519
- headers: {
520
- "Content-Type": "application/json",
521
- "Idempotency-Key": idempotencyKey
522
- },
523
- body: JSON.stringify({
524
- amount: config.amount,
525
- email: config.email || "",
526
- name: config.customerName || "",
527
- phone: config.phone || "",
528
- method: paymentMethod,
529
- country,
530
- provider: options2?.preferredProvider || options2?.allowedProviders?.[0],
531
- custom_fields: config.customFields
532
- })
576
+ const identity = resolveIntentIdentity(config, paymentMethod, options2);
577
+ const { idempotencyKey, reference, cacheEntry } = identity;
578
+ intentKey = idempotencyKey;
579
+ if (currentIntentKeyRef.current === idempotencyKey && stateRef.current.paymentIntent) {
580
+ return;
581
+ }
582
+ currentIntentKeyRef.current = idempotencyKey;
583
+ requestId = ++initRequestIdRef.current;
584
+ if (stateRef.current.status !== "loading") {
585
+ dispatch({ type: "INIT_START" });
586
+ }
587
+ const requestIntent = async () => {
588
+ if (config.paymentLinkCode) {
589
+ const response = await fetch(
590
+ `${apiBaseUrl || DEFAULT_PUBLIC_API_BASE_URL}/v1/pay/${config.paymentLinkCode}/pay`,
591
+ {
592
+ method: "POST",
593
+ headers: {
594
+ "Content-Type": "application/json",
595
+ "Idempotency-Key": idempotencyKey
596
+ },
597
+ body: JSON.stringify({
598
+ amount: config.amount,
599
+ email: config.email || "",
600
+ name: config.customerName || "",
601
+ phone: config.phone || "",
602
+ method: paymentMethod,
603
+ country,
604
+ provider: options2?.preferredProvider || options2?.allowedProviders?.[0],
605
+ custom_fields: config.customFields
606
+ })
607
+ }
608
+ );
609
+ const responseData = await response.json().catch(() => ({}));
610
+ if (!response.ok) {
611
+ throw buildPaymentLinkError(response, responseData);
533
612
  }
534
- );
535
- const responseData = await response.json().catch(() => ({}));
536
- if (!response.ok) {
537
- error = buildPaymentLinkError(response, responseData);
538
- } else {
539
- data = responseData;
613
+ return responseData;
540
614
  }
541
- } else {
542
615
  const result = await apiClient.createPaymentIntent(
543
- { ...config, reference },
616
+ { ...config, reference, idempotencyKey },
544
617
  paymentMethod,
545
618
  country,
546
619
  {
@@ -548,35 +621,43 @@ function useReevit(options) {
548
621
  allowedProviders: options2?.allowedProviders
549
622
  }
550
623
  );
551
- data = result.data;
552
- error = result.error;
624
+ if (result.error) {
625
+ throw result.error;
626
+ }
627
+ if (!result.data) {
628
+ throw {
629
+ code: "INIT_FAILED",
630
+ message: "No data received from API",
631
+ recoverable: true
632
+ };
633
+ }
634
+ return result.data;
635
+ };
636
+ let data;
637
+ if (cacheEntry?.response) {
638
+ data = cacheEntry.response;
639
+ } else {
640
+ let intentPromise = cacheEntry?.promise;
641
+ if (!intentPromise) {
642
+ intentPromise = requestIntent();
643
+ setIntentCacheEntry(idempotencyKey, { promise: intentPromise });
644
+ }
645
+ data = await intentPromise;
646
+ setIntentCacheEntry(idempotencyKey, { response: data, promise: void 0 });
553
647
  }
554
648
  if (requestId !== initRequestIdRef.current) {
555
649
  return;
556
650
  }
557
- if (error) {
558
- dispatch({ type: "INIT_ERROR", payload: error });
559
- onError?.(error);
560
- return;
561
- }
562
- if (!data) {
563
- const noDataError = {
564
- code: "INIT_FAILED",
565
- message: "No data received from API",
566
- recoverable: true
567
- };
568
- dispatch({ type: "INIT_ERROR", payload: noDataError });
569
- onError?.(noDataError);
570
- initializingRef.current = false;
571
- return;
572
- }
573
- const paymentIntent = mapToPaymentIntent(data, { ...config, reference });
651
+ const paymentIntent = mapToPaymentIntent(data, { ...config, reference, idempotencyKey });
574
652
  dispatch({ type: "INIT_SUCCESS", payload: paymentIntent });
575
653
  } catch (err) {
654
+ if (intentKey) {
655
+ clearIntentCacheEntry(intentKey);
656
+ }
576
657
  if (requestId !== initRequestIdRef.current) {
577
658
  return;
578
659
  }
579
- const error = {
660
+ const error = isPaymentError(err) ? err : {
580
661
  code: "INIT_FAILED",
581
662
  message: err instanceof Error ? err.message : "Failed to initialize checkout",
582
663
  recoverable: true,
@@ -584,7 +665,6 @@ function useReevit(options) {
584
665
  };
585
666
  dispatch({ type: "INIT_ERROR", payload: error });
586
667
  onError?.(error);
587
- initializingRef.current = false;
588
668
  }
589
669
  },
590
670
  [config, onError, apiBaseUrl]
@@ -665,7 +745,7 @@ function useReevit(options) {
665
745
  } catch {
666
746
  }
667
747
  }
668
- initializingRef.current = false;
748
+ currentIntentKeyRef.current = null;
669
749
  initRequestIdRef.current += 1;
670
750
  dispatch({ type: "RESET" });
671
751
  }, [state.paymentIntent, state.status]);
@@ -731,30 +811,47 @@ function detectCountryFromCurrency(currency) {
731
811
  };
732
812
  return currencyToCountry[currency.toUpperCase()] || "GH";
733
813
  }
814
+ var MethodIcons = {
815
+ card: () => /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
816
+ /* @__PURE__ */ jsx("rect", { x: "1", y: "4", width: "22", height: "16", rx: "3" }),
817
+ /* @__PURE__ */ jsx("line", { x1: "1", y1: "10", x2: "23", y2: "10" }),
818
+ /* @__PURE__ */ jsx("line", { x1: "5", y1: "15", x2: "9", y2: "15" })
819
+ ] }),
820
+ mobile_money: () => /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
821
+ /* @__PURE__ */ jsx("rect", { x: "5", y: "2", width: "14", height: "20", rx: "3" }),
822
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "18", x2: "12", y2: "18.01", strokeWidth: "2", strokeLinecap: "round" })
823
+ ] }),
824
+ bank_transfer: () => /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
825
+ /* @__PURE__ */ jsx("path", { d: "M3 21h18" }),
826
+ /* @__PURE__ */ jsx("path", { d: "M3 10h18" }),
827
+ /* @__PURE__ */ jsx("path", { d: "M12 3l9 7H3l9-7z" }),
828
+ /* @__PURE__ */ jsx("path", { d: "M6 10v8" }),
829
+ /* @__PURE__ */ jsx("path", { d: "M10 10v8" }),
830
+ /* @__PURE__ */ jsx("path", { d: "M14 10v8" }),
831
+ /* @__PURE__ */ jsx("path", { d: "M18 10v8" })
832
+ ] }),
833
+ apple_pay: () => /* @__PURE__ */ jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx("path", { d: "M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.53 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" }) }),
834
+ google_pay: () => /* @__PURE__ */ jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", children: /* @__PURE__ */ jsx("path", { d: "M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.479 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.085-1.39-.189-1.989H12.24z", fill: "currentColor" }) })
835
+ };
734
836
  var methodConfig = {
735
837
  card: {
736
838
  label: "Card",
737
- icon: "\u{1F4B3}",
738
839
  description: "Pay with Visa, Mastercard, or other cards"
739
840
  },
740
841
  mobile_money: {
741
842
  label: "Mobile Money",
742
- icon: "\u{1F4F1}",
743
843
  description: "MTN, Telecel, AirtelTigo Money"
744
844
  },
745
845
  bank_transfer: {
746
846
  label: "Bank Transfer",
747
- icon: "\u{1F3E6}",
748
847
  description: "Pay directly from your bank account"
749
848
  },
750
849
  apple_pay: {
751
850
  label: "Apple Pay",
752
- icon: "\u{1F34E}",
753
851
  description: "Pay with Apple Pay"
754
852
  },
755
853
  google_pay: {
756
854
  label: "Google Pay",
757
- icon: "\u{1F916}",
758
855
  description: "Pay with Google Pay"
759
856
  }
760
857
  };
@@ -804,7 +901,6 @@ function PaymentMethodSelector({
804
901
  ),
805
902
  style: selectedTheme?.backgroundColor ? { backgroundColor: selectedTheme.backgroundColor } : void 0,
806
903
  children: methods.map((method, index) => {
807
- const config = methodConfig[method];
808
904
  const isSelected = selectedMethod === method;
809
905
  const methodLabel = getMethodLabel(method);
810
906
  const methodDescription = getMethodDescription(method);
@@ -835,7 +931,7 @@ function PaymentMethodSelector({
835
931
  className: "reevit-method-option__logo-img"
836
932
  },
837
933
  i
838
- )) }) : /* @__PURE__ */ jsx("span", { className: "reevit-method-option__icon", children: config.icon }) }),
934
+ )) }) : /* @__PURE__ */ jsx("span", { className: "reevit-method-option__icon", children: MethodIcons[method]() }) }),
839
935
  /* @__PURE__ */ jsxs("div", { className: "reevit-method-option__content", children: [
840
936
  /* @__PURE__ */ jsx("span", { className: "reevit-method-option__label", style: selectedTheme?.textColor ? { color: selectedTheme.textColor } : void 0, children: methodLabel }),
841
937
  !isGrid && /* @__PURE__ */ jsx("span", { className: "reevit-method-option__description", style: selectedTheme?.descriptionColor ? { color: selectedTheme.descriptionColor } : void 0, children: methodDescription })
@@ -2395,7 +2491,11 @@ function ReevitCheckout({
2395
2491
  const renderContent = () => {
2396
2492
  if (status === "loading" || status === "processing") {
2397
2493
  return /* @__PURE__ */ jsxs("div", { className: "reevit-loading reevit-animate-fade-in", children: [
2398
- /* @__PURE__ */ jsx("div", { className: "reevit-spinner" }),
2494
+ /* @__PURE__ */ jsxs("div", { className: "reevit-dot-pulse", children: [
2495
+ /* @__PURE__ */ jsx("span", { className: "reevit-dot-pulse__dot" }),
2496
+ /* @__PURE__ */ jsx("span", { className: "reevit-dot-pulse__dot" }),
2497
+ /* @__PURE__ */ jsx("span", { className: "reevit-dot-pulse__dot" })
2498
+ ] }),
2399
2499
  /* @__PURE__ */ jsx("p", { children: status === "loading" ? "Preparing checkout..." : "Processing payment..." })
2400
2500
  ] });
2401
2501
  }
@@ -2411,12 +2511,22 @@ function ReevitCheckout({
2411
2511
  "Reference: ",
2412
2512
  result.reference
2413
2513
  ] }),
2414
- /* @__PURE__ */ jsx("p", { className: "reevit-success__redirect", children: "Redirecting in a moment..." })
2514
+ /* @__PURE__ */ jsx("p", { className: "reevit-success__redirect", children: "Redirecting in a moment..." }),
2515
+ /* @__PURE__ */ jsx(
2516
+ "div",
2517
+ {
2518
+ className: "reevit-success__countdown",
2519
+ style: { animationDuration: `${successDelayMs}ms` }
2520
+ }
2521
+ )
2415
2522
  ] });
2416
2523
  }
2417
2524
  if (status === "failed" && error && !error.recoverable) {
2418
2525
  return /* @__PURE__ */ jsxs("div", { className: "reevit-error reevit-animate-fade-in", children: [
2419
- /* @__PURE__ */ jsx("div", { className: "reevit-error__icon", children: "\u2715" }),
2526
+ /* @__PURE__ */ jsx("div", { className: "reevit-error__icon", children: /* @__PURE__ */ jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
2527
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
2528
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
2529
+ ] }) }),
2420
2530
  /* @__PURE__ */ jsx("h3", { children: "Payment Failed" }),
2421
2531
  /* @__PURE__ */ jsx("p", { children: error.message }),
2422
2532
  /* @__PURE__ */ jsx("button", { className: "reevit-btn reevit-btn--primary", onClick: handleBack, children: "Try Again" })
@@ -2538,7 +2648,11 @@ function ReevitCheckout({
2538
2648
  );
2539
2649
  default:
2540
2650
  return /* @__PURE__ */ jsxs("div", { className: "reevit-error", children: [
2541
- /* @__PURE__ */ jsx("div", { className: "reevit-error__icon", children: "\u26A0\uFE0F" }),
2651
+ /* @__PURE__ */ jsx("div", { className: "reevit-error__icon", children: /* @__PURE__ */ jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2652
+ /* @__PURE__ */ jsx("path", { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" }),
2653
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "9", x2: "12", y2: "13" }),
2654
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" })
2655
+ ] }) }),
2542
2656
  /* @__PURE__ */ jsx("h3", { children: "Provider Not Supported" }),
2543
2657
  /* @__PURE__ */ jsxs("p", { children: [
2544
2658
  "Provider (",