@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,156 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
|
|
4
|
+
import api, { API } from '../../shared/api';
|
|
5
|
+
import { validateForm } from '../../shared/validation';
|
|
6
|
+
import type { FieldConfig, CheckoutFormData } from '../types';
|
|
7
|
+
import { buildFields, createInitialValues, setNestedValue } from '../core/customerForm';
|
|
8
|
+
import type { SessionData } from './useCheckoutSession';
|
|
9
|
+
|
|
10
|
+
export interface UseCustomerFormReturn {
|
|
11
|
+
fields: FieldConfig[];
|
|
12
|
+
values: CheckoutFormData;
|
|
13
|
+
onChange: (field: string, value: string | boolean | Record<string, string>) => void;
|
|
14
|
+
errors: Partial<Record<string, string>>;
|
|
15
|
+
touched: Record<string, boolean>;
|
|
16
|
+
validate: () => Promise<boolean>;
|
|
17
|
+
validateField: (field: string) => Promise<void>;
|
|
18
|
+
/** Re-fetch customer info from backend and update form values (e.g. after login) */
|
|
19
|
+
refetchCustomer: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useCustomerForm(
|
|
23
|
+
sessionData: SessionData | null,
|
|
24
|
+
currencyId: string | null,
|
|
25
|
+
methodId: string | null
|
|
26
|
+
): UseCustomerFormReturn {
|
|
27
|
+
const session = sessionData?.checkoutSession;
|
|
28
|
+
const customer = sessionData?.customer;
|
|
29
|
+
|
|
30
|
+
const fields = useMemo(() => buildFields(session), [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
31
|
+
|
|
32
|
+
const [values, setValues] = useState<CheckoutFormData>(() => createInitialValues(session, customer));
|
|
33
|
+
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
|
|
34
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
35
|
+
const [prefetched, setPrefetched] = useState(false);
|
|
36
|
+
|
|
37
|
+
// Re-initialize when session loads
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (session && !prefetched) {
|
|
40
|
+
setValues(createInitialValues(session, customer));
|
|
41
|
+
}
|
|
42
|
+
}, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
43
|
+
|
|
44
|
+
// Sync payment method/currency into values
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (currencyId || methodId) {
|
|
47
|
+
setValues((prev) => ({
|
|
48
|
+
...prev,
|
|
49
|
+
...(currencyId && { payment_currency: currencyId }),
|
|
50
|
+
...(methodId && { payment_method: methodId }),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
}, [currencyId, methodId]);
|
|
54
|
+
|
|
55
|
+
// Prefetch customer info
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!session || prefetched || session.status === 'complete') return;
|
|
58
|
+
|
|
59
|
+
const fetchCustomer = async () => {
|
|
60
|
+
try {
|
|
61
|
+
const { data } = await api.get(`${API.CUSTOMER_ME}?fallback=1&skipSummary=1`);
|
|
62
|
+
if (data) {
|
|
63
|
+
setPrefetched(true);
|
|
64
|
+
setValues((prev) => ({
|
|
65
|
+
...prev,
|
|
66
|
+
customer_name: prev.customer_name || data.name || '',
|
|
67
|
+
customer_email: prev.customer_email || data.email || '',
|
|
68
|
+
customer_phone: prev.customer_phone || data.phone || '',
|
|
69
|
+
billing_address: {
|
|
70
|
+
...prev.billing_address!,
|
|
71
|
+
country: prev.billing_address?.country || data.address?.country || '',
|
|
72
|
+
state: prev.billing_address?.state || data.address?.state || '',
|
|
73
|
+
city: prev.billing_address?.city || data.address?.city || '',
|
|
74
|
+
line1: prev.billing_address?.line1 || data.address?.line1 || '',
|
|
75
|
+
line2: prev.billing_address?.line2 || data.address?.line2 || '',
|
|
76
|
+
postal_code: prev.billing_address?.postal_code || data.address?.postal_code || '',
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore - customer info is optional
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
fetchCustomer();
|
|
86
|
+
}, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
87
|
+
|
|
88
|
+
const onChange = useMemoizedFn((field: string, value: string | boolean | Record<string, string>) => {
|
|
89
|
+
setValues((prev) => setNestedValue(prev, field, value));
|
|
90
|
+
setTouched((prev) => ({ ...prev, [field]: true }));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const getValidateOptions = useMemoizedFn(() => ({
|
|
94
|
+
phoneEnabled: !!session?.phone_number_collection?.enabled,
|
|
95
|
+
addressMode: session?.billing_address_collection as 'auto' | 'required' | null,
|
|
96
|
+
fieldValidation: session?.metadata?.page_info?.field_validation,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const validate = useMemoizedFn(async () => {
|
|
100
|
+
const result = await validateForm(values, getValidateOptions());
|
|
101
|
+
setErrors(result.errors);
|
|
102
|
+
return result.valid;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Single-field blur validation — matches V1's trigger(fieldName)
|
|
106
|
+
const validateField = useMemoizedFn(async (field: string) => {
|
|
107
|
+
const result = await validateForm(values, getValidateOptions());
|
|
108
|
+
setErrors((prev) => {
|
|
109
|
+
const next = { ...prev };
|
|
110
|
+
if (result.errors[field]) {
|
|
111
|
+
next[field] = result.errors[field];
|
|
112
|
+
} else {
|
|
113
|
+
delete next[field];
|
|
114
|
+
}
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Re-fetch customer info (used after login to populate form)
|
|
120
|
+
const refetchCustomer = useMemoizedFn(async () => {
|
|
121
|
+
try {
|
|
122
|
+
const { data } = await api.get(`${API.CUSTOMER_ME}?fallback=1&skipSummary=1`);
|
|
123
|
+
if (data) {
|
|
124
|
+
setPrefetched(true);
|
|
125
|
+
setValues((prev) => ({
|
|
126
|
+
...prev,
|
|
127
|
+
customer_name: data.name || prev.customer_name || '',
|
|
128
|
+
customer_email: data.email || prev.customer_email || '',
|
|
129
|
+
customer_phone: data.phone || prev.customer_phone || '',
|
|
130
|
+
billing_address: {
|
|
131
|
+
...prev.billing_address!,
|
|
132
|
+
country: data.address?.country || prev.billing_address?.country || '',
|
|
133
|
+
state: data.address?.state || prev.billing_address?.state || '',
|
|
134
|
+
city: data.address?.city || prev.billing_address?.city || '',
|
|
135
|
+
line1: data.address?.line1 || prev.billing_address?.line1 || '',
|
|
136
|
+
line2: data.address?.line2 || prev.billing_address?.line2 || '',
|
|
137
|
+
postal_code: data.address?.postal_code || prev.billing_address?.postal_code || '',
|
|
138
|
+
},
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Ignore - customer info is optional
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
fields,
|
|
148
|
+
values,
|
|
149
|
+
onChange,
|
|
150
|
+
errors,
|
|
151
|
+
touched,
|
|
152
|
+
validate,
|
|
153
|
+
validateField,
|
|
154
|
+
refetchCustomer,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useCustomerFormContext } from '../context/CustomerFormContext';
|
|
2
|
+
import type { UseCustomerFormReturn } from './useCustomerForm';
|
|
3
|
+
|
|
4
|
+
// Context-aware version: reads shared customer form state from CheckoutProvider
|
|
5
|
+
export function useCustomerFormFeature(): UseCustomerFormReturn {
|
|
6
|
+
return useCustomerFormContext();
|
|
7
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useExchangeRateContext } from '../context/ExchangeRateContext';
|
|
2
|
+
import { formatExchangeRate, formatExchangeRateDisplay } from '../../shared/format';
|
|
3
|
+
|
|
4
|
+
export interface UseExchangeRateReturn {
|
|
5
|
+
value: string | null;
|
|
6
|
+
display: string | null;
|
|
7
|
+
provider: string | null;
|
|
8
|
+
providerDisplay: string | null;
|
|
9
|
+
fetchedAt: number | null;
|
|
10
|
+
status: 'loading' | 'available' | 'unavailable';
|
|
11
|
+
hasDynamicPricing: boolean;
|
|
12
|
+
refresh: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useExchangeRate(): UseExchangeRateReturn {
|
|
16
|
+
const { rate, provider, providerDisplay, fetchedAt, status, hasDynamicPricing, refresh } = useExchangeRateContext();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
value: formatExchangeRate(rate),
|
|
20
|
+
display: rate ? formatExchangeRateDisplay(rate) : null,
|
|
21
|
+
provider,
|
|
22
|
+
providerDisplay,
|
|
23
|
+
fetchedAt,
|
|
24
|
+
status,
|
|
25
|
+
hasDynamicPricing,
|
|
26
|
+
refresh,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useMemo, useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
import type { TLineItemExpanded, TPrice } from '@blocklet/payment-types';
|
|
4
|
+
|
|
5
|
+
import { getErrorMessage } from '../../types/checkout-augmented';
|
|
6
|
+
|
|
7
|
+
import { useSessionContext } from '../context/SessionContext';
|
|
8
|
+
import { usePaymentMethodContext } from '../context/PaymentMethodContext';
|
|
9
|
+
import {
|
|
10
|
+
adjustQuantity,
|
|
11
|
+
performUpsell,
|
|
12
|
+
performDownsell,
|
|
13
|
+
changeDonationAmount,
|
|
14
|
+
getCrossSellItem,
|
|
15
|
+
} from '../core/lineItems';
|
|
16
|
+
import { addCrossSellItem, removeCrossSellItem, fetchCrossSellItem } from '../core/crossSell';
|
|
17
|
+
|
|
18
|
+
export interface UseLineItemsReturn {
|
|
19
|
+
items: Array<
|
|
20
|
+
TLineItemExpanded & {
|
|
21
|
+
adjustable_quantity?: {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
minimum?: number;
|
|
24
|
+
maximum?: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
>;
|
|
28
|
+
/** true if all items pass inventory check (quantity_available, quantity_limit_per_checkout) */
|
|
29
|
+
inventoryOk: boolean;
|
|
30
|
+
updateQuantity: (itemId: string, qty: number) => Promise<void>;
|
|
31
|
+
upsell: (fromId: string, toId: string) => Promise<void>;
|
|
32
|
+
downsell: (priceId: string) => Promise<void>;
|
|
33
|
+
crossSellItem: TPrice | null;
|
|
34
|
+
crossSellRequired: boolean;
|
|
35
|
+
addCrossSell: () => Promise<void>;
|
|
36
|
+
removeCrossSell: () => Promise<void>;
|
|
37
|
+
isDonation: boolean;
|
|
38
|
+
setDonationAmount: (priceId: string, amount: string) => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useLineItems(): UseLineItemsReturn {
|
|
42
|
+
const { items, session, effectiveSessionId, isDonation, refresh } = useSessionContext();
|
|
43
|
+
const { currency } = usePaymentMethodContext();
|
|
44
|
+
const currencyId = currency?.id || null;
|
|
45
|
+
|
|
46
|
+
// Default quantity from URL params: ?qty=5 or ?qty_price_xxx=10 (matching original product-item.tsx)
|
|
47
|
+
const defaultQtyApplied = useRef(false);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (defaultQtyApplied.current || !effectiveSessionId || !items.length || !currencyId || session?.status === 'complete') return;
|
|
50
|
+
try {
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
const qtyStr = params.get(`qty_${item.price_id}`) || params.get(`qty_${item.price?.id}`) || params.get('qty');
|
|
54
|
+
if (qtyStr) {
|
|
55
|
+
const qty = Math.max(1, parseInt(qtyStr, 10));
|
|
56
|
+
if (Number.isFinite(qty) && qty !== item.quantity) {
|
|
57
|
+
defaultQtyApplied.current = true;
|
|
58
|
+
adjustQuantity(effectiveSessionId, item.price_id, qty, currencyId, session, refresh);
|
|
59
|
+
break; // only apply to the first matching item for ?qty
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
}, [effectiveSessionId, items, currencyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
67
|
+
|
|
68
|
+
const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
|
|
69
|
+
if (session?.status === 'complete') return;
|
|
70
|
+
try {
|
|
71
|
+
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
|
|
72
|
+
} catch (err: unknown) {
|
|
73
|
+
console.error('Failed to update quantity:', getErrorMessage(err));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
78
|
+
if (session?.status === 'complete') return;
|
|
79
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
83
|
+
if (session?.status === 'complete') return;
|
|
84
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Cross-sell item detection (must be before addCrossSell so it can reference crossSellItem)
|
|
88
|
+
const embeddedCrossSellItem = useMemo(() => getCrossSellItem(items), [items]);
|
|
89
|
+
const [fetchedCrossSellItem, setFetchedCrossSellItem] = useState<TPrice | null>(null);
|
|
90
|
+
|
|
91
|
+
// Stable key that only changes on structural item changes (upsell/downsell/add/remove), not quantity
|
|
92
|
+
const itemPriceIds = useMemo(() => items.map((i) => i.price_id).join(','), [items]);
|
|
93
|
+
|
|
94
|
+
// Skip fetch when cross-sell is already added to session line items
|
|
95
|
+
const hasCrossSellInItems = useMemo(() => items.some((i: any) => i.cross_sell), [items]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!effectiveSessionId || !session) return undefined;
|
|
99
|
+
if (session.status === 'complete') return undefined;
|
|
100
|
+
if (embeddedCrossSellItem) {
|
|
101
|
+
setFetchedCrossSellItem(null); // clear stale fetched item when embedded item exists
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (hasCrossSellInItems) {
|
|
105
|
+
return undefined; // cross-sell already in cart, no need to fetch the offer
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let cancelled = false;
|
|
109
|
+
fetchCrossSellItem(effectiveSessionId).then((item) => {
|
|
110
|
+
if (!cancelled) {
|
|
111
|
+
setFetchedCrossSellItem(item); // set null explicitly when no cross-sell for current interval
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return () => {
|
|
115
|
+
cancelled = true;
|
|
116
|
+
};
|
|
117
|
+
// Re-fetch when item structure changes (e.g. after upsell/downsell changes billing interval)
|
|
118
|
+
}, [effectiveSessionId, session?.id, embeddedCrossSellItem, itemPriceIds, hasCrossSellInItems]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
119
|
+
|
|
120
|
+
const crossSellItem = embeddedCrossSellItem || fetchedCrossSellItem;
|
|
121
|
+
const crossSellRequired = useMemo(() => items.some((item) => item.cross_sell_required), [items]);
|
|
122
|
+
|
|
123
|
+
const addCrossSell = useMemoizedFn(async () => {
|
|
124
|
+
if (session?.status === 'complete') return;
|
|
125
|
+
if (!crossSellItem) return;
|
|
126
|
+
try {
|
|
127
|
+
await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const removeCrossSell = useMemoizedFn(async () => {
|
|
134
|
+
if (session?.status === 'complete') return;
|
|
135
|
+
try {
|
|
136
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
137
|
+
} catch (err: unknown) {
|
|
138
|
+
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const setDonationAmount = useMemoizedFn(async (priceId: string, amount: string) => {
|
|
143
|
+
if (session?.status === 'complete') return;
|
|
144
|
+
if (!isDonation) return;
|
|
145
|
+
try {
|
|
146
|
+
await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
|
|
147
|
+
} catch (err: unknown) {
|
|
148
|
+
console.error('Failed to change amount:', getErrorMessage(err));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const mappedItems = useMemo(
|
|
153
|
+
() =>
|
|
154
|
+
items.map((item) => ({
|
|
155
|
+
...item,
|
|
156
|
+
adjustable_quantity: item.adjustable_quantity,
|
|
157
|
+
})),
|
|
158
|
+
[items]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Inventory check: matching original formatQuantityInventory logic
|
|
162
|
+
const inventoryOk = useMemo(
|
|
163
|
+
() =>
|
|
164
|
+
items.every((item) => {
|
|
165
|
+
const { price } = item;
|
|
166
|
+
if (!price) return true;
|
|
167
|
+
const q = Number(item.quantity) || 0;
|
|
168
|
+
const available = price.quantity_available || 0;
|
|
169
|
+
const sold = price.quantity_sold || 0;
|
|
170
|
+
const limitPerCheckout = price.quantity_limit_per_checkout || 0;
|
|
171
|
+
if (available > 0 && sold + q > available) return false;
|
|
172
|
+
if (limitPerCheckout > 0 && limitPerCheckout < q) return false;
|
|
173
|
+
return true;
|
|
174
|
+
}),
|
|
175
|
+
[items]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
items: mappedItems,
|
|
180
|
+
inventoryOk,
|
|
181
|
+
updateQuantity,
|
|
182
|
+
upsell,
|
|
183
|
+
downsell,
|
|
184
|
+
crossSellItem,
|
|
185
|
+
crossSellRequired,
|
|
186
|
+
addCrossSell,
|
|
187
|
+
removeCrossSell,
|
|
188
|
+
isDonation,
|
|
189
|
+
setDonationAmount,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
import type { TPaymentMethodExpanded, TPaymentCurrency } from '@blocklet/payment-types';
|
|
4
|
+
|
|
5
|
+
import type { CheckoutSessionRuntime } from '../../types/checkout-augmented';
|
|
6
|
+
import { getErrorMessage } from '../../types/checkout-augmented';
|
|
7
|
+
|
|
8
|
+
import api, { API } from '../../shared/api';
|
|
9
|
+
import {
|
|
10
|
+
flattenCurrencies,
|
|
11
|
+
getInitialCurrencyId,
|
|
12
|
+
getCurrencyStorageKey,
|
|
13
|
+
findMethodAndCurrency,
|
|
14
|
+
buildPaymentTypes,
|
|
15
|
+
} from '../core/paymentMethod';
|
|
16
|
+
import type { SessionData } from './useCheckoutSession';
|
|
17
|
+
|
|
18
|
+
export interface UsePaymentMethodReturn {
|
|
19
|
+
current: TPaymentMethodExpanded;
|
|
20
|
+
currency: TPaymentCurrency;
|
|
21
|
+
available: TPaymentMethodExpanded[];
|
|
22
|
+
currencies: TPaymentCurrency[];
|
|
23
|
+
isStripe: boolean;
|
|
24
|
+
isCrypto: boolean;
|
|
25
|
+
isCredit: boolean;
|
|
26
|
+
switching: boolean;
|
|
27
|
+
setType: (type: 'stripe' | 'crypto') => Promise<void>;
|
|
28
|
+
types: Array<{
|
|
29
|
+
type: 'stripe' | 'crypto';
|
|
30
|
+
label: string;
|
|
31
|
+
currencies: TPaymentCurrency[];
|
|
32
|
+
active: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
setCurrency: (currencyId: string) => Promise<void>;
|
|
35
|
+
stripe: {
|
|
36
|
+
publishableKey: string | null;
|
|
37
|
+
clientSecret: string | null;
|
|
38
|
+
status: 'idle' | 'ready' | 'processing' | 'succeeded' | 'failed';
|
|
39
|
+
} | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function usePaymentMethod(
|
|
43
|
+
sessionData: SessionData | null,
|
|
44
|
+
sessionId: string,
|
|
45
|
+
refreshSession: (force?: boolean) => Promise<void>
|
|
46
|
+
): UsePaymentMethodReturn {
|
|
47
|
+
const methods = sessionData?.paymentMethods || [];
|
|
48
|
+
const session = sessionData?.checkoutSession;
|
|
49
|
+
|
|
50
|
+
const [currencyId, setCurrencyId] = useState<string | null>(() => getInitialCurrencyId(session, methods));
|
|
51
|
+
const [switching, setSwitching] = useState(false);
|
|
52
|
+
const initialSyncedRef = useRef<string | null>(null);
|
|
53
|
+
|
|
54
|
+
// Re-initialize when session first loads
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (session && !currencyId) {
|
|
57
|
+
setCurrencyId(getInitialCurrencyId(session, methods));
|
|
58
|
+
}
|
|
59
|
+
}, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
60
|
+
|
|
61
|
+
const { method: currentMethod, currency: currentCurrency } = useMemo(
|
|
62
|
+
() => findMethodAndCurrency(methods, currencyId),
|
|
63
|
+
[methods, currencyId]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const allCurrencies = useMemo(() => flattenCurrencies(methods), [methods]);
|
|
67
|
+
|
|
68
|
+
const isStripe = currentMethod?.type === 'stripe';
|
|
69
|
+
const isCrypto = currentMethod?.type !== 'stripe' && currentMethod?.type !== undefined;
|
|
70
|
+
const isCredit = currentCurrency?.type === 'credit';
|
|
71
|
+
|
|
72
|
+
const types = useMemo(() => buildPaymentTypes(methods, currentMethod), [methods, currentMethod]);
|
|
73
|
+
|
|
74
|
+
const setType = useMemoizedFn(async (type: 'stripe' | 'crypto') => {
|
|
75
|
+
const targetMethod = methods.find((m) => (type === 'stripe' ? m.type === 'stripe' : m.type !== 'stripe'));
|
|
76
|
+
if (!targetMethod) return;
|
|
77
|
+
|
|
78
|
+
const firstCurrency = targetMethod.payment_currencies?.[0];
|
|
79
|
+
if (firstCurrency) {
|
|
80
|
+
await switchCurrency(firstCurrency.id);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const switchCurrency = useMemoizedFn(async (newCurrencyId: string) => {
|
|
85
|
+
if (session?.status === 'complete') return;
|
|
86
|
+
const { method } = findMethodAndCurrency(methods, newCurrencyId);
|
|
87
|
+
if (!method || !sessionId) return;
|
|
88
|
+
|
|
89
|
+
setSwitching(true);
|
|
90
|
+
try {
|
|
91
|
+
await api.put(API.SWITCH_CURRENCY(sessionId), {
|
|
92
|
+
currency_id: newCurrencyId,
|
|
93
|
+
payment_method_id: method.id,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
setCurrencyId(newCurrencyId);
|
|
97
|
+
|
|
98
|
+
// Save preference (DID-scoped key matching original payment-react)
|
|
99
|
+
try {
|
|
100
|
+
localStorage.setItem(
|
|
101
|
+
getCurrencyStorageKey((session as CheckoutSessionRuntime | undefined)?.user?.did),
|
|
102
|
+
newCurrencyId
|
|
103
|
+
);
|
|
104
|
+
} catch {
|
|
105
|
+
// Ignore
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Refresh session to get updated data (backend clears quote on currency switch)
|
|
109
|
+
await refreshSession(true);
|
|
110
|
+
} catch (err: unknown) {
|
|
111
|
+
console.error('Failed to switch currency:', getErrorMessage(err));
|
|
112
|
+
// Fallback: align with backend currency to resolve currencyMismatch
|
|
113
|
+
// Without this, a failed switch leaves currencyId diverged from session.currency_id,
|
|
114
|
+
// causing currencyMismatch (and thus `switching`) to stay true permanently
|
|
115
|
+
if (session?.currency_id) {
|
|
116
|
+
setCurrencyId(session.currency_id);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
setSwitching(false);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Sync initial currency with backend if it differs from session.currency_id
|
|
124
|
+
// This handles the case where frontend picks a different currency (from cache/URL/no-wallet rule)
|
|
125
|
+
// than the session's default, ensuring backend recalculates amounts for the correct currency
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!session?.id || !currencyId || !methods.length) return;
|
|
128
|
+
// Only sync once per session
|
|
129
|
+
if (initialSyncedRef.current === session.id) return;
|
|
130
|
+
initialSyncedRef.current = session.id;
|
|
131
|
+
|
|
132
|
+
const sessionCurrencyId = session.currency_id;
|
|
133
|
+
if (session.status === 'complete') return;
|
|
134
|
+
if (sessionCurrencyId && sessionCurrencyId !== currencyId) {
|
|
135
|
+
switchCurrency(currencyId);
|
|
136
|
+
}
|
|
137
|
+
}, [session?.id, currencyId, methods.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
138
|
+
|
|
139
|
+
// Stripe configuration
|
|
140
|
+
const stripe = useMemo(() => {
|
|
141
|
+
if (!currentMethod || currentMethod.type !== 'stripe') return null;
|
|
142
|
+
|
|
143
|
+
const stripeSettings = currentMethod.settings?.stripe;
|
|
144
|
+
return {
|
|
145
|
+
publishableKey: stripeSettings?.publishable_key || null,
|
|
146
|
+
clientSecret: (sessionData?.paymentIntent as { client_secret?: string } | undefined)?.client_secret || null,
|
|
147
|
+
status: 'idle' as const,
|
|
148
|
+
};
|
|
149
|
+
}, [currentMethod, sessionData?.paymentIntent]);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
current: currentMethod as TPaymentMethodExpanded,
|
|
153
|
+
currency: currentCurrency as TPaymentCurrency,
|
|
154
|
+
available: methods,
|
|
155
|
+
currencies: allCurrencies,
|
|
156
|
+
isStripe,
|
|
157
|
+
isCrypto,
|
|
158
|
+
isCredit,
|
|
159
|
+
switching,
|
|
160
|
+
setType,
|
|
161
|
+
types,
|
|
162
|
+
setCurrency: switchCurrency,
|
|
163
|
+
stripe,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { usePaymentMethodContext } from '../context/PaymentMethodContext';
|
|
2
|
+
import type { UsePaymentMethodReturn } from './usePaymentMethod';
|
|
3
|
+
|
|
4
|
+
// Context-aware version: reads from PaymentMethodContext
|
|
5
|
+
// Same return type as the param-based usePaymentMethod
|
|
6
|
+
export function usePaymentMethodFeature(): UsePaymentMethodReturn {
|
|
7
|
+
return usePaymentMethodContext();
|
|
8
|
+
}
|