tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.10

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.
Files changed (71) hide show
  1. package/README.md +115 -24
  2. package/dist/index.d.mts +8194 -282
  3. package/dist/index.d.ts +8194 -282
  4. package/dist/index.js +32308 -7001
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32547 -7127
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +23 -11
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/scripts/verify-discussion-support.cjs +131 -0
  12. package/src/CardInput.tsx +64 -3
  13. package/src/EmbeddedCheckout.tsx +140 -38
  14. package/src/auth/AuthModal.tsx +186 -9
  15. package/src/auth/EmailCodeForm.tsx +91 -0
  16. package/src/auth/RegisterForm.tsx +91 -5
  17. package/src/auth/TwoFactorForm.tsx +74 -0
  18. package/src/auth/index.ts +4 -0
  19. package/src/configure.ts +19 -14
  20. package/src/confirmer.ts +112 -20
  21. package/src/generated/seatMapWidget.ts +3 -0
  22. package/src/index.tsx +1 -1
  23. package/src/sections/Accommodation.tsx +57 -0
  24. package/src/sections/AccountDeletion.tsx +347 -0
  25. package/src/sections/AccountEmailEnrichment.tsx +122 -0
  26. package/src/sections/AccountInformation.tsx +139 -0
  27. package/src/sections/AccountPaymentPlan.tsx +105 -0
  28. package/src/sections/AccountRewards.tsx +108 -0
  29. package/src/sections/AddOns.tsx +99 -148
  30. package/src/sections/BillingInfo.tsx +80 -78
  31. package/src/sections/Bundles.tsx +467 -0
  32. package/src/sections/EventDiscussionView.tsx +655 -0
  33. package/src/sections/NotificationPreferences.tsx +244 -0
  34. package/src/sections/OrderSummary.tsx +29 -2
  35. package/src/sections/Passes.tsx +177 -0
  36. package/src/sections/Payment.tsx +170 -53
  37. package/src/sections/PhoneAuthentication.tsx +371 -0
  38. package/src/sections/PostReaction.tsx +122 -0
  39. package/src/sections/ProfileSettings.tsx +446 -0
  40. package/src/sections/Rewards.tsx +320 -0
  41. package/src/sections/SeatMap.tsx +225 -0
  42. package/src/sections/SeatsIoMap.tsx +110 -0
  43. package/src/sections/Streaming.tsx +278 -0
  44. package/src/sections/TicketChange.tsx +379 -0
  45. package/src/sections/TicketGift.tsx +206 -0
  46. package/src/sections/TicketRefund.tsx +104 -0
  47. package/src/sections/Tickets.tsx +289 -208
  48. package/src/sections/Trips.tsx +87 -0
  49. package/src/sections/WaiverSigning.tsx +429 -0
  50. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  51. package/src/sections/billing/TicketHoldersCard.tsx +4 -0
  52. package/src/sections/discussion/CommentImages.tsx +366 -0
  53. package/src/sections/discussion/SwipeReply.tsx +88 -0
  54. package/src/sections/index.ts +46 -0
  55. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  56. package/src/sections/rewards/RewardsTree.tsx +117 -0
  57. package/src/storage.ts +8 -9
  58. package/src/ui/CheckoutProvider.tsx +51 -15
  59. package/src/ui/CustomFields.tsx +48 -12
  60. package/src/ui/Field.tsx +21 -3
  61. package/src/ui/Frozen.tsx +22 -0
  62. package/src/ui/InstallmentPicker.tsx +15 -18
  63. package/src/ui/PaymentMethods.tsx +6 -3
  64. package/src/ui/ProviderPages.tsx +214 -0
  65. package/src/ui/QuantitySelect.tsx +28 -3
  66. package/src/ui/Select.tsx +4 -1
  67. package/src/ui/SignaturePad.tsx +218 -0
  68. package/src/ui/index.ts +3 -0
  69. package/src/ui/primitives.tsx +13 -2
  70. package/src/ui/useAccountT.ts +16 -0
  71. package/src/useCheckout.ts +3 -1
@@ -1,3 +1,4 @@
1
+ import { Accommodation } from './Accommodation'
1
2
  /**
2
3
  * `<Payment/>` — order review + payment-method selection + T&C, then place the
3
4
  * order. The RN analogue of the web `Payment` section. Buyer details come from
@@ -7,9 +8,8 @@
7
8
  *
8
9
  * Native adaptations vs web (all behaviour-preserving where the platform allows):
9
10
  * - No Stripe Payment Element / wallet widget: web folds Apple/Google Pay into
10
- * the Element and lists NO wallet rows, so neither do we. Native wallets are
11
- * confirmer legs (`placeOrder({method:'applePay'|'googlePay'})`) — a native
12
- * wallet button would slot in as an extra row triggering that request.
11
+ * the Element. Native offers Apple/Google Pay as explicit rows only when
12
+ * the backend enables the wallet and Stripe confirms device support.
13
13
  * - The card slot renders the native `<CardInput/>` (Stripe `<CardField/>`) for
14
14
  * the Stripe card + payment-plan-new-card paths. Xendit card entry tokenizes
15
15
  * in a WebView sheet (deferred in `confirmer.ts`), so the Xendit card row
@@ -21,23 +21,39 @@
21
21
  * - Conditions render as plain text (HTML stripped) — no DOM to inject markup.
22
22
  */
23
23
  import React, { useEffect, useMemo, useState, type FC } from 'react'
24
- import { View } from 'react-native'
24
+ import { Platform, View } from 'react-native'
25
+ import { isPlatformPaySupported } from '@stripe/stripe-react-native'
25
26
 
26
27
  import {
28
+ ConfirmerBusyError,
29
+ CardClaimError,
30
+ cardClaimStep,
31
+ CartChangedError,
27
32
  XENDIT_EWALLET_EXTRA_FIELD,
33
+ cartTicketCount,
34
+ getConfig,
28
35
  errorMessage,
36
+ htmlToPlainText,
29
37
  missingCheckoutDetails,
38
+ paymentAcknowledgementsMet,
30
39
  paymentPlanTerms,
31
40
  planConfirmLabel,
32
41
  planTermsTitle,
33
- ttf,
42
+ stripeOffersOxxo,
43
+ planUsesSavedCard,
34
44
  type CheckoutPaymentPlanPreview,
35
45
  type EventCondition,
46
+ type LocaleCode,
47
+ type Messages,
48
+ type Translate,
36
49
  type PaymentRequest,
37
50
  type PlaceOrderResult,
51
+ hooks,
52
+ createTranslator,
38
53
  } from 'tf-checkout-shared'
39
54
 
40
- import { CardInput } from '../CardInput'
55
+ import { CardInput, claimCardField } from '../CardInput'
56
+ import { rnConfirmer } from '../instance'
41
57
  import { useCheckoutContext, useCheckoutValidation } from '../ui/CheckoutProvider'
42
58
  import { Checkbox } from '../ui/Field'
43
59
  import { PaymentMethods, type PaymentMethodOption } from '../ui/PaymentMethods'
@@ -50,9 +66,9 @@ import {
50
66
  MutedText,
51
67
  SectionHeader,
52
68
  } from '../ui/primitives'
69
+ import { FrozenFields } from '../ui/Frozen'
53
70
  import { OrderSummary } from './OrderSummary'
54
71
 
55
- type PreOrderMethods = Awaited<ReturnType<typeof ttf.cart.preOrderMethods>>
56
72
 
57
73
  /** Server spellings → display labels for Stripe redirect rails. */
58
74
  const REDIRECT_LABELS: Record<string, string> = {
@@ -72,17 +88,9 @@ const REDIRECT_LABELS: Record<string, string> = {
72
88
  const redirectLabel = (t: string) =>
73
89
  REDIRECT_LABELS[t] ?? t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
74
90
 
75
- /** Strip HTML from condition copy — native has no DOM to render markup into. */
76
- const stripHtml = (html: string) =>
77
- html
78
- .replace(/<[^>]*>/g, '')
79
- .replace(/&amp;/g, '&')
80
- .replace(/&lt;/g, '<')
81
- .replace(/&gt;/g, '>')
82
- .replace(/&nbsp;/g, ' ')
83
- .replace(/&#39;/g, "'")
84
- .replace(/&quot;/g, '"')
85
- .trim()
91
+ /** Condition copy as one line of plain text: native has no DOM to render
92
+ * markup into, and the copy is a checkbox label. */
93
+ const conditionText = (html: string) => htmlToPlainText(html).replace(/\s*\n\s*/g, ' ')
86
94
 
87
95
  export interface PaymentProps {
88
96
  onComplete?: (order: PlaceOrderResult) => void
@@ -103,6 +111,10 @@ export interface PaymentProps {
103
111
  onPrivacyRequired?: () => void
104
112
  /** Test card copy is useful in examples, but should never appear by default. */
105
113
  showTestCardHint?: boolean
114
+ /** The language of the section's messages (en by default). */
115
+ locale?: LocaleCode
116
+ messages?: Partial<Messages>
117
+ translate?: Translate
106
118
  }
107
119
 
108
120
  /**
@@ -135,31 +147,50 @@ export const Payment: FC<PaymentProps> = ({
135
147
  requirePrivacyPolicy = false,
136
148
  onPrivacyRequired,
137
149
  showTestCardHint = false,
150
+ locale,
151
+ messages,
152
+ translate,
138
153
  }) => {
154
+ const t = translate ?? createTranslator({ locale, messages })
139
155
  const checkout = useCheckoutContext()
156
+ // This checkout's own card field: another checkout on the page has its own.
157
+ const cardOwner = hooks.useTicketCardOwner()
158
+ // A bundle purchase holds the payment SDK: Pay would do nothing now.
159
+ // Why a bundle holds the payment SDK (paying, or setting up a card form).
160
+ const bundleWait = hooks.useTicketGatewayWait(checkout.store)
161
+ const bundleStarting = bundleWait !== null
140
162
  const validation = useCheckoutValidation()
141
163
  const [error, setError] = useState('')
142
- const [methods, setMethods] = useState<PreOrderMethods | null>(null)
164
+ const paymentMethods = hooks.useCheckoutPaymentMethods(checkout.store)
165
+ const methods = paymentMethods.data ?? null
143
166
  const [selected, setSelected] = useState('')
144
167
  const [extras, setExtras] = useState<
145
168
  Record<string, { cashtag?: string; phoneNumber?: string }>
146
169
  >({})
147
170
  const [useSavedCard, setUseSavedCard] = useState(true)
171
+ const [nativeWalletSupported, setNativeWalletSupported] = useState(false)
148
172
 
149
173
  useEffect(() => {
150
- let active = true
151
- ttf.cart
152
- .preOrderMethods()
153
- .then((m) => active && setMethods(m))
154
- .catch(() => active && setMethods(null))
174
+ let mounted = true
175
+ const env = getConfig().env
176
+ isPlatformPaySupported({ googlePay: { testEnv: env === 'STAG' || env === 'DEV' } })
177
+ .then((supported) => {
178
+ if (mounted) setNativeWalletSupported(supported)
179
+ })
180
+ .catch(() => {
181
+ if (mounted) setNativeWalletSupported(false)
182
+ })
155
183
  return () => {
156
- active = false
184
+ mounted = false
157
185
  }
158
186
  }, [])
159
187
 
188
+
189
+
160
190
  const bd = checkout.breakdown
161
191
  const isFree = bd !== undefined && Number(bd.total) === 0
162
192
  const isXendit = checkout.gateway === 'xendit'
193
+ const offersOxxo = stripeOffersOxxo(checkout.stripeElementsConfig)
163
194
 
164
195
  // Build the selectable method rows from gateway + probe + snapshot.
165
196
  const options = useMemo<PaymentMethodOption[]>(() => {
@@ -178,9 +209,25 @@ export const Payment: FC<PaymentProps> = ({
178
209
  })
179
210
  } else if (checkout.gateway === 'razorpay') {
180
211
  out.push({ id: { kind: 'razorpay' }, value: 'razorpay', label: 'Razorpay' })
212
+ } else if (checkout.gateway === 'adyen') {
213
+ out.push({ id: { kind: 'adyen' }, value: 'adyen', label: 'Adyen' })
181
214
  } else {
182
215
  // Stripe (or unknown → fail open to card).
183
- out.push({ id: { kind: 'card' }, value: 'card', label: 'Card' })
216
+ out.push({ id: { kind: 'card' }, value: 'card', label: 'Credit or debit card' })
217
+ // A device wallet is offered only when Stripe's server configuration AND
218
+ // the current device allow it. Never infer availability from the OS alone.
219
+ if (
220
+ nativeWalletSupported &&
221
+ checkout.walletAvailability?.applePay &&
222
+ Platform.OS === 'ios'
223
+ )
224
+ out.push({ id: { kind: 'applePay' }, value: 'applePay', label: 'Apple Pay' })
225
+ if (
226
+ nativeWalletSupported &&
227
+ checkout.walletAvailability?.googlePay &&
228
+ Platform.OS === 'android'
229
+ )
230
+ out.push({ id: { kind: 'googlePay' }, value: 'googlePay', label: 'Google Pay' })
184
231
  for (const t of checkout.redirectPaymentMethods ?? []) {
185
232
  if (t === 'paypal' && methods.paypal) continue // dedupe vs PayPal Standard
186
233
  out.push({
@@ -195,8 +242,8 @@ export const Payment: FC<PaymentProps> = ({
195
242
  value: 'paymentPlan',
196
243
  label: 'Payment plan',
197
244
  })
198
- // OXXO cash voucher — Mexico only, and never listed in redirect methods.
199
- if (checkout.breakdown?.currencyCode === 'MXN')
245
+ // The server applies merchant, event, date and capture restrictions.
246
+ if (offersOxxo)
200
247
  out.push({ id: { kind: 'oxxo' }, value: 'oxxo', label: 'OXXO (pay in cash)' })
201
248
  }
202
249
  if (methods.paypal)
@@ -210,6 +257,9 @@ export const Payment: FC<PaymentProps> = ({
210
257
  checkout.redirectPaymentMethods,
211
258
  checkout.paymentPlan,
212
259
  checkout.breakdown,
260
+ checkout.walletAvailability,
261
+ nativeWalletSupported,
262
+ offersOxxo,
213
263
  ])
214
264
 
215
265
  // Default the selection to the first row once options exist.
@@ -222,11 +272,21 @@ export const Payment: FC<PaymentProps> = ({
222
272
  const selectedOption = options.find((o) => o.value === selected)
223
273
  const isCardSelected = selected === cardValue
224
274
  const isPlanSelected = selectedOption?.id.kind === 'paymentPlan'
275
+ const savedLast4 = methods?.savedPlanCardLast4
276
+ const reuseSavedCard = planUsesSavedCard(savedLast4, useSavedCard)
225
277
  // The native Stripe card field: shown for the Stripe card path and for a
226
278
  // payment-plan NEW card (SetupIntent). Xendit card is a WebView sheet (below).
227
279
  const showStripeCardField =
228
- !isXendit && (isCardSelected || (isPlanSelected && !useSavedCard))
280
+ !isXendit && (isCardSelected || (isPlanSelected && !reuseSavedCard))
229
281
  const showXenditCardNote = isXendit && isCardSelected
282
+ // One card form at a time owns the shared payment SDK (bundles and tickets
283
+ // alike): while another one does, this form offers "Continue paying here".
284
+ const ticketCard = hooks.useTicketCardForm({
285
+ confirmer: rnConfirmer,
286
+ store: checkout.store,
287
+ owner: cardOwner,
288
+ showsStripeField: showStripeCardField && !isFree,
289
+ })
230
290
 
231
291
  // When there's only ONE way to pay and it's a card, there's nothing to
232
292
  // *choose* — render the field directly, no radio wrapper (matching web).
@@ -251,17 +311,19 @@ export const Payment: FC<PaymentProps> = ({
251
311
  )
252
312
  const billingReady = missingDetails.length === 0
253
313
  const conditions = [...(checkout.conditions ?? []), ...additionalConditions]
254
- const requiredConditions = conditions.filter((c) => c.isRequired)
255
- const conditionsOk = requiredConditions.every(
256
- (c) => checkout.acceptedConditions?.[c.id]
257
- )
314
+ const { conditionsOk, privacyOk } = paymentAcknowledgementsMet({
315
+ eventConditions: checkout.conditions,
316
+ additionalConditions,
317
+ acceptedConditions: checkout.acceptedConditions,
318
+ requirePrivacyPolicy,
319
+ privacyAccepted: checkout.consent.ttfOptIn,
320
+ })
258
321
  const extra = selectedOption?.extra
259
322
  const extrasOk =
260
323
  !extra ||
261
324
  (extra === 'cashtag'
262
325
  ? Boolean(extras[selected]?.cashtag?.trim())
263
326
  : Boolean(extras[selected]?.phoneNumber?.trim()))
264
- const privacyOk = !requirePrivacyPolicy || checkout.consent.ttfOptIn
265
327
  // The plan CTA carries the brand's terminology ("Confirm Layaway"), and the
266
328
  // terms sentence quotes this same label — they must agree.
267
329
  const payLabel = isFree
@@ -280,15 +342,22 @@ export const Payment: FC<PaymentProps> = ({
280
342
  validation.requestValidation()
281
343
  return
282
344
  }
345
+ // This checkout's own card field is claimed inside the confirmer gateway,
346
+ // together with the order, so a bundle holding the payment SDK never
347
+ // loses its field to a click that cannot proceed.
348
+ const usesOwnCardField = showStripeCardField && !isFree
283
349
  // Backfill one holder per ticket if billing was set without holders.
284
350
  if (checkout.holders.length === 0 && b) {
285
- const n = checkout.selections.reduce((acc, s) => acc + s.quantity, 0) || 1
351
+ const n = cartTicketCount(checkout)
352
+ // Marked as filled in, not entered: a guardian's signature may count
353
+ // under the buyer's name only on these (`holderBackfilledFrom`).
286
354
  checkout.setHolders(
287
355
  Array.from({ length: n }, () => ({
288
356
  firstName: b.firstName,
289
357
  lastName: b.lastName,
290
358
  email: b.email,
291
- }))
359
+ })),
360
+ { backfilled: true }
292
361
  )
293
362
  }
294
363
  let req: PaymentRequest = { method: 'auto' }
@@ -298,18 +367,40 @@ export const Payment: FC<PaymentProps> = ({
298
367
  req = { method: 'xenditEWallet', walletKey: id.walletKey, extra: extras[selected] }
299
368
  else if (id.kind === 'redirect')
300
369
  req = { method: 'redirect', methodType: id.methodType }
301
- else if (id.kind === 'paymentPlan') req = { method: 'paymentPlan', useSavedCard }
370
+ else if (id.kind === 'paymentPlan') req = { method: 'paymentPlan', useSavedCard: reuseSavedCard }
302
371
  else req = { method: id.kind } as PaymentRequest
303
372
  // Called synchronously in the press handler — popup rails (e-wallet,
304
373
  // redirect, razorpay, paypal) want the user gesture on their WebView host.
374
+ // `placeOrder` runs in the confirmer gateway, so a bundle or another
375
+ // checkout cannot point the one payment SDK elsewhere mid-order. It still
376
+ // starts inside this press.
377
+ // The card field is claimed inside the order, after the payment SDK holds
378
+ // the ticket order's keys and right before the card is confirmed. Whether
379
+ // a key change empties the field is decided in shared (`cardClaimStep`).
305
380
  checkout
306
- .placeOrder(req)
381
+ .placeOrder(
382
+ req,
383
+ usesOwnCardField
384
+ ? {
385
+ claimCard: cardClaimStep('nativeCard', () => claimCardField(cardOwner)),
386
+ }
387
+ : undefined
388
+ )
307
389
  .then((order) => onComplete?.(order))
308
- .catch((e) => setError(errorMessage(e)))
390
+ // Cart ownership and native field ownership each fail before a charge.
391
+ .catch((e) =>
392
+ setError(
393
+ e instanceof CartChangedError
394
+ ? t('checkout.error.cartChanged')
395
+ : e instanceof CardClaimError || e instanceof ConfirmerBusyError
396
+ ? createTranslator()(e.message)
397
+ : errorMessage(e)
398
+ )
399
+ )
309
400
  }
310
401
 
311
- const savedLast4 = checkout.paymentPlan?.savedCardLast4
312
- const checkoutError = checkout.error ?? ''
402
+ // A card claim stores its translation key; a server message passes through.
403
+ const checkoutError = checkout.error ? createTranslator()(checkout.error) : ''
313
404
 
314
405
  // The card/plan field + its note + plan terms. Rendered inside the selected
315
406
  // method row when there's a choice, or directly when there's only a card.
@@ -318,7 +409,16 @@ export const Payment: FC<PaymentProps> = ({
318
409
  : 'Test card: 4242 4242 4242 4242 · any future date · any CVC'
319
410
  const paymentInput = (
320
411
  <>
321
- {showStripeCardField ? <CardInput /> : null}
412
+ {showStripeCardField && ticketCard.cardElsewhere ? (
413
+ <>
414
+ <MutedText>{createTranslator()('bundles.payingElsewhere')}</MutedText>
415
+ <Button variant="ghost" onPress={ticketCard.payHere}>
416
+ {createTranslator()('bundles.payHere')}
417
+ </Button>
418
+ </>
419
+ ) : showStripeCardField ? (
420
+ <CardInput owner={cardOwner} />
421
+ ) : null}
322
422
  {showXenditCardNote ? (
323
423
  // Xendit card tokenizes in a WebView sheet (deferred — see confirmer.ts).
324
424
  <MutedText>
@@ -339,7 +439,11 @@ export const Payment: FC<PaymentProps> = ({
339
439
  <MutedText>
340
440
  Your payment will proceed with the card ending in **** {savedLast4}
341
441
  </MutedText>
342
- <Checkbox checked={useSavedCard} onChange={setUseSavedCard} label="Use this card" />
442
+ <Checkbox
443
+ checked={useSavedCard}
444
+ onChange={setUseSavedCard}
445
+ label="Use this card"
446
+ />
343
447
  </View>
344
448
  ) : null}
345
449
  </>
@@ -347,6 +451,7 @@ export const Payment: FC<PaymentProps> = ({
347
451
 
348
452
  return (
349
453
  <View style={{ gap: 12 }}>
454
+ {checkout.eventId && <Accommodation eventId={checkout.eventId} />}
350
455
  <Card>
351
456
  <SectionHeader title="Payment" />
352
457
 
@@ -369,7 +474,7 @@ export const Payment: FC<PaymentProps> = ({
369
474
  }
370
475
  showValidation={validation.attempt > 0}
371
476
  inputSlotFor={
372
- isPlanSelected ? (useSavedCard ? undefined : 'paymentPlan') : cardValue
477
+ isPlanSelected ? (reuseSavedCard ? undefined : 'paymentPlan') : cardValue
373
478
  }
374
479
  >
375
480
  {paymentInput}
@@ -384,12 +489,14 @@ export const Payment: FC<PaymentProps> = ({
384
489
  validation.attempt > 0 && c.isRequired && !checkout.acceptedConditions?.[c.id]
385
490
  return (
386
491
  <View key={c.id} style={{ gap: 4 }}>
387
- <Checkbox
388
- checked={Boolean(checkout.acceptedConditions?.[c.id])}
389
- invalid={invalid}
390
- onChange={(next) => checkout.acceptCondition(c.id, next)}
391
- label={`${stripHtml(c.content || c.name)}${c.isRequired ? ' *' : ''}`}
392
- />
492
+ <FrozenFields frozen={checkout.orderFixed}>
493
+ <Checkbox
494
+ checked={Boolean(checkout.acceptedConditions?.[c.id])}
495
+ invalid={invalid}
496
+ onChange={(next) => checkout.acceptCondition(c.id, next)}
497
+ label={`${conditionText(c.content || c.name)}${c.isRequired ? ' *' : ''}`}
498
+ />
499
+ </FrozenFields>
393
500
  {invalid ? (
394
501
  <ErrorText>You must accept this condition to continue.</ErrorText>
395
502
  ) : null}
@@ -398,18 +505,28 @@ export const Payment: FC<PaymentProps> = ({
398
505
  })}
399
506
  </Card>
400
507
 
401
- <OrderSummary />
508
+ {!isFree && paymentMethods.error ? <Card><ErrorText>{paymentMethods.error.message}</ErrorText><Button onPress={()=>void paymentMethods.refetch()}>Try again</Button></Card> : null}
509
+ <OrderSummary translate={t} />
402
510
 
403
511
  <Button
404
- onPress={pay}
512
+ onPress={() => pay()}
405
513
  // Stay busy through 'succeeded' too: in redirect mode the checkout
406
514
  // remains mounted while the host navigates away, so the button must not
407
515
  // flip back to a clickable state before the redirect lands.
408
516
  loading={finalizing}
409
- disabled={checkout.isBusy || finalizing || (!isFree && !methods)}
517
+ disabled={
518
+ checkout.isBusy ||
519
+ finalizing ||
520
+ bundleStarting ||
521
+ ticketCard.cardElsewhere ||
522
+ (!isFree && !methods)
523
+ }
410
524
  >
411
525
  {finalizing ? 'Placing…' : payLabel}
412
526
  </Button>
527
+ {bundleStarting ? (
528
+ <MutedText>{createTranslator()(bundleWait ?? 'tickets.waitBundle')}</MutedText>
529
+ ) : null}
413
530
 
414
531
  {error || checkoutError ? <ErrorText>{error || checkoutError}</ErrorText> : null}
415
532
  </View>