@blocklet/payment-react-headless 1.26.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/.eslintrc.js +18 -0
- package/build.config.ts +30 -0
- package/es/checkout/context/CheckoutProvider.d.ts +6 -0
- package/es/checkout/context/CheckoutProvider.js +209 -0
- package/es/checkout/context/CustomerFormContext.d.ts +4 -0
- package/es/checkout/context/CustomerFormContext.js +9 -0
- package/es/checkout/context/ExchangeRateContext.d.ts +11 -0
- package/es/checkout/context/ExchangeRateContext.js +9 -0
- package/es/checkout/context/PaymentMethodContext.d.ts +26 -0
- package/es/checkout/context/PaymentMethodContext.js +9 -0
- package/es/checkout/context/SessionContext.d.ts +45 -0
- package/es/checkout/context/SessionContext.js +9 -0
- package/es/checkout/context/SubmitContext.d.ts +4 -0
- package/es/checkout/context/SubmitContext.js +9 -0
- package/es/checkout/context/index.d.ts +6 -0
- package/es/checkout/context/index.js +6 -0
- package/es/checkout/core/billingInterval.d.ts +15 -0
- package/es/checkout/core/billingInterval.js +36 -0
- package/es/checkout/core/crossSell.d.ts +4 -0
- package/es/checkout/core/crossSell.js +30 -0
- package/es/checkout/core/customerForm.d.ts +5 -0
- package/es/checkout/core/customerForm.js +105 -0
- package/es/checkout/core/exchangeRate.d.ts +11 -0
- package/es/checkout/core/exchangeRate.js +25 -0
- package/es/checkout/core/index.d.ts +10 -0
- package/es/checkout/core/index.js +55 -0
- package/es/checkout/core/lineItems.d.ts +7 -0
- package/es/checkout/core/lineItems.js +59 -0
- package/es/checkout/core/paymentMethod.d.ts +23 -0
- package/es/checkout/core/paymentMethod.js +85 -0
- package/es/checkout/core/pricing.d.ts +32 -0
- package/es/checkout/core/pricing.js +221 -0
- package/es/checkout/core/promotion.d.ts +10 -0
- package/es/checkout/core/promotion.js +39 -0
- package/es/checkout/core/session.d.ts +26 -0
- package/es/checkout/core/session.js +50 -0
- package/es/checkout/core/submit.d.ts +40 -0
- package/es/checkout/core/submit.js +66 -0
- package/es/checkout/hooks/index.d.ts +34 -0
- package/es/checkout/hooks/index.js +19 -0
- package/es/checkout/hooks/useBillingInterval.d.ts +14 -0
- package/es/checkout/hooks/useBillingInterval.js +50 -0
- package/es/checkout/hooks/useCheckout.d.ts +2 -0
- package/es/checkout/hooks/useCheckout.js +212 -0
- package/es/checkout/hooks/useCheckoutSession.d.ts +58 -0
- package/es/checkout/hooks/useCheckoutSession.js +107 -0
- package/es/checkout/hooks/useCheckoutStatus.d.ts +10 -0
- package/es/checkout/hooks/useCheckoutStatus.js +16 -0
- package/es/checkout/hooks/useCrossSell.d.ts +8 -0
- package/es/checkout/hooks/useCrossSell.js +57 -0
- package/es/checkout/hooks/useCustomerForm.d.ts +14 -0
- package/es/checkout/hooks/useCustomerForm.js +116 -0
- package/es/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
- package/es/checkout/hooks/useCustomerFormFeature.js +4 -0
- package/es/checkout/hooks/useExchangeRate.d.ts +11 -0
- package/es/checkout/hooks/useExchangeRate.js +15 -0
- package/es/checkout/hooks/useLineItems.d.ts +22 -0
- package/es/checkout/hooks/useLineItems.js +139 -0
- package/es/checkout/hooks/usePaymentMethod.d.ts +26 -0
- package/es/checkout/hooks/usePaymentMethod.js +101 -0
- package/es/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
- package/es/checkout/hooks/usePaymentMethodFeature.js +4 -0
- package/es/checkout/hooks/usePricing.d.ts +57 -0
- package/es/checkout/hooks/usePricing.js +174 -0
- package/es/checkout/hooks/usePricingFeature.d.ts +28 -0
- package/es/checkout/hooks/usePricingFeature.js +36 -0
- package/es/checkout/hooks/useProduct.d.ts +32 -0
- package/es/checkout/hooks/useProduct.js +5 -0
- package/es/checkout/hooks/usePromotion.d.ts +12 -0
- package/es/checkout/hooks/usePromotion.js +48 -0
- package/es/checkout/hooks/useSlippage.d.ts +8 -0
- package/es/checkout/hooks/useSlippage.js +29 -0
- package/es/checkout/hooks/useSubmit.d.ts +38 -0
- package/es/checkout/hooks/useSubmit.js +493 -0
- package/es/checkout/hooks/useSubmitFeature.d.ts +2 -0
- package/es/checkout/hooks/useSubmitFeature.js +4 -0
- package/es/checkout/hooks/useUpsell.d.ts +5 -0
- package/es/checkout/hooks/useUpsell.js +25 -0
- package/es/checkout/index.d.ts +37 -0
- package/es/checkout/index.js +28 -0
- package/es/checkout/types.d.ts +262 -0
- package/es/checkout/types.js +0 -0
- package/es/index.d.ts +1 -0
- package/es/index.js +28 -0
- package/es/shared/api.d.ts +41 -0
- package/es/shared/api.js +81 -0
- package/es/shared/format.d.ts +38 -0
- package/es/shared/format.js +229 -0
- package/es/shared/polling.d.ts +15 -0
- package/es/shared/polling.js +20 -0
- package/es/shared/types.d.ts +10 -0
- package/es/shared/types.js +0 -0
- package/es/shared/validation.d.ts +38 -0
- package/es/shared/validation.js +190 -0
- package/es/types/checkout-augmented.d.ts +42 -0
- package/es/types/checkout-augmented.js +17 -0
- package/es/types/external.d.ts +18 -0
- package/examples/01-basic-checkout.tsx +159 -0
- package/examples/01-credit-recharge.tsx +19 -0
- package/examples/02-subscription.tsx +40 -0
- package/examples/03-upsell.tsx +60 -0
- package/examples/04-cross-sell.tsx +54 -0
- package/examples/05-full-checkout.tsx +126 -0
- package/jest.config.js +15 -0
- package/lib/checkout/context/CheckoutProvider.d.ts +6 -0
- package/lib/checkout/context/CheckoutProvider.js +181 -0
- package/lib/checkout/context/CustomerFormContext.d.ts +4 -0
- package/lib/checkout/context/CustomerFormContext.js +16 -0
- package/lib/checkout/context/ExchangeRateContext.d.ts +11 -0
- package/lib/checkout/context/ExchangeRateContext.js +16 -0
- package/lib/checkout/context/PaymentMethodContext.d.ts +26 -0
- package/lib/checkout/context/PaymentMethodContext.js +16 -0
- package/lib/checkout/context/SessionContext.d.ts +45 -0
- package/lib/checkout/context/SessionContext.js +16 -0
- package/lib/checkout/context/SubmitContext.d.ts +4 -0
- package/lib/checkout/context/SubmitContext.js +16 -0
- package/lib/checkout/context/index.d.ts +6 -0
- package/lib/checkout/context/index.js +77 -0
- package/lib/checkout/core/billingInterval.d.ts +15 -0
- package/lib/checkout/core/billingInterval.js +42 -0
- package/lib/checkout/core/crossSell.d.ts +4 -0
- package/lib/checkout/core/crossSell.js +43 -0
- package/lib/checkout/core/customerForm.d.ts +5 -0
- package/lib/checkout/core/customerForm.js +106 -0
- package/lib/checkout/core/exchangeRate.d.ts +11 -0
- package/lib/checkout/core/exchangeRate.js +45 -0
- package/lib/checkout/core/index.d.ts +10 -0
- package/lib/checkout/core/index.js +297 -0
- package/lib/checkout/core/lineItems.d.ts +7 -0
- package/lib/checkout/core/lineItems.js +76 -0
- package/lib/checkout/core/paymentMethod.d.ts +23 -0
- package/lib/checkout/core/paymentMethod.js +114 -0
- package/lib/checkout/core/pricing.d.ts +32 -0
- package/lib/checkout/core/pricing.js +216 -0
- package/lib/checkout/core/promotion.d.ts +10 -0
- package/lib/checkout/core/promotion.js +62 -0
- package/lib/checkout/core/session.d.ts +26 -0
- package/lib/checkout/core/session.js +58 -0
- package/lib/checkout/core/submit.d.ts +40 -0
- package/lib/checkout/core/submit.js +84 -0
- package/lib/checkout/hooks/index.d.ts +34 -0
- package/lib/checkout/hooks/index.js +138 -0
- package/lib/checkout/hooks/useBillingInterval.d.ts +14 -0
- package/lib/checkout/hooks/useBillingInterval.js +63 -0
- package/lib/checkout/hooks/useCheckout.d.ts +2 -0
- package/lib/checkout/hooks/useCheckout.js +190 -0
- package/lib/checkout/hooks/useCheckoutSession.d.ts +58 -0
- package/lib/checkout/hooks/useCheckoutSession.js +119 -0
- package/lib/checkout/hooks/useCheckoutStatus.d.ts +10 -0
- package/lib/checkout/hooks/useCheckoutStatus.js +28 -0
- package/lib/checkout/hooks/useCrossSell.d.ts +8 -0
- package/lib/checkout/hooks/useCrossSell.js +75 -0
- package/lib/checkout/hooks/useCustomerForm.d.ts +14 -0
- package/lib/checkout/hooks/useCustomerForm.js +135 -0
- package/lib/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
- package/lib/checkout/hooks/useCustomerFormFeature.js +10 -0
- package/lib/checkout/hooks/useExchangeRate.d.ts +11 -0
- package/lib/checkout/hooks/useExchangeRate.js +29 -0
- package/lib/checkout/hooks/useLineItems.d.ts +22 -0
- package/lib/checkout/hooks/useLineItems.js +142 -0
- package/lib/checkout/hooks/usePaymentMethod.d.ts +26 -0
- package/lib/checkout/hooks/usePaymentMethod.js +101 -0
- package/lib/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
- package/lib/checkout/hooks/usePaymentMethodFeature.js +10 -0
- package/lib/checkout/hooks/usePricing.d.ts +57 -0
- package/lib/checkout/hooks/usePricing.js +168 -0
- package/lib/checkout/hooks/usePricingFeature.d.ts +28 -0
- package/lib/checkout/hooks/usePricingFeature.js +48 -0
- package/lib/checkout/hooks/useProduct.d.ts +32 -0
- package/lib/checkout/hooks/useProduct.js +21 -0
- package/lib/checkout/hooks/usePromotion.d.ts +12 -0
- package/lib/checkout/hooks/usePromotion.js +57 -0
- package/lib/checkout/hooks/useSlippage.d.ts +8 -0
- package/lib/checkout/hooks/useSlippage.js +39 -0
- package/lib/checkout/hooks/useSubmit.d.ts +38 -0
- package/lib/checkout/hooks/useSubmit.js +504 -0
- package/lib/checkout/hooks/useSubmitFeature.d.ts +2 -0
- package/lib/checkout/hooks/useSubmitFeature.js +10 -0
- package/lib/checkout/hooks/useUpsell.d.ts +5 -0
- package/lib/checkout/hooks/useUpsell.js +40 -0
- package/lib/checkout/index.d.ts +37 -0
- package/lib/checkout/index.js +182 -0
- package/lib/checkout/types.d.ts +262 -0
- package/lib/checkout/types.js +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +162 -0
- package/lib/shared/api.d.ts +41 -0
- package/lib/shared/api.js +88 -0
- package/lib/shared/format.d.ts +38 -0
- package/lib/shared/format.js +262 -0
- package/lib/shared/polling.d.ts +15 -0
- package/lib/shared/polling.js +32 -0
- package/lib/shared/types.d.ts +10 -0
- package/lib/shared/types.js +1 -0
- package/lib/shared/validation.d.ts +38 -0
- package/lib/shared/validation.js +212 -0
- package/lib/types/checkout-augmented.d.ts +42 -0
- package/lib/types/checkout-augmented.js +24 -0
- package/lib/types/external.d.ts +18 -0
- package/package.json +64 -0
- package/src/checkout/context/CheckoutProvider.tsx +269 -0
- package/src/checkout/context/CustomerFormContext.ts +14 -0
- package/src/checkout/context/ExchangeRateContext.ts +21 -0
- package/src/checkout/context/PaymentMethodContext.ts +36 -0
- package/src/checkout/context/SessionContext.ts +49 -0
- package/src/checkout/context/SubmitContext.ts +14 -0
- package/src/checkout/context/index.ts +6 -0
- package/src/checkout/core/billingInterval.ts +62 -0
- package/src/checkout/core/crossSell.ts +52 -0
- package/src/checkout/core/customerForm.ts +122 -0
- package/src/checkout/core/exchangeRate.ts +38 -0
- package/src/checkout/core/index.ts +60 -0
- package/src/checkout/core/lineItems.ts +106 -0
- package/src/checkout/core/paymentMethod.ts +113 -0
- package/src/checkout/core/pricing.ts +347 -0
- package/src/checkout/core/promotion.ts +59 -0
- package/src/checkout/core/session.ts +62 -0
- package/src/checkout/core/submit.ts +109 -0
- package/src/checkout/hooks/index.ts +41 -0
- package/src/checkout/hooks/useBillingInterval.ts +71 -0
- package/src/checkout/hooks/useCheckout.ts +267 -0
- package/src/checkout/hooks/useCheckoutSession.ts +217 -0
- package/src/checkout/hooks/useCheckoutStatus.ts +31 -0
- package/src/checkout/hooks/useCrossSell.ts +80 -0
- package/src/checkout/hooks/useCustomerForm.ts +156 -0
- package/src/checkout/hooks/useCustomerFormFeature.ts +7 -0
- package/src/checkout/hooks/useExchangeRate.ts +28 -0
- package/src/checkout/hooks/useLineItems.ts +191 -0
- package/src/checkout/hooks/usePaymentMethod.ts +165 -0
- package/src/checkout/hooks/usePaymentMethodFeature.ts +8 -0
- package/src/checkout/hooks/usePricing.ts +274 -0
- package/src/checkout/hooks/usePricingFeature.ts +73 -0
- package/src/checkout/hooks/useProduct.ts +32 -0
- package/src/checkout/hooks/usePromotion.ts +67 -0
- package/src/checkout/hooks/useSlippage.ts +39 -0
- package/src/checkout/hooks/useSubmit.ts +684 -0
- package/src/checkout/hooks/useSubmitFeature.ts +7 -0
- package/src/checkout/hooks/useUpsell.ts +35 -0
- package/src/checkout/index.ts +65 -0
- package/src/checkout/types.ts +292 -0
- package/src/index.ts +64 -0
- package/src/shared/api.ts +118 -0
- package/src/shared/format.ts +318 -0
- package/src/shared/polling.ts +49 -0
- package/src/shared/types.ts +13 -0
- package/src/shared/validation.ts +254 -0
- package/src/types/checkout-augmented.ts +77 -0
- package/src/types/external.d.ts +18 -0
- package/tools/jest.js +1 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
import { WsClient } from '@arcblock/ws';
|
|
4
|
+
|
|
5
|
+
import type { TLineItemExpanded } from '@blocklet/payment-types';
|
|
6
|
+
|
|
7
|
+
import api, { API } from '../../shared/api';
|
|
8
|
+
import { waitForCheckoutComplete, generateIdempotencyKey } from '../../shared/polling';
|
|
9
|
+
import type { CheckoutSessionRuntime } from '../../types/checkout-augmented';
|
|
10
|
+
import { getErrorMessage, getAxiosErrorDetails } from '../../types/checkout-augmented';
|
|
11
|
+
import type { SessionData } from './useCheckoutSession';
|
|
12
|
+
import type { SubmitStatus, SubmitContext, CheckoutResult, CheckoutFormData } from '../types';
|
|
13
|
+
import {
|
|
14
|
+
RELAY_SOCKET_PREFIX,
|
|
15
|
+
getAppId,
|
|
16
|
+
getRelayChannel,
|
|
17
|
+
getRelayProtocol,
|
|
18
|
+
getSocketHost,
|
|
19
|
+
getSessionFingerprint,
|
|
20
|
+
buildSubmitPayload,
|
|
21
|
+
isQuoteError,
|
|
22
|
+
abortStripePayment,
|
|
23
|
+
submitCheckout,
|
|
24
|
+
confirmFastCheckout,
|
|
25
|
+
} from '../core/submit';
|
|
26
|
+
|
|
27
|
+
export interface VendorStatus {
|
|
28
|
+
success: boolean;
|
|
29
|
+
status: 'delivered' | 'pending' | 'failed';
|
|
30
|
+
progress: number;
|
|
31
|
+
message: string;
|
|
32
|
+
appUrl?: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
vendorType: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VendorOrderStatus {
|
|
39
|
+
payment_status: string;
|
|
40
|
+
session_status: string;
|
|
41
|
+
vendors: VendorStatus[];
|
|
42
|
+
error: string | null;
|
|
43
|
+
isAllCompleted: boolean;
|
|
44
|
+
hasFailed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseSubmitReturn {
|
|
48
|
+
status: SubmitStatus;
|
|
49
|
+
context: SubmitContext;
|
|
50
|
+
execute: () => Promise<void>;
|
|
51
|
+
confirm: () => Promise<void>;
|
|
52
|
+
cancel: () => void;
|
|
53
|
+
result: CheckoutResult | null;
|
|
54
|
+
retry: () => Promise<void>;
|
|
55
|
+
reset: () => void;
|
|
56
|
+
stripeConfirm: () => Promise<void>;
|
|
57
|
+
stripeCancel: () => Promise<void>;
|
|
58
|
+
vendorStatus: VendorOrderStatus | null;
|
|
59
|
+
/** Whether the checkout config is locked (user clicked "Connect and Pay", pending login/submit) */
|
|
60
|
+
locked: boolean;
|
|
61
|
+
/** Lock the checkout config — prevents quantity/currency/method changes */
|
|
62
|
+
lock: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useSubmit(
|
|
66
|
+
sessionData: SessionData | null,
|
|
67
|
+
sessionId: string,
|
|
68
|
+
currencyId: string | null,
|
|
69
|
+
isStripe: boolean,
|
|
70
|
+
isCredit: boolean,
|
|
71
|
+
isDonation: boolean,
|
|
72
|
+
formValues: CheckoutFormData,
|
|
73
|
+
validateForm: () => Promise<boolean>,
|
|
74
|
+
refreshSession: (force?: boolean) => Promise<void>,
|
|
75
|
+
updateSessionData?: (data: SessionData) => void
|
|
76
|
+
): UseSubmitReturn {
|
|
77
|
+
const session = sessionData?.checkoutSession;
|
|
78
|
+
|
|
79
|
+
const [status, setStatus] = useState<SubmitStatus>('idle');
|
|
80
|
+
const [context, setContext] = useState<SubmitContext>(null);
|
|
81
|
+
const [result, setResult] = useState<CheckoutResult | null>(null);
|
|
82
|
+
const [locked, setLocked] = useState(false);
|
|
83
|
+
|
|
84
|
+
const lock = useMemoizedFn(() => setLocked(true));
|
|
85
|
+
const unlock = useMemoizedFn(() => setLocked(false));
|
|
86
|
+
|
|
87
|
+
const mountedRef = useRef(true);
|
|
88
|
+
const pollingAbortRef = useRef(false);
|
|
89
|
+
const socketRef = useRef<WsClient | null>(null);
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
const subscriptionRef = useRef<{
|
|
92
|
+
channel: string;
|
|
93
|
+
on: (event: string, fn: (...args: any[]) => void) => void;
|
|
94
|
+
off?: (event: string, fn: (...args: any[]) => void) => void;
|
|
95
|
+
} | null>(null);
|
|
96
|
+
const lastPayloadRef = useRef<Record<string, unknown> | null>(null);
|
|
97
|
+
const [vendorStatus, setVendorStatus] = useState<VendorOrderStatus | null>(null);
|
|
98
|
+
const vendorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
99
|
+
const quoteRetryCountRef = useRef(0);
|
|
100
|
+
const MAX_QUOTE_RETRIES = 2;
|
|
101
|
+
|
|
102
|
+
// Stable idempotency key: reuse same Quote on retry (intent: "retry with same Quote")
|
|
103
|
+
// Only regenerate when payment context truly changes (currency, line items)
|
|
104
|
+
const idempotencyKeyRef = useRef<string>('');
|
|
105
|
+
const sessionFingerprintRef = useRef<string>('');
|
|
106
|
+
|
|
107
|
+
// Auto-detect already-completed session on load/refresh (e.g. page refresh after payment)
|
|
108
|
+
const completedDetectedRef = useRef(false);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (completedDetectedRef.current) return;
|
|
111
|
+
if (!session || !sessionData) return;
|
|
112
|
+
|
|
113
|
+
const isSessionComplete =
|
|
114
|
+
(session as any).status === 'complete' &&
|
|
115
|
+
['paid', 'no_payment_required'].includes((session as any).payment_status);
|
|
116
|
+
|
|
117
|
+
if (isSessionComplete && status === 'idle') {
|
|
118
|
+
completedDetectedRef.current = true;
|
|
119
|
+
setResult(sessionData as CheckoutResult);
|
|
120
|
+
setStatus('completed');
|
|
121
|
+
}
|
|
122
|
+
}, [session, sessionData]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
123
|
+
|
|
124
|
+
// WebSocket: connect and listen for checkout.session.completed
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
mountedRef.current = true;
|
|
127
|
+
|
|
128
|
+
if (!sessionId || !getAppId()) return undefined;
|
|
129
|
+
|
|
130
|
+
const socket = new WsClient(`${getRelayProtocol()}//${getSocketHost()}${RELAY_SOCKET_PREFIX}`, {
|
|
131
|
+
longpollerTimeout: 5000,
|
|
132
|
+
heartbeatIntervalMs: 30 * 1000,
|
|
133
|
+
});
|
|
134
|
+
socket.connect();
|
|
135
|
+
socketRef.current = socket;
|
|
136
|
+
|
|
137
|
+
const sub = socket.subscribe(getRelayChannel('events'));
|
|
138
|
+
sub.channel = 'events';
|
|
139
|
+
subscriptionRef.current = sub;
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
mountedRef.current = false;
|
|
143
|
+
pollingAbortRef.current = true;
|
|
144
|
+
|
|
145
|
+
if (subscriptionRef.current) {
|
|
146
|
+
socket.unsubscribe(getRelayChannel(subscriptionRef.current.channel));
|
|
147
|
+
subscriptionRef.current = null;
|
|
148
|
+
}
|
|
149
|
+
socket.disconnect();
|
|
150
|
+
socketRef.current = null;
|
|
151
|
+
};
|
|
152
|
+
}, [sessionId]);
|
|
153
|
+
|
|
154
|
+
// Listen for checkout completion via WebSocket
|
|
155
|
+
const handleWsComplete = useMemoizedFn(async ({ response }: { response: { id?: string } }) => {
|
|
156
|
+
if (response.id === sessionId && status !== 'completed') {
|
|
157
|
+
pollingAbortRef.current = true;
|
|
158
|
+
await handleCompletion();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const sub = subscriptionRef.current;
|
|
164
|
+
if (sub) {
|
|
165
|
+
sub.on('checkout.session.completed', handleWsComplete);
|
|
166
|
+
}
|
|
167
|
+
return () => {
|
|
168
|
+
if (sub) {
|
|
169
|
+
try {
|
|
170
|
+
sub.off?.('checkout.session.completed', handleWsComplete);
|
|
171
|
+
} catch {
|
|
172
|
+
// Ignore
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}, [subscriptionRef.current, status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
177
|
+
|
|
178
|
+
// Common completion handler
|
|
179
|
+
const handleCompletion = useMemoizedFn(async () => {
|
|
180
|
+
try {
|
|
181
|
+
const completionResult = await waitForCheckoutComplete(sessionId);
|
|
182
|
+
if (!mountedRef.current) return;
|
|
183
|
+
setResult(completionResult);
|
|
184
|
+
// Update session context so downstream consumers (e.g. SuccessView) have fresh data
|
|
185
|
+
updateSessionData?.(completionResult as SessionData);
|
|
186
|
+
setStatus('completed');
|
|
187
|
+
setContext(null);
|
|
188
|
+
} catch (err: unknown) {
|
|
189
|
+
if (!mountedRef.current) return;
|
|
190
|
+
setStatus('failed');
|
|
191
|
+
setContext({ type: 'error', message: getErrorMessage(err) || 'Payment verification failed' });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Vendor order polling after completion
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (status !== 'completed' || !sessionId) return undefined;
|
|
198
|
+
|
|
199
|
+
const hasVendors = (session?.line_items as TLineItemExpanded[] | undefined)?.some(
|
|
200
|
+
(item) => !!item?.price?.product?.vendor_config?.length
|
|
201
|
+
);
|
|
202
|
+
if (!hasVendors) return undefined;
|
|
203
|
+
|
|
204
|
+
const startTime = Date.now();
|
|
205
|
+
|
|
206
|
+
const fetchVendorStatus = async () => {
|
|
207
|
+
try {
|
|
208
|
+
const { data } = await api.get(API.VENDOR_ORDER_STATUS(sessionId));
|
|
209
|
+
if (!mountedRef.current) return;
|
|
210
|
+
|
|
211
|
+
const needCheckError = Date.now() - startTime > 6000;
|
|
212
|
+
const allCompleted = data?.vendors?.every((v: VendorStatus) => v.progress >= 100);
|
|
213
|
+
const hasFailed = data?.vendors?.some(
|
|
214
|
+
(v: VendorStatus & { error?: string; error_message?: string }) =>
|
|
215
|
+
v.status === 'failed' || (needCheckError && !!v.error && !!v.error_message)
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
setVendorStatus({
|
|
219
|
+
...data,
|
|
220
|
+
isAllCompleted: !hasFailed && allCompleted,
|
|
221
|
+
hasFailed,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (hasFailed || allCompleted) return;
|
|
225
|
+
|
|
226
|
+
vendorTimerRef.current = setTimeout(fetchVendorStatus, 5000);
|
|
227
|
+
} catch {
|
|
228
|
+
// Ignore polling errors
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
fetchVendorStatus();
|
|
233
|
+
|
|
234
|
+
return () => {
|
|
235
|
+
if (vendorTimerRef.current) clearTimeout(vendorTimerRef.current);
|
|
236
|
+
};
|
|
237
|
+
}, [status, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
238
|
+
|
|
239
|
+
// Auto-refresh session for quote errors, with auto-retry for stale-lock errors (like V1)
|
|
240
|
+
const handleQuoteError = useMemoizedFn(async (errorCode: string, errorData?: Record<string, unknown>) => {
|
|
241
|
+
try {
|
|
242
|
+
await refreshSession(true);
|
|
243
|
+
if (!mountedRef.current) return;
|
|
244
|
+
} catch {
|
|
245
|
+
// Ignore refresh error
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// QUOTE_UPDATED means the price actually changed — user should review
|
|
249
|
+
if (errorCode === 'QUOTE_UPDATED' && errorData?.checkoutSession) {
|
|
250
|
+
quoteRetryCountRef.current = 0;
|
|
251
|
+
setStatus('failed');
|
|
252
|
+
setContext({ type: 'error', message: 'Price updated, please resubmit', code: errorCode });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Stale-lock errors: auto-retry silently (up to MAX_QUOTE_RETRIES)
|
|
257
|
+
const canAutoRetry = ['QUOTE_LOCK_EXPIRED', 'QUOTE_EXPIRED_OR_USED', 'QUOTE_NOT_FOUND', 'QUOTE_REQUIRED'].includes(
|
|
258
|
+
errorCode
|
|
259
|
+
);
|
|
260
|
+
if (canAutoRetry && quoteRetryCountRef.current < MAX_QUOTE_RETRIES) {
|
|
261
|
+
quoteRetryCountRef.current += 1;
|
|
262
|
+
setStatus('idle');
|
|
263
|
+
setContext(null);
|
|
264
|
+
await execute(true);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Exhausted retries or unknown quote error
|
|
269
|
+
quoteRetryCountRef.current = 0;
|
|
270
|
+
setStatus('failed');
|
|
271
|
+
setContext({
|
|
272
|
+
type: 'error',
|
|
273
|
+
message: 'Price updated, please resubmit',
|
|
274
|
+
code: errorCode,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Route based on submit response
|
|
279
|
+
const routeSubmitResponse = useMemoizedFn(async (data: Record<string, unknown>) => {
|
|
280
|
+
// Stripe payment — check BEFORE noPaymentRequired to match V1 behavior
|
|
281
|
+
// V1 routes by payment method type first, regardless of noPaymentRequired flag
|
|
282
|
+
if (isStripe) {
|
|
283
|
+
const stripeCtx = data.stripeContext as
|
|
284
|
+
| { status?: string; client_secret?: string; intent_type?: string }
|
|
285
|
+
| undefined;
|
|
286
|
+
if (stripeCtx?.status === 'succeeded') {
|
|
287
|
+
await handleCompletion();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const piCtx = data.paymentIntent as { client_secret?: string } | undefined;
|
|
292
|
+
const clientSecret = stripeCtx?.client_secret || piCtx?.client_secret;
|
|
293
|
+
if (clientSecret) {
|
|
294
|
+
const intentType = (stripeCtx?.intent_type || 'payment_intent') as 'payment_intent' | 'setup_intent';
|
|
295
|
+
setStatus('waiting_stripe');
|
|
296
|
+
setContext({ type: 'stripe', clientSecret, intentType });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Stripe with no client_secret (e.g., free trial with no setup_intent needed)
|
|
301
|
+
if (data.noPaymentRequired) {
|
|
302
|
+
try {
|
|
303
|
+
const confirmResult = await confirmFastCheckout(sessionId);
|
|
304
|
+
const confirmSession = confirmResult.checkoutSession as Record<string, unknown> | undefined;
|
|
305
|
+
if (confirmResult.fastPaid || confirmSession?.status === 'complete') {
|
|
306
|
+
await handleCompletion();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// fall through
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await handleCompletion();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// No payment required (free trial, etc.) — crypto/DID path
|
|
319
|
+
if (data.noPaymentRequired) {
|
|
320
|
+
try {
|
|
321
|
+
const confirmResult = await confirmFastCheckout(sessionId);
|
|
322
|
+
const confirmSession = confirmResult.checkoutSession as Record<string, unknown> | undefined;
|
|
323
|
+
if (confirmResult.fastPaid || confirmSession?.status === 'complete') {
|
|
324
|
+
await handleCompletion();
|
|
325
|
+
} else {
|
|
326
|
+
setStatus('waiting_did');
|
|
327
|
+
setContext({
|
|
328
|
+
type: 'did_connect',
|
|
329
|
+
action: session?.mode || 'payment',
|
|
330
|
+
checkpointId: sessionId,
|
|
331
|
+
extraParams: {
|
|
332
|
+
checkoutSessionId: sessionId,
|
|
333
|
+
sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
if (!mountedRef.current) return;
|
|
339
|
+
setStatus('waiting_did');
|
|
340
|
+
setContext({
|
|
341
|
+
type: 'did_connect',
|
|
342
|
+
action: session?.mode || 'payment',
|
|
343
|
+
checkpointId: sessionId,
|
|
344
|
+
extraParams: {
|
|
345
|
+
checkoutSessionId: sessionId,
|
|
346
|
+
sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Fast pay routing — match V1: credit first, then balance/delegation
|
|
354
|
+
const fastPayInfo = data.fastPayInfo as
|
|
355
|
+
| { type?: string; amount?: string; payer?: string; token?: { balance?: string } }
|
|
356
|
+
| undefined;
|
|
357
|
+
const balance = data.balance as { sufficient?: boolean } | undefined;
|
|
358
|
+
const delegation = data.delegation as { sufficient?: boolean } | undefined;
|
|
359
|
+
|
|
360
|
+
// Credit payment — only when currency type is 'credit' (matches V1: paymentCurrency?.type === 'credit')
|
|
361
|
+
if (isCredit) {
|
|
362
|
+
if (data.creditSufficient === true && fastPayInfo) {
|
|
363
|
+
setStatus('confirming_fast_pay');
|
|
364
|
+
setContext({
|
|
365
|
+
type: 'fast_pay',
|
|
366
|
+
payType: 'credit',
|
|
367
|
+
amount: fastPayInfo.amount || '0',
|
|
368
|
+
payer: fastPayInfo.payer || '',
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Credit currency but insufficient — show dialog
|
|
373
|
+
setStatus('credit_insufficient');
|
|
374
|
+
setContext({
|
|
375
|
+
type: 'credit_insufficient',
|
|
376
|
+
available: '0',
|
|
377
|
+
required: '0',
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Balance/delegation fast pay (non-credit, non-donation)
|
|
383
|
+
if ((balance?.sufficient || delegation?.sufficient) && !isDonation && fastPayInfo) {
|
|
384
|
+
setStatus('confirming_fast_pay');
|
|
385
|
+
setContext({
|
|
386
|
+
type: 'fast_pay',
|
|
387
|
+
payType: (fastPayInfo.type as 'balance' | 'delegation') || 'balance',
|
|
388
|
+
amount: fastPayInfo.amount || '',
|
|
389
|
+
payer: fastPayInfo.payer || '',
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Need DID connect (crypto payment)
|
|
395
|
+
setStatus('waiting_did');
|
|
396
|
+
setContext({
|
|
397
|
+
type: 'did_connect',
|
|
398
|
+
action: session?.mode || 'payment',
|
|
399
|
+
checkpointId: sessionId,
|
|
400
|
+
extraParams: {
|
|
401
|
+
checkoutSessionId: sessionId,
|
|
402
|
+
sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Handle submit error
|
|
408
|
+
const handleSubmitError = useMemoizedFn(async (err: unknown) => {
|
|
409
|
+
const { code: errorCode, data: errorData, message: errMessage } = getAxiosErrorDetails(err);
|
|
410
|
+
|
|
411
|
+
// Quote-related errors
|
|
412
|
+
if (errorCode && isQuoteError(errorCode)) {
|
|
413
|
+
await handleQuoteError(errorCode, errorData);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// RATE_BELOW_SLIPPAGE_LIMIT: fallback — stable idempotency key should prevent this,
|
|
418
|
+
// but handle gracefully if it occurs (e.g., after session refresh with new context)
|
|
419
|
+
if (errorCode === 'RATE_BELOW_SLIPPAGE_LIMIT') {
|
|
420
|
+
try {
|
|
421
|
+
await refreshSession(true);
|
|
422
|
+
} catch {
|
|
423
|
+
// Ignore
|
|
424
|
+
}
|
|
425
|
+
if (!mountedRef.current) return;
|
|
426
|
+
// Invalidate stale key so next submit creates a fresh quote
|
|
427
|
+
idempotencyKeyRef.current = '';
|
|
428
|
+
sessionFingerprintRef.current = '';
|
|
429
|
+
setStatus('failed');
|
|
430
|
+
setContext({
|
|
431
|
+
type: 'error',
|
|
432
|
+
message: (errorData?.error as string) || 'Exchange rate below acceptable limit, please retry',
|
|
433
|
+
code: errorCode,
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// RATE_UNAVAILABLE
|
|
439
|
+
if (errorCode === 'RATE_UNAVAILABLE') {
|
|
440
|
+
try {
|
|
441
|
+
await refreshSession(true);
|
|
442
|
+
} catch {
|
|
443
|
+
// Ignore
|
|
444
|
+
}
|
|
445
|
+
if (!mountedRef.current) return;
|
|
446
|
+
setStatus('failed');
|
|
447
|
+
setContext({
|
|
448
|
+
type: 'error',
|
|
449
|
+
message: (errorData?.rateError as string) || 'Exchange rate unavailable',
|
|
450
|
+
code: errorCode,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// PRICE_CHANGED — match V1: show confirmation dialog with change_percent (snake_case from backend)
|
|
456
|
+
if (errorCode === 'PRICE_CHANGED') {
|
|
457
|
+
setStatus('confirming_price');
|
|
458
|
+
setContext({
|
|
459
|
+
type: 'price_change',
|
|
460
|
+
changePercent: (errorData?.change_percent as number) || 0,
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// PRICE_UNAVAILABLE / PRICE_UNSTABLE
|
|
466
|
+
if (errorCode === 'PRICE_UNAVAILABLE' || errorCode === 'PRICE_UNSTABLE') {
|
|
467
|
+
try {
|
|
468
|
+
await refreshSession(true);
|
|
469
|
+
} catch {
|
|
470
|
+
// Ignore
|
|
471
|
+
}
|
|
472
|
+
if (!mountedRef.current) return;
|
|
473
|
+
setStatus('failed');
|
|
474
|
+
setContext({
|
|
475
|
+
type: 'error',
|
|
476
|
+
message: (errorData?.error as string) || 'Price unavailable, please retry',
|
|
477
|
+
code: errorCode,
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// UNIFIED_APP_REQUIRED / CUSTOMER_LIMITED
|
|
483
|
+
if (errorCode === 'UNIFIED_APP_REQUIRED' || errorCode === 'CUSTOMER_LIMITED') {
|
|
484
|
+
setStatus('failed');
|
|
485
|
+
setContext({
|
|
486
|
+
type: 'error',
|
|
487
|
+
message: (errorData?.error as string) || errMessage || 'Cannot complete payment',
|
|
488
|
+
code: errorCode,
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Generic error
|
|
494
|
+
const message = (errorData?.error as string) || errMessage || 'Payment failed';
|
|
495
|
+
setStatus('failed');
|
|
496
|
+
setContext({ type: 'error', message, code: errorCode });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Main execute
|
|
500
|
+
const execute = useMemoizedFn(async (force = false) => {
|
|
501
|
+
if (!force && status !== 'idle') return;
|
|
502
|
+
|
|
503
|
+
// Reset quote retry counter on fresh user-initiated submit
|
|
504
|
+
if (!force) quoteRetryCountRef.current = 0;
|
|
505
|
+
|
|
506
|
+
if (!(await validateForm())) return;
|
|
507
|
+
|
|
508
|
+
// Vendor account validation
|
|
509
|
+
const user = (session as CheckoutSessionRuntime | undefined)?.user;
|
|
510
|
+
if (!user?.sourceAppPid) {
|
|
511
|
+
const hasVendorConfig = (session?.line_items as TLineItemExpanded[] | undefined)?.some(
|
|
512
|
+
(item) => !!item?.price?.product?.vendor_config?.length
|
|
513
|
+
);
|
|
514
|
+
if (hasVendorConfig) {
|
|
515
|
+
setStatus('failed');
|
|
516
|
+
setContext({ type: 'error', message: 'Vendor account required', code: 'VENDOR_ACCOUNT_REQUIRED' });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
setStatus('submitting');
|
|
522
|
+
setContext(null);
|
|
523
|
+
setResult(null);
|
|
524
|
+
pollingAbortRef.current = false;
|
|
525
|
+
|
|
526
|
+
// Stable idempotency key: only regenerate when payment context changes
|
|
527
|
+
// Same context retry → reuse Quote (intent: "Failed Payments don't invalidate Quote")
|
|
528
|
+
const fingerprint = getSessionFingerprint(session, currencyId);
|
|
529
|
+
if (fingerprint !== sessionFingerprintRef.current || !idempotencyKeyRef.current) {
|
|
530
|
+
sessionFingerprintRef.current = fingerprint;
|
|
531
|
+
idempotencyKeyRef.current = generateIdempotencyKey(sessionId, currencyId || '');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const payload = buildSubmitPayload(sessionId, currencyId, formValues, session, false, idempotencyKeyRef.current);
|
|
535
|
+
lastPayloadRef.current = payload;
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const data = await submitCheckout(sessionId, isDonation, payload);
|
|
539
|
+
if (!mountedRef.current) return;
|
|
540
|
+
await routeSubmitResponse(data);
|
|
541
|
+
} catch (err: unknown) {
|
|
542
|
+
if (!mountedRef.current) return;
|
|
543
|
+
await handleSubmitError(err);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Confirm: handles both fast_pay and price_change
|
|
548
|
+
const confirm = useMemoizedFn(async () => {
|
|
549
|
+
if (status === 'confirming_fast_pay') {
|
|
550
|
+
setStatus('submitting');
|
|
551
|
+
try {
|
|
552
|
+
const data = await confirmFastCheckout(sessionId);
|
|
553
|
+
|
|
554
|
+
if (!mountedRef.current) return;
|
|
555
|
+
|
|
556
|
+
if (data.fastPaid) {
|
|
557
|
+
await handleCompletion();
|
|
558
|
+
} else {
|
|
559
|
+
setStatus('waiting_did');
|
|
560
|
+
setContext({
|
|
561
|
+
type: 'did_connect',
|
|
562
|
+
action: session?.mode || 'payment',
|
|
563
|
+
checkpointId: sessionId,
|
|
564
|
+
extraParams: {
|
|
565
|
+
checkoutSessionId: sessionId,
|
|
566
|
+
sessionUserDid: (session as CheckoutSessionRuntime | undefined)?.user?.did,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
} catch (err: unknown) {
|
|
571
|
+
if (!mountedRef.current) return;
|
|
572
|
+
|
|
573
|
+
const { code: errorCode, data: errorData, message: errMsg } = getAxiosErrorDetails(err);
|
|
574
|
+
|
|
575
|
+
if (errorCode && isQuoteError(errorCode)) {
|
|
576
|
+
await handleQuoteError(errorCode, errorData);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (errorCode === 'RATE_UNAVAILABLE') {
|
|
581
|
+
try {
|
|
582
|
+
await refreshSession(true);
|
|
583
|
+
} catch {
|
|
584
|
+
/* Ignore */
|
|
585
|
+
}
|
|
586
|
+
if (!mountedRef.current) return;
|
|
587
|
+
setStatus('failed');
|
|
588
|
+
setContext({
|
|
589
|
+
type: 'error',
|
|
590
|
+
message: (errorData?.rateError as string) || 'Exchange rate unavailable',
|
|
591
|
+
code: errorCode,
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
setStatus('failed');
|
|
597
|
+
setContext({
|
|
598
|
+
type: 'error',
|
|
599
|
+
message: (errorData?.error as string) || errMsg || 'Fast pay confirmation failed',
|
|
600
|
+
code: errorCode,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (status === 'confirming_price') {
|
|
607
|
+
setStatus('submitting');
|
|
608
|
+
setContext(null);
|
|
609
|
+
pollingAbortRef.current = false;
|
|
610
|
+
|
|
611
|
+
// Price confirmed resubmit: reuse same idempotency key (same Quote context)
|
|
612
|
+
const payload = buildSubmitPayload(sessionId, currencyId, formValues, session, true, idempotencyKeyRef.current);
|
|
613
|
+
lastPayloadRef.current = payload;
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const data = await submitCheckout(sessionId, isDonation, payload);
|
|
617
|
+
if (!mountedRef.current) return;
|
|
618
|
+
await routeSubmitResponse(data);
|
|
619
|
+
} catch (err: unknown) {
|
|
620
|
+
if (!mountedRef.current) return;
|
|
621
|
+
await handleSubmitError(err);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Cancel
|
|
627
|
+
const cancel = useMemoizedFn(() => {
|
|
628
|
+
if (status === 'confirming_price' || status === 'confirming_fast_pay' || status === 'credit_insufficient') {
|
|
629
|
+
setStatus('idle');
|
|
630
|
+
setContext(null);
|
|
631
|
+
unlock();
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Retry
|
|
636
|
+
const retry = useMemoizedFn(async () => {
|
|
637
|
+
if (status !== 'failed') return;
|
|
638
|
+
setStatus('idle');
|
|
639
|
+
setContext(null);
|
|
640
|
+
setResult(null);
|
|
641
|
+
await execute(true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Reset
|
|
645
|
+
const reset = useMemoizedFn(() => {
|
|
646
|
+
pollingAbortRef.current = true;
|
|
647
|
+
setStatus('idle');
|
|
648
|
+
setContext(null);
|
|
649
|
+
setResult(null);
|
|
650
|
+
unlock();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Stripe confirm
|
|
654
|
+
const stripeConfirm = useMemoizedFn(async () => {
|
|
655
|
+
if (status !== 'waiting_stripe') return;
|
|
656
|
+
setStatus('submitting');
|
|
657
|
+
setContext(null);
|
|
658
|
+
await handleCompletion();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Stripe cancel
|
|
662
|
+
const stripeCancel = useMemoizedFn(async () => {
|
|
663
|
+
if (status !== 'waiting_stripe') return;
|
|
664
|
+
await abortStripePayment(sessionId);
|
|
665
|
+
setStatus('idle');
|
|
666
|
+
setContext(null);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
status,
|
|
671
|
+
context,
|
|
672
|
+
execute,
|
|
673
|
+
confirm,
|
|
674
|
+
cancel,
|
|
675
|
+
result,
|
|
676
|
+
retry,
|
|
677
|
+
reset,
|
|
678
|
+
stripeConfirm,
|
|
679
|
+
stripeCancel,
|
|
680
|
+
vendorStatus,
|
|
681
|
+
locked,
|
|
682
|
+
lock,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useSubmitContext } from '../context/SubmitContext';
|
|
2
|
+
import type { UseSubmitReturn } from './useSubmit';
|
|
3
|
+
|
|
4
|
+
// Context-aware version: reads shared submit state from CheckoutProvider
|
|
5
|
+
export function useSubmitFeature(): UseSubmitReturn {
|
|
6
|
+
return useSubmitContext();
|
|
7
|
+
}
|