tf-checkout-react-native 2.0.0-beta.14 → 2.0.0-beta.15
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.d.mts +38 -37
- package/dist/index.d.ts +38 -37
- package/dist/index.js +723 -342
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +677 -293
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
- package/src/confirmer.ts +338 -99
- package/src/index.tsx +10 -2
- package/src/instance.ts +1 -3
- package/src/paymentSheet.ts +170 -0
- package/src/sections/Payment.tsx +41 -24
- package/src/ui/CheckoutProvider.tsx +14 -12
- package/src/ui/Select.tsx +30 -6
- package/src/useCheckout.ts +78 -3
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type * as Stripe from '@stripe/stripe-react-native'
|
|
2
|
+
import {
|
|
3
|
+
confirmedStripeIntentStatus,
|
|
4
|
+
CONFIRMED_INTENT_STATUSES,
|
|
5
|
+
nativeStripeErrorOutcome,
|
|
6
|
+
paymentProviderError,
|
|
7
|
+
type CheckoutSnapshot,
|
|
8
|
+
type StripeCardPaymentContext,
|
|
9
|
+
} from 'tf-checkout-shared'
|
|
10
|
+
|
|
11
|
+
/** The MXN card rail collects a PaymentMethod before offering installments. */
|
|
12
|
+
export const nativePaymentSheetAvailable = (
|
|
13
|
+
snapshot: Pick<CheckoutSnapshot, 'gateway' | 'breakdown'>
|
|
14
|
+
): boolean =>
|
|
15
|
+
snapshot.gateway === 'stripe_payment_intents' &&
|
|
16
|
+
typeof snapshot.breakdown?.currencyCode === 'string' &&
|
|
17
|
+
/^[A-Z]{3}$/.test(snapshot.breakdown.currencyCode) &&
|
|
18
|
+
snapshot.breakdown.currencyCode !== 'MXN'
|
|
19
|
+
|
|
20
|
+
type SheetSdk = Pick<
|
|
21
|
+
typeof Stripe,
|
|
22
|
+
'initPaymentSheet' | 'presentPaymentSheet' | 'retrievePaymentIntent' | 'LinkDisplay'
|
|
23
|
+
>
|
|
24
|
+
|
|
25
|
+
export const NATIVE_SHEET_CARD_FIELD_REQUIRED = 'NATIVE_SHEET_CARD_FIELD_REQUIRED'
|
|
26
|
+
export const NATIVE_SHEET_CARD_FIELD_MESSAGE =
|
|
27
|
+
'Enter your card details below, then select Pay to continue.'
|
|
28
|
+
|
|
29
|
+
export const NATIVE_SHEET_REVIEW_REQUIRED =
|
|
30
|
+
'The original payment details changed. Review this order before paying.'
|
|
31
|
+
|
|
32
|
+
/** Financial identity is independent of method eligibility. A mismatch must
|
|
33
|
+
* never authorize the legacy CardField path to confirm the same stale review. */
|
|
34
|
+
export const nativeSheetIntentIdentity = (
|
|
35
|
+
intent: unknown,
|
|
36
|
+
intentId: string,
|
|
37
|
+
reviewed: StripeCardPaymentContext | undefined
|
|
38
|
+
): boolean => {
|
|
39
|
+
if (
|
|
40
|
+
!intent ||
|
|
41
|
+
typeof intent !== 'object' ||
|
|
42
|
+
!reviewed ||
|
|
43
|
+
!Number.isSafeInteger(reviewed.amountMinor) ||
|
|
44
|
+
reviewed.amountMinor <= 0 ||
|
|
45
|
+
!/^[A-Z]{3}$/.test(reviewed.currency)
|
|
46
|
+
)
|
|
47
|
+
return false
|
|
48
|
+
const own = (key: string): unknown =>
|
|
49
|
+
Object.getOwnPropertyDescriptor(intent, key)?.value
|
|
50
|
+
const amount = own('amount')
|
|
51
|
+
const currency = own('currency')
|
|
52
|
+
return (
|
|
53
|
+
own('id') === intentId &&
|
|
54
|
+
Number.isSafeInteger(amount) &&
|
|
55
|
+
amount === reviewed.amountMinor &&
|
|
56
|
+
typeof currency === 'string' &&
|
|
57
|
+
/^[A-Za-z]{3}$/.test(currency) &&
|
|
58
|
+
currency.toUpperCase() === reviewed.currency
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The bridge must preserve the complete raw PI payment_method_types list.
|
|
63
|
+
* Link has no universal card-fee equivalence contract, so it is not admitted. */
|
|
64
|
+
const nativeSheetCardMethods = (intent: unknown): boolean => {
|
|
65
|
+
if (!intent || typeof intent !== 'object') return false
|
|
66
|
+
const methods = Object.getOwnPropertyDescriptor(intent, 'paymentMethodTypes')?.value
|
|
67
|
+
return Array.isArray(methods) && methods.length === 1 && methods[0] === 'card'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Internal building block. The caller must hold
|
|
71
|
+
* the checkout confirmer gateway for the complete promise and bind assertCurrent
|
|
72
|
+
* to the original order/account initialization, including the return scheme.
|
|
73
|
+
* PaymentSheet owns collection and confirmation; no card details enter JS.
|
|
74
|
+
*/
|
|
75
|
+
export const createPaymentSheetConfirmation = (
|
|
76
|
+
sdk: SheetSdk,
|
|
77
|
+
options: {
|
|
78
|
+
merchantDisplayName: string
|
|
79
|
+
returnURL: string
|
|
80
|
+
reviewed: StripeCardPaymentContext | undefined
|
|
81
|
+
assertCurrent: () => void
|
|
82
|
+
}
|
|
83
|
+
) => {
|
|
84
|
+
let active = false
|
|
85
|
+
const assertAfterPresentation = () => {
|
|
86
|
+
try {
|
|
87
|
+
options.assertCurrent()
|
|
88
|
+
} catch {
|
|
89
|
+
throw paymentProviderError('The original payment owner changed.', 'unknown')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return async (
|
|
93
|
+
clientSecret: string,
|
|
94
|
+
billing: Stripe.BillingDetails
|
|
95
|
+
): Promise<string> => {
|
|
96
|
+
if (active)
|
|
97
|
+
throw paymentProviderError('Another payment sheet is active.', 'notStarted')
|
|
98
|
+
// Only an existing PaymentIntent is admitted. No SetupIntent, deferred
|
|
99
|
+
// intent creation callback, saved Customer or account inference is supplied.
|
|
100
|
+
const intentId = /^(pi_[A-Za-z0-9]+)_secret_[A-Za-z0-9]+$/.exec(clientSecret)?.[1]
|
|
101
|
+
if (!intentId || !options.merchantDisplayName.trim() || !options.returnURL)
|
|
102
|
+
throw paymentProviderError('Payment sheet details are unavailable.', 'notStarted')
|
|
103
|
+
options.assertCurrent()
|
|
104
|
+
active = true
|
|
105
|
+
try {
|
|
106
|
+
const before = await sdk.retrievePaymentIntent(clientSecret)
|
|
107
|
+
options.assertCurrent()
|
|
108
|
+
const requireAdmittedIntent = (result: typeof before) => {
|
|
109
|
+
if (
|
|
110
|
+
result.error ||
|
|
111
|
+
!nativeSheetIntentIdentity(result.paymentIntent, intentId, options.reviewed)
|
|
112
|
+
)
|
|
113
|
+
throw paymentProviderError(NATIVE_SHEET_REVIEW_REQUIRED, 'notStarted')
|
|
114
|
+
if (!nativeSheetCardMethods(result.paymentIntent))
|
|
115
|
+
throw paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted', {
|
|
116
|
+
code: NATIVE_SHEET_CARD_FIELD_REQUIRED,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
requireAdmittedIntent(before)
|
|
120
|
+
if (CONFIRMED_INTENT_STATUSES.includes(before.paymentIntent.status))
|
|
121
|
+
return confirmedStripeIntentStatus(before.paymentIntent)
|
|
122
|
+
const initialized = await sdk.initPaymentSheet({
|
|
123
|
+
paymentIntentClientSecret: clientSecret,
|
|
124
|
+
merchantDisplayName: options.merchantDisplayName,
|
|
125
|
+
returnURL: options.returnURL,
|
|
126
|
+
defaultBillingDetails: billing,
|
|
127
|
+
customFlow: false,
|
|
128
|
+
allowsDelayedPaymentMethods: false,
|
|
129
|
+
// A native Link selection can have a different canonical fee class.
|
|
130
|
+
// Do not infer equivalence from PI eligibility or a display preference.
|
|
131
|
+
link: { display: sdk.LinkDisplay.NEVER },
|
|
132
|
+
})
|
|
133
|
+
if (initialized.error)
|
|
134
|
+
throw paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted', {
|
|
135
|
+
code: NATIVE_SHEET_CARD_FIELD_REQUIRED,
|
|
136
|
+
})
|
|
137
|
+
// A retired owner must never present a sheet initialized for its account.
|
|
138
|
+
options.assertCurrent()
|
|
139
|
+
const ready = await sdk.retrievePaymentIntent(clientSecret)
|
|
140
|
+
options.assertCurrent()
|
|
141
|
+
requireAdmittedIntent(ready)
|
|
142
|
+
if (CONFIRMED_INTENT_STATUSES.includes(ready.paymentIntent.status))
|
|
143
|
+
return confirmedStripeIntentStatus(ready.paymentIntent)
|
|
144
|
+
const presented = await sdk.presentPaymentSheet()
|
|
145
|
+
if (presented.error)
|
|
146
|
+
throw paymentProviderError(
|
|
147
|
+
presented.error.message || 'Payment failed.',
|
|
148
|
+
nativeStripeErrorOutcome(presented.error)
|
|
149
|
+
)
|
|
150
|
+
// A resolved sheet alone is not authorization to finalize the order.
|
|
151
|
+
// Retrieve only the original intent; failures remain ambiguous to the
|
|
152
|
+
// engine, which retains the original order instead of creating another.
|
|
153
|
+
assertAfterPresentation()
|
|
154
|
+
const result = await sdk.retrievePaymentIntent(clientSecret)
|
|
155
|
+
if (
|
|
156
|
+
result.error ||
|
|
157
|
+
!nativeSheetIntentIdentity(result.paymentIntent, intentId, options.reviewed) ||
|
|
158
|
+
!nativeSheetCardMethods(result.paymentIntent)
|
|
159
|
+
)
|
|
160
|
+
throw paymentProviderError(
|
|
161
|
+
'The original payment could not be verified.',
|
|
162
|
+
'unknown'
|
|
163
|
+
)
|
|
164
|
+
assertAfterPresentation()
|
|
165
|
+
return confirmedStripeIntentStatus(result.paymentIntent)
|
|
166
|
+
} finally {
|
|
167
|
+
active = false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/sections/Payment.tsx
CHANGED
|
@@ -10,14 +10,11 @@ import { Accommodation } from './Accommodation'
|
|
|
10
10
|
* - No Stripe Payment Element / wallet widget: web folds Apple/Google Pay into
|
|
11
11
|
* the Element. Native offers Apple/Google Pay as explicit rows only when
|
|
12
12
|
* the backend enables the wallet and Stripe confirms device support.
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - Redirect rails / Razorpay / PayPal / Xendit e-wallets render their rows and
|
|
19
|
-
* call `placeOrder` with the mapped request; the WebView `providerPage` port
|
|
20
|
-
* that presents them is likewise a follow-up (see `confirmer.ts` header).
|
|
13
|
+
* - An explicitly configured native PaymentSheet collects regular Stripe
|
|
14
|
+
* payments. CardField remains for other hosts, MXN installments and new-card
|
|
15
|
+
* payment plans. Existing explicit wallet rows keep their own policy gates.
|
|
16
|
+
* - CheckoutProvider owns provider-page windows for Xendit, redirect methods,
|
|
17
|
+
* Razorpay and PayPal; imperative hosts provide that port themselves.
|
|
21
18
|
* - Conditions render as plain text (HTML stripped) — no DOM to inject markup.
|
|
22
19
|
*/
|
|
23
20
|
import React, { useEffect, useMemo, useState, type FC } from 'react'
|
|
@@ -53,6 +50,7 @@ import {
|
|
|
53
50
|
} from 'tf-checkout-shared'
|
|
54
51
|
|
|
55
52
|
import { CardInput, claimCardField } from '../CardInput'
|
|
53
|
+
import { nativePaymentSheetAvailable } from '../paymentSheet'
|
|
56
54
|
import { rnConfirmer } from '../instance'
|
|
57
55
|
import { useCheckoutContext, useCheckoutValidation } from '../ui/CheckoutProvider'
|
|
58
56
|
import { Checkbox } from '../ui/Field'
|
|
@@ -69,7 +67,6 @@ import {
|
|
|
69
67
|
import { FrozenFields } from '../ui/Frozen'
|
|
70
68
|
import { OrderSummary } from './OrderSummary'
|
|
71
69
|
|
|
72
|
-
|
|
73
70
|
/** Server spellings → display labels for Stripe redirect rails. */
|
|
74
71
|
const REDIRECT_LABELS: Record<string, string> = {
|
|
75
72
|
klarna: 'Klarna',
|
|
@@ -185,8 +182,6 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
185
182
|
}
|
|
186
183
|
}, [])
|
|
187
184
|
|
|
188
|
-
|
|
189
|
-
|
|
190
185
|
const bd = checkout.breakdown
|
|
191
186
|
const isFree = bd !== undefined && Number(bd.total) === 0
|
|
192
187
|
const isXendit = checkout.gateway === 'xendit'
|
|
@@ -274,10 +269,14 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
274
269
|
const isPlanSelected = selectedOption?.id.kind === 'paymentPlan'
|
|
275
270
|
const savedLast4 = methods?.savedPlanCardLast4
|
|
276
271
|
const reuseSavedCard = planUsesSavedCard(savedLast4, useSavedCard)
|
|
272
|
+
const useStripeSheet = Boolean(
|
|
273
|
+
checkout.paymentSheet && isCardSelected && nativePaymentSheetAvailable(checkout)
|
|
274
|
+
)
|
|
277
275
|
// The native Stripe card field: shown for the Stripe card path and for a
|
|
278
276
|
// payment-plan NEW card (SetupIntent). Xendit card is a WebView sheet (below).
|
|
279
277
|
const showStripeCardField =
|
|
280
|
-
!isXendit &&
|
|
278
|
+
!isXendit &&
|
|
279
|
+
((!useStripeSheet && isCardSelected) || (isPlanSelected && !reuseSavedCard))
|
|
281
280
|
const showXenditCardNote = isXendit && isCardSelected
|
|
282
281
|
// One card form at a time owns the shared payment SDK (bundles and tickets
|
|
283
282
|
// alike): while another one does, this form offers "Continue paying here".
|
|
@@ -367,7 +366,8 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
367
366
|
req = { method: 'xenditEWallet', walletKey: id.walletKey, extra: extras[selected] }
|
|
368
367
|
else if (id.kind === 'redirect')
|
|
369
368
|
req = { method: 'redirect', methodType: id.methodType }
|
|
370
|
-
else if (id.kind === 'paymentPlan')
|
|
369
|
+
else if (id.kind === 'paymentPlan')
|
|
370
|
+
req = { method: 'paymentPlan', useSavedCard: reuseSavedCard }
|
|
371
371
|
else req = { method: id.kind } as PaymentRequest
|
|
372
372
|
// Called synchronously in the press handler — popup rails (e-wallet,
|
|
373
373
|
// redirect, razorpay, paypal) want the user gesture on their WebView host.
|
|
@@ -377,15 +377,18 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
377
377
|
// The card field is claimed inside the order, after the payment SDK holds
|
|
378
378
|
// the ticket order's keys and right before the card is confirmed. Whether
|
|
379
379
|
// a key change empties the field is decided in shared (`cardClaimStep`).
|
|
380
|
-
|
|
381
|
-
.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
380
|
+
const payment =
|
|
381
|
+
useStripeSheet && checkout.paymentSheet && !isFree
|
|
382
|
+
? checkout.paymentSheet.placeOrder()
|
|
383
|
+
: checkout.placeOrder(
|
|
384
|
+
req,
|
|
385
|
+
usesOwnCardField
|
|
386
|
+
? {
|
|
387
|
+
claimCard: cardClaimStep('nativeCard', () => claimCardField(cardOwner)),
|
|
388
|
+
}
|
|
389
|
+
: undefined
|
|
390
|
+
)
|
|
391
|
+
payment
|
|
389
392
|
.then((order) => onComplete?.(order))
|
|
390
393
|
// Cart ownership and native field ownership each fail before a charge.
|
|
391
394
|
.catch((e) =>
|
|
@@ -419,6 +422,11 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
419
422
|
) : showStripeCardField ? (
|
|
420
423
|
<CardInput owner={cardOwner} />
|
|
421
424
|
) : null}
|
|
425
|
+
{useStripeSheet && !isFree ? (
|
|
426
|
+
<MutedText>
|
|
427
|
+
Choose your payment details securely with Stripe when you pay.
|
|
428
|
+
</MutedText>
|
|
429
|
+
) : null}
|
|
422
430
|
{showXenditCardNote ? (
|
|
423
431
|
// Xendit card tokenizes in a WebView sheet (deferred — see confirmer.ts).
|
|
424
432
|
<MutedText>
|
|
@@ -474,7 +482,11 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
474
482
|
}
|
|
475
483
|
showValidation={validation.attempt > 0}
|
|
476
484
|
inputSlotFor={
|
|
477
|
-
isPlanSelected
|
|
485
|
+
isPlanSelected
|
|
486
|
+
? reuseSavedCard
|
|
487
|
+
? undefined
|
|
488
|
+
: 'paymentPlan'
|
|
489
|
+
: cardValue
|
|
478
490
|
}
|
|
479
491
|
>
|
|
480
492
|
{paymentInput}
|
|
@@ -505,7 +517,12 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
505
517
|
})}
|
|
506
518
|
</Card>
|
|
507
519
|
|
|
508
|
-
{!isFree && paymentMethods.error ?
|
|
520
|
+
{!isFree && paymentMethods.error ? (
|
|
521
|
+
<Card>
|
|
522
|
+
<ErrorText>{paymentMethods.error.message}</ErrorText>
|
|
523
|
+
<Button onPress={() => void paymentMethods.refetch()}>Try again</Button>
|
|
524
|
+
</Card>
|
|
525
|
+
) : null}
|
|
509
526
|
<OrderSummary translate={t} />
|
|
510
527
|
|
|
511
528
|
<Button
|
|
@@ -24,19 +24,15 @@ import React, {
|
|
|
24
24
|
|
|
25
25
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
hooks,
|
|
29
|
-
type UseCheckout,
|
|
30
|
-
type CheckoutOptions,
|
|
31
|
-
type CheckoutImagePicker,
|
|
32
|
-
} from 'tf-checkout-shared'
|
|
27
|
+
import { hooks, type CheckoutOptions, type CheckoutImagePicker } from 'tf-checkout-shared'
|
|
33
28
|
|
|
34
|
-
import { useCheckout } from '../useCheckout'
|
|
29
|
+
import { useCheckout, type NativeUseCheckout } from '../useCheckout'
|
|
30
|
+
import type { NativePaymentSheetOptions } from '../confirmer'
|
|
35
31
|
import { useInstallmentPicker } from './InstallmentPicker'
|
|
36
32
|
import { useProviderPages } from './ProviderPages'
|
|
37
33
|
import { resolveTheme, type CheckoutTheme, type ResolvedTheme } from './theme'
|
|
38
34
|
|
|
39
|
-
const CheckoutCtx = createContext<
|
|
35
|
+
const CheckoutCtx = createContext<NativeUseCheckout | null>(null)
|
|
40
36
|
const ThemeCtx = createContext<ResolvedTheme>(resolveTheme())
|
|
41
37
|
const ImagePickerCtx = createContext<CheckoutImagePicker | undefined>(undefined)
|
|
42
38
|
const ValidationCtx = createContext<{
|
|
@@ -61,12 +57,14 @@ export interface CheckoutProviderProps {
|
|
|
61
57
|
pickImage?: CheckoutImagePicker
|
|
62
58
|
/** Wallet/redirect settings for the provider-owned native engine. */
|
|
63
59
|
options?: CheckoutOptions
|
|
60
|
+
/** Enable Stripe's native sheet for regular card payments. */
|
|
61
|
+
paymentSheet?: NativePaymentSheetOptions
|
|
64
62
|
/**
|
|
65
63
|
* Bring your own checkout instance (from a top-level `useCheckout()`). Omit
|
|
66
64
|
* and the provider runs `useCheckout()` itself. Either way the sections read
|
|
67
65
|
* the same shared cart (the engine store is a module singleton).
|
|
68
66
|
*/
|
|
69
|
-
value?:
|
|
67
|
+
value?: NativeUseCheckout
|
|
70
68
|
/** Retheme via design tokens. */
|
|
71
69
|
theme?: CheckoutTheme
|
|
72
70
|
/** Provide your own react-query client instead of the bundled shared one. */
|
|
@@ -80,14 +78,17 @@ export interface CheckoutThemeProviderProps {
|
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
/** Retheme standalone SDK screens without binding checkout or query state. */
|
|
83
|
-
export const CheckoutThemeProvider: FC<CheckoutThemeProviderProps> = ({
|
|
81
|
+
export const CheckoutThemeProvider: FC<CheckoutThemeProviderProps> = ({
|
|
82
|
+
theme,
|
|
83
|
+
children,
|
|
84
|
+
}) => {
|
|
84
85
|
const resolved = useMemo(() => resolveTheme(theme), [theme])
|
|
85
86
|
return <ThemeCtx.Provider value={resolved}>{children}</ThemeCtx.Provider>
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/** The context boundary — given a resolved checkout instance + theme. */
|
|
89
90
|
const CheckoutRoot: FC<{
|
|
90
|
-
checkout:
|
|
91
|
+
checkout: NativeUseCheckout
|
|
91
92
|
pickImage?: CheckoutImagePicker
|
|
92
93
|
theme?: CheckoutTheme
|
|
93
94
|
children: ReactNode
|
|
@@ -123,6 +124,7 @@ const SelfProvidedCheckout: FC<Omit<CheckoutProviderProps, 'value' | 'queryClien
|
|
|
123
124
|
const checkout = useCheckout({
|
|
124
125
|
ports: { onInstallmentOffers, providerPage },
|
|
125
126
|
options: rest.options,
|
|
127
|
+
paymentSheet: rest.paymentSheet,
|
|
126
128
|
})
|
|
127
129
|
return (
|
|
128
130
|
<CheckoutRoot checkout={checkout} {...rest}>
|
|
@@ -154,7 +156,7 @@ export const CheckoutProvider: FC<CheckoutProviderProps> = ({
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
/** Read the shared checkout inside any component under `<CheckoutProvider>`. */
|
|
157
|
-
export const useCheckoutContext = ():
|
|
159
|
+
export const useCheckoutContext = (): NativeUseCheckout => {
|
|
158
160
|
const ctx = useContext(CheckoutCtx)
|
|
159
161
|
if (!ctx) {
|
|
160
162
|
throw new Error('useCheckoutContext must be used within <CheckoutProvider>.')
|
package/src/ui/Select.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* list of options. Generic over the option value so callers keep type-safety
|
|
5
5
|
* (country ids, gender codes, currency codes, ISO dates, …).
|
|
6
6
|
*/
|
|
7
|
-
import React, { useMemo, useState } from 'react'
|
|
7
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
9
9
|
|
|
10
10
|
import { useTheme } from './CheckoutProvider'
|
|
@@ -36,6 +36,17 @@ export function Select<T = string>({
|
|
|
36
36
|
const theme = useTheme()
|
|
37
37
|
const s = useSelectStyles(theme)
|
|
38
38
|
const [open, setOpen] = useState(false)
|
|
39
|
+
const interaction = useRef({ disabled, open: false, revision: 0 })
|
|
40
|
+
interaction.current.disabled = disabled
|
|
41
|
+
const revision = interaction.current.revision
|
|
42
|
+
const close = useCallback(() => {
|
|
43
|
+
interaction.current.open = false
|
|
44
|
+
interaction.current.revision += 1
|
|
45
|
+
setOpen(false)
|
|
46
|
+
}, [])
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (disabled && interaction.current.open) close()
|
|
49
|
+
}, [disabled, close])
|
|
39
50
|
|
|
40
51
|
const selected = options.find((o) => o.value === value)
|
|
41
52
|
|
|
@@ -44,7 +55,12 @@ export function Select<T = string>({
|
|
|
44
55
|
<Pressable
|
|
45
56
|
accessibilityRole="button"
|
|
46
57
|
disabled={disabled}
|
|
47
|
-
onPress={() =>
|
|
58
|
+
onPress={() => {
|
|
59
|
+
if (interaction.current.disabled) return
|
|
60
|
+
interaction.current.open = true
|
|
61
|
+
interaction.current.revision += 1
|
|
62
|
+
setOpen(true)
|
|
63
|
+
}}
|
|
48
64
|
style={[s.control, invalid && s.controlInvalid, disabled && s.controlDisabled]}
|
|
49
65
|
>
|
|
50
66
|
<Text style={[s.value, !selected && s.placeholder]} numberOfLines={1}>
|
|
@@ -54,12 +70,12 @@ export function Select<T = string>({
|
|
|
54
70
|
</Pressable>
|
|
55
71
|
|
|
56
72
|
<Modal
|
|
57
|
-
visible={open}
|
|
73
|
+
visible={open && !disabled}
|
|
58
74
|
transparent
|
|
59
75
|
animationType="fade"
|
|
60
|
-
onRequestClose={
|
|
76
|
+
onRequestClose={close}
|
|
61
77
|
>
|
|
62
|
-
<Pressable style={s.backdrop} onPress={
|
|
78
|
+
<Pressable style={s.backdrop} onPress={close}>
|
|
63
79
|
<Pressable style={s.sheet} onPress={() => {}}>
|
|
64
80
|
<FlatList
|
|
65
81
|
data={options}
|
|
@@ -70,9 +86,17 @@ export function Select<T = string>({
|
|
|
70
86
|
return (
|
|
71
87
|
<Pressable
|
|
72
88
|
style={s.option}
|
|
89
|
+
disabled={disabled}
|
|
73
90
|
onPress={() => {
|
|
91
|
+
const current = interaction.current
|
|
92
|
+
if (
|
|
93
|
+
current.disabled ||
|
|
94
|
+
!current.open ||
|
|
95
|
+
current.revision !== revision
|
|
96
|
+
)
|
|
97
|
+
return
|
|
74
98
|
onChange(item.value)
|
|
75
|
-
|
|
99
|
+
close()
|
|
76
100
|
}}
|
|
77
101
|
>
|
|
78
102
|
<Text style={[s.optionLabel, isSelected && s.optionLabelSelected]}>
|
package/src/useCheckout.ts
CHANGED
|
@@ -11,15 +11,27 @@
|
|
|
11
11
|
* checkout.setBilling(b)
|
|
12
12
|
* await checkout.placeOrder('card') // <CardInput/> rendered
|
|
13
13
|
*/
|
|
14
|
+
import { useState } from 'react'
|
|
14
15
|
import {
|
|
15
16
|
useCheckout as useSharedCheckout,
|
|
16
17
|
type CheckoutOptions,
|
|
17
18
|
type CheckoutPorts,
|
|
18
19
|
type UseCheckout,
|
|
20
|
+
paymentProviderError,
|
|
19
21
|
} from 'tf-checkout-shared'
|
|
20
22
|
|
|
21
23
|
import { claimDefaultCardField } from './CardInput'
|
|
22
24
|
import { rnConfirmer, rnPorts } from './instance'
|
|
25
|
+
import type { NativePaymentSheetOptions } from './confirmer'
|
|
26
|
+
import {
|
|
27
|
+
nativePaymentSheetAvailable,
|
|
28
|
+
NATIVE_SHEET_CARD_FIELD_MESSAGE,
|
|
29
|
+
} from './paymentSheet'
|
|
30
|
+
|
|
31
|
+
export interface NativeUseCheckout extends UseCheckout {
|
|
32
|
+
/** Native intent-only sheet. Omitted unless explicitly configured by the host. */
|
|
33
|
+
paymentSheet?: { placeOrder: () => ReturnType<UseCheckout['placeOrder']> }
|
|
34
|
+
}
|
|
23
35
|
|
|
24
36
|
export interface UseCheckoutOptions {
|
|
25
37
|
/** Extra platform ports merged over the native defaults (e.g. an injected
|
|
@@ -27,11 +39,74 @@ export interface UseCheckoutOptions {
|
|
|
27
39
|
ports?: Omit<CheckoutPorts, 'confirmer'>
|
|
28
40
|
/** Wallet/redirect engine options (merchant identity, deep-link scheme). */
|
|
29
41
|
options?: CheckoutOptions
|
|
42
|
+
/** Host must register the return scheme and route callbacks to Stripe. */
|
|
43
|
+
paymentSheet?: NativePaymentSheetOptions
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
export const useCheckout = (opts: UseCheckoutOptions = {}):
|
|
33
|
-
|
|
46
|
+
export const useCheckout = (opts: UseCheckoutOptions = {}): NativeUseCheckout => {
|
|
47
|
+
const [sheetConfig] = useState(() =>
|
|
48
|
+
opts.paymentSheet ? { ...opts.paymentSheet } : undefined
|
|
49
|
+
)
|
|
50
|
+
const [sheetScheme] = useState(() => opts.options?.urlScheme)
|
|
51
|
+
const [sheet] = useState(() =>
|
|
52
|
+
sheetConfig ? rnConfirmer.createPaymentSheetSession(sheetConfig) : undefined
|
|
53
|
+
)
|
|
54
|
+
const nativePorts = { ...rnPorts, ...opts.ports }
|
|
55
|
+
const checkout = useSharedCheckout({
|
|
34
56
|
// A Pay without its own claim uses the default ticket field (`checkout`).
|
|
35
|
-
ports: {
|
|
57
|
+
ports: {
|
|
58
|
+
confirmer: sheet?.confirmer ?? rnConfirmer,
|
|
59
|
+
claimDefaultCardField,
|
|
60
|
+
...nativePorts,
|
|
61
|
+
...(sheet && nativePorts.createCardPaymentMethod
|
|
62
|
+
? {
|
|
63
|
+
createCardPaymentMethod: (billing) =>
|
|
64
|
+
sheet.cardFieldOperation(() =>
|
|
65
|
+
nativePorts.createCardPaymentMethod!(billing)
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
},
|
|
36
70
|
options: opts.options,
|
|
37
71
|
})
|
|
72
|
+
return {
|
|
73
|
+
...checkout,
|
|
74
|
+
paymentSheet:
|
|
75
|
+
sheet && !sheet.requiresCardField()
|
|
76
|
+
? {
|
|
77
|
+
placeOrder: () => {
|
|
78
|
+
if (sheet.requiresCardField())
|
|
79
|
+
return Promise.reject(
|
|
80
|
+
paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted')
|
|
81
|
+
)
|
|
82
|
+
// MSI needs a CardField PaymentMethod before its server-confirmed path.
|
|
83
|
+
// Keep that rail intact rather than collecting a different method in a sheet.
|
|
84
|
+
if (
|
|
85
|
+
!opts.options?.urlScheme ||
|
|
86
|
+
!sheetConfig?.merchantDisplayName.trim() ||
|
|
87
|
+
opts.options.urlScheme !== sheetScheme ||
|
|
88
|
+
opts.paymentSheet?.merchantDisplayName !==
|
|
89
|
+
sheetConfig?.merchantDisplayName ||
|
|
90
|
+
opts.paymentSheet?.returnURL !== sheetConfig?.returnURL ||
|
|
91
|
+
opts.paymentSheet?.returnURL !==
|
|
92
|
+
`${opts.options.urlScheme}://stripe-redirect`
|
|
93
|
+
)
|
|
94
|
+
return Promise.reject(
|
|
95
|
+
paymentProviderError(
|
|
96
|
+
'The payment return route is not configured.',
|
|
97
|
+
'notStarted'
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
if (!nativePaymentSheetAvailable(checkout.store.getState()))
|
|
101
|
+
return Promise.reject(
|
|
102
|
+
paymentProviderError(
|
|
103
|
+
'Use the installment card form for this currency.',
|
|
104
|
+
'notStarted'
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return sheet.run((claimCard) => checkout.placeOrder('card', { claimCard }))
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: undefined,
|
|
111
|
+
}
|
|
112
|
+
}
|