@reevit/react 0.5.8 → 0.6.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
@@ -174,7 +174,7 @@ var ReevitAPIClient = class {
174
174
  const headers = {
175
175
  "Content-Type": "application/json",
176
176
  "X-Reevit-Client": "@reevit/react",
177
- "X-Reevit-Client-Version": "0.3.2"
177
+ "X-Reevit-Client-Version": "0.5.9"
178
178
  };
179
179
  if (this.publicKey) {
180
180
  headers["X-Reevit-Key"] = this.publicKey;
@@ -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]);
@@ -2245,19 +2325,6 @@ function ReevitCheckout({
2245
2325
  selectMethod(activeProvider.methods[0]);
2246
2326
  }
2247
2327
  }, [activeProvider, selectedMethod, selectMethod]);
2248
- useEffect(() => {
2249
- if (isOpen && selectedMethod && paymentIntent && !showPSPBridge) {
2250
- const psp = (selectedProvider || paymentIntent.recommendedPsp || "paystack").toLowerCase();
2251
- const needsPhone = psp.includes("mpesa");
2252
- if (selectedMethod === "card") {
2253
- setShowPSPBridge(true);
2254
- } else if (selectedMethod === "mobile_money") {
2255
- if (!needsPhone || (momoData?.phone || phone)) {
2256
- setShowPSPBridge(true);
2257
- }
2258
- }
2259
- }
2260
- }, [isOpen, selectedMethod, showPSPBridge, paymentIntent, momoData, phone, selectedProvider]);
2261
2328
  const handleOpen = useCallback(() => {
2262
2329
  if (controlledIsOpen !== void 0) return;
2263
2330
  setIsOpen(true);