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

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 +8195 -282
  3. package/dist/index.d.ts +8195 -282
  4. package/dist/index.js +32346 -7001
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32586 -7128
  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
@@ -9,16 +9,28 @@
9
9
  * for seated events. Reads the shared engine via `useCheckoutContext()`; primes
10
10
  * the cart with `checkout.setTickets`.
11
11
  */
12
- import React, { Fragment, useEffect, useMemo, useRef, useState, type FC } from 'react'
12
+ import React, {
13
+ Fragment,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type FC,
19
+ type ReactNode,
20
+ } from 'react'
13
21
  import { Pressable, StyleSheet, Text, View } from 'react-native'
14
22
 
15
23
  import {
24
+ ConfirmerBusyError,
25
+ changeTicketQuantity,
16
26
  errorMessage,
17
27
  hooks,
28
+ htmlToPlainText,
18
29
  ticketQuantityRange,
19
30
  type TicketSelection,
20
31
  type TicketType,
21
32
  type WaitingListEntry,
33
+ createTranslator,
22
34
  } from 'tf-checkout-shared'
23
35
 
24
36
  import {
@@ -30,30 +42,13 @@ import {
30
42
  MutedText,
31
43
  SectionHeader,
32
44
  } from '../ui/primitives'
45
+ import { FrozenFields } from '../ui/Frozen'
33
46
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
34
47
  import { Field, Input } from '../ui/Field'
35
48
  import { QuantitySelect } from '../ui/QuantitySelect'
36
49
  import { Select } from '../ui/Select'
37
50
  import type { ResolvedTheme } from '../ui/theme'
38
51
 
39
- // Web renders the rich description via `dangerouslySetInnerHTML`. React Native
40
- // has no HTML renderer, so we strip tags + decode a few common entities to show
41
- // the description as plain text (the adaptation for native).
42
- const stripHtml = (html: string): string =>
43
- html
44
- .replace(/<\s*br\s*\/?\s*>/gi, '\n')
45
- .replace(/<\/\s*(p|div|li|h[1-6])\s*>/gi, '\n')
46
- .replace(/<[^>]+>/g, '')
47
- .replace(/&nbsp;/gi, ' ')
48
- .replace(/&amp;/gi, '&')
49
- .replace(/&lt;/gi, '<')
50
- .replace(/&gt;/gi, '>')
51
- .replace(/&#39;|&apos;/gi, "'")
52
- .replace(/&quot;/gi, '"')
53
- .replace(/\n{3,}/g, '\n\n')
54
- .replace(/[ \t]+\n/g, '\n')
55
- .trim()
56
-
57
52
  const money = (sym: string, n: number): string => `${sym} ${Number(n).toFixed(2)}`
58
53
 
59
54
  /** Inline waiting-list sign-up for a sold-out type. */
@@ -118,7 +113,29 @@ const WaitingListForm: FC<{
118
113
  )
119
114
  }
120
115
 
116
+ /** Host-owned visuals over the shared catalog and reservation flow. */
117
+ export interface TicketRowRenderProps {
118
+ ticket: TicketType
119
+ quantity: number
120
+ range: { min: number; max: number; step: number }
121
+ unavailable: boolean
122
+ seated: boolean
123
+ onQuantityChange: (quantity: number) => void
124
+ }
125
+
121
126
  export interface TicketsProps {
127
+ /** Replace a row's visuals; availability, selection and cart rules stay SDK-owned. */
128
+ renderTicket?: (props: TicketRowRenderProps) => ReactNode
129
+ /** Replace the reserve CTA without reimplementing reservation or validation. */
130
+ renderContinue?: (props: {
131
+ onPress: () => void
132
+ disabled: boolean
133
+ loading: boolean
134
+ title: string
135
+ }) => ReactNode
136
+ /** Render the catalog directly in the host's own screen/card. */
137
+ unstyled?: boolean
138
+
122
139
  eventId: string
123
140
  /** Optional heading above the catalog. The legacy storefront shows none, so
124
141
  * the default is intentionally hidden. */
@@ -153,11 +170,20 @@ export const Tickets: FC<TicketsProps> = ({
153
170
  initialPromoCode,
154
171
  referrer,
155
172
  readAttributionFromUrl = true,
173
+ renderTicket,
174
+ renderContinue,
175
+ unstyled = false,
156
176
  }) => {
157
177
  const checkout = useCheckoutContext()
158
178
  const theme = useTheme()
159
179
  const s = useTicketStyles(theme)
160
180
  const ticketsQuery = hooks.useTicketsWithMeta(eventId)
181
+ // A bundle order is starting: wait, so the two flows never start at once.
182
+ // The checkout start lock is held by a bundle start or pay (shown as a
183
+ // wait), or by this checkout's own priming (shown as the busy button).
184
+ // Why a bundle holds the payment SDK (paying, or setting up a card form).
185
+ const bundleWait = hooks.useTicketGatewayWait(checkout.store)
186
+ const bundleStarting = bundleWait !== null
161
187
  const promoMeta = ticketsQuery.data?.promo
162
188
  const showWaitingList = ticketsQuery.data?.showWaitingList ?? false
163
189
 
@@ -184,6 +210,14 @@ export const Tickets: FC<TicketsProps> = ({
184
210
  const [code, setCode] = useState('')
185
211
  const [codeState, setCodeState] = useState<'idle' | 'busy'>('idle')
186
212
  const [promoOpen, setPromoOpen] = useState(false)
213
+ // Another event: its tickets, date and code start fresh.
214
+ hooks.useOnEventChange(eventId, () => {
215
+ setQty({})
216
+ setSlotDate('')
217
+ setCode('')
218
+ setPromoOpen(false)
219
+ setError('')
220
+ })
187
221
  const [wlOpen, setWlOpen] = useState<Record<string, boolean>>({})
188
222
  const [visibleDescription, setVisibleDescription] = useState<string | null>(null)
189
223
  const attributionEvent = useRef<string | null>(null)
@@ -253,21 +287,10 @@ export const Tickets: FC<TicketsProps> = ({
253
287
  // into multiple types (`allowMultipleTicketTypePurchases`), a new selection
254
288
  // REPLACES the previous one — one ticket type per order. Even when multiple are
255
289
  // allowed, switching between a table and a regular ticket clears the rest.
256
- const selectQty = (t: TicketType, value: number) =>
257
- setQty((prev) => {
258
- const activeIds = Object.keys(prev).filter((k) => (prev[k] ?? 0) > 0)
259
- const prevIsTable = activeIds.length
260
- ? Boolean(tickets.find((x) => x.id === activeIds[0])?.isTable)
261
- : t.isTable
262
- const switchingKind = activeIds.length > 0 && prevIsTable !== t.isTable
263
-
264
- if (!allowMultiple) return value > 0 ? { [t.id]: value } : {}
265
- if (switchingKind && value > 0) return { [t.id]: value }
266
- const next = { ...prev }
267
- if (value > 0) next[t.id] = value
268
- else delete next[t.id]
269
- return next
270
- })
290
+ const selectQty = (ticket: TicketType, value: number) =>
291
+ setQty((previous) =>
292
+ changeTicketQuantity(previous, tickets, ticket, value, allowMultiple)
293
+ )
271
294
 
272
295
  // Seated events: the buyer picks on a seat map, so seat-map ticket types show
273
296
  // read-only (no quantity control) and the CTA becomes "Select seats". A type
@@ -296,10 +319,18 @@ export const Tickets: FC<TicketsProps> = ({
296
319
  quantity: qty[t.id] ?? 0,
297
320
  currency: t.currency,
298
321
  }))
322
+ // `setTickets` runs in the confirmer gateway: while another flow holds the
323
+ // payment SDK nothing is primed, and the error names that flow.
299
324
  checkout
300
325
  .setTickets(eventId, selections)
301
326
  .then(() => onContinue?.())
302
- .catch((e) => setError(errorMessage(e)))
327
+ .catch((e) =>
328
+ setError(
329
+ e instanceof ConfirmerBusyError
330
+ ? createTranslator()(e.message)
331
+ : errorMessage(e)
332
+ )
333
+ )
303
334
  }
304
335
 
305
336
  const applyCode = () => {
@@ -345,71 +376,90 @@ export const Tickets: FC<TicketsProps> = ({
345
376
  return (
346
377
  <Fragment key={t.id}>
347
378
  {groupHeading}
348
- <View style={s.rowWrap}>
349
- <View style={s.row}>
350
- <View style={s.info}>
351
- <View style={s.nameRow}>
352
- <Text style={s.name}>{t.name}</Text>
353
- {t.descriptionRich ? (
354
- <Pressable
355
- accessibilityRole="button"
356
- onPress={() =>
357
- setVisibleDescription((cur) => (cur === t.id ? null : t.id))
358
- }
359
- >
360
- <Text style={s.infoToggle}>
361
- {visibleDescription === t.id ? 'Hide info' : 'View info'}
379
+ <View style={renderTicket ? undefined : s.rowWrap}>
380
+ {renderTicket ? (
381
+ renderTicket({
382
+ ticket: t,
383
+ quantity: qty[t.id] ?? 0,
384
+ range,
385
+ unavailable: unavailable || checkout.orderFixed,
386
+ seated: seatMapRow(t),
387
+ onQuantityChange: (value) => {
388
+ if (!checkout.orderFixed) selectQty(t, value)
389
+ },
390
+ })
391
+ ) : (
392
+ <>
393
+ <View style={s.row}>
394
+ <View style={s.info}>
395
+ <View style={s.nameRow}>
396
+ <Text style={s.name}>{t.name}</Text>
397
+ {t.descriptionRich ? (
398
+ <Pressable
399
+ accessibilityRole="button"
400
+ onPress={() =>
401
+ setVisibleDescription((cur) => (cur === t.id ? null : t.id))
402
+ }
403
+ >
404
+ <Text style={s.infoToggle}>
405
+ {visibleDescription === t.id ? 'Hide info' : 'View info'}
406
+ </Text>
407
+ </Pressable>
408
+ ) : null}
409
+ </View>
410
+ <View style={s.priceRow}>
411
+ {discounted && original != null ? (
412
+ <Text style={s.oldPrice}>{money(sym, original)}</Text>
413
+ ) : null}
414
+ <Text style={[s.amount, isFree && s.amountFree]}>
415
+ {isFree ? 'FREE' : money(sym, t.price)}
362
416
  </Text>
363
- </Pressable>
364
- ) : null}
365
- </View>
366
- <View style={s.priceRow}>
367
- {discounted && original != null ? (
368
- <Text style={s.oldPrice}>{money(sym, original)}</Text>
369
- ) : null}
370
- <Text style={[s.amount, isFree && s.amountFree]}>
371
- {isFree ? 'FREE' : money(sym, t.price)}
372
- </Text>
373
- {/* Native affordance kept from the RN ticket screen: tables price
417
+ {/* Native affordance kept from the RN ticket screen: tables price
374
418
  per table. */}
375
- {t.isTable ? <Text style={s.perTable}>· per table</Text> : null}
376
- {/* Fees sub-line — omitted for free / sold-out types. */}
377
- {!isFree && !t.soldOut ? (
378
- <Text style={s.fees}>({money(sym, t.priceWithFees)} with fees)</Text>
379
- ) : null}
380
- {t.depositPercent != null ? (
381
- <Text style={s.deposit}>{t.depositPercent.toFixed(2)}% DEPOSIT</Text>
382
- ) : null}
383
- </View>
384
- </View>
385
- {/* Seat-map types are read-only here — no quantity control; the buyer
419
+ {t.isTable ? <Text style={s.perTable}>· per table</Text> : null}
420
+ {/* Fees sub-line — omitted for free / sold-out types. */}
421
+ {!isFree && !t.soldOut ? (
422
+ <Text style={s.fees}>
423
+ ({money(sym, t.priceWithFees)} with fees)
424
+ </Text>
425
+ ) : null}
426
+ {t.depositPercent != null ? (
427
+ <Text style={s.deposit}>
428
+ {t.depositPercent.toFixed(2)}% DEPOSIT
429
+ </Text>
430
+ ) : null}
431
+ </View>
432
+ </View>
433
+ {/* Seat-map types are read-only here — no quantity control; the buyer
386
434
  picks them on the map via "Select seats". */}
387
- {seatMapRow(t) ? null : unavailable ? (
388
- <Text style={s.soldout}>
389
- {t.soldOut
390
- ? 'Sold out'
391
- : notStarted
392
- ? 'Sales not started'
393
- : 'Sales Ended'}
394
- </Text>
395
- ) : (
396
- <View style={s.qtyWrap}>
397
- {/* Tables pick a number of GUESTS (bounded by minGuests/maxGuests),
435
+ {seatMapRow(t) ? null : unavailable ? (
436
+ <Text style={s.soldout}>
437
+ {t.soldOut
438
+ ? 'Sold out'
439
+ : notStarted
440
+ ? 'Sales not started'
441
+ : 'Sales Ended'}
442
+ </Text>
443
+ ) : (
444
+ <View style={s.qtyWrap}>
445
+ {/* Tables pick a number of GUESTS (bounded by minGuests/maxGuests),
398
446
  not a count of tickets — the label makes that explicit. */}
399
- {t.isTable ? <Text style={s.guests}>Guests</Text> : null}
400
- <QuantitySelect
401
- value={qty[t.id] ?? 0}
402
- min={range.min}
403
- max={range.max}
404
- step={range.step}
405
- onChange={(v) => selectQty(t, v)}
406
- />
447
+ {t.isTable ? <Text style={s.guests}>Guests</Text> : null}
448
+ <QuantitySelect
449
+ value={qty[t.id] ?? 0}
450
+ min={range.min}
451
+ max={range.max}
452
+ step={range.step}
453
+ onChange={(v) => selectQty(t, v)}
454
+ />
455
+ </View>
456
+ )}
407
457
  </View>
408
- )}
409
- </View>
410
- {t.descriptionRich && visibleDescription === t.id ? (
411
- <Text style={s.description}>{stripHtml(t.descriptionRich)}</Text>
412
- ) : null}
458
+ {t.descriptionRich && visibleDescription === t.id ? (
459
+ <Text style={s.description}>{htmlToPlainText(t.descriptionRich)}</Text>
460
+ ) : null}
461
+ </>
462
+ )}
413
463
  {canWaitlist ? (
414
464
  wlOpen[t.id] ? (
415
465
  <WaitingListForm
@@ -440,123 +490,154 @@ export const Tickets: FC<TicketsProps> = ({
440
490
  else timeGroups.push({ time, items: [t] })
441
491
  }
442
492
 
493
+ const Container = unstyled ? View : Card
494
+ const continueTitle = checkout.isBusy
495
+ ? 'Reserving…'
496
+ : selectionIsTable
497
+ ? 'Reserve tables'
498
+ : buttonLabel
499
+
443
500
  return (
444
- <Card>
445
- {title ? <SectionHeader title={title} /> : null}
501
+ <FrozenFields frozen={checkout.orderFixed}>
502
+ <Container>
503
+ {title ? <SectionHeader title={title} /> : null}
446
504
 
447
- {listQuery.isLoading ? (
448
- <View style={s.loading}>
449
- <Loader />
450
- </View>
451
- ) : null}
452
-
453
- {listQuery.error ? (
454
- <ErrorText>{(listQuery.error as Error).message}</ErrorText>
455
- ) : null}
456
-
457
- {isTimeSlot && dates.length > 0 ? (
458
- <View style={s.selectBlock}>
459
- <Text style={s.selectLabel}>Date</Text>
460
- <Select
461
- value={slotDate}
462
- onChange={(d) => {
463
- setSlotDate(d)
464
- setQty({})
465
- }}
466
- options={dates.map((d) => ({ label: d, value: d }))}
467
- />
468
- </View>
469
- ) : null}
470
-
471
- {currency && currency.available.length > 1 ? (
472
- <View style={s.selectBlock}>
473
- <Text style={s.selectLabel}>Currency</Text>
474
- <Select
475
- value={currency.currency}
476
- onChange={changeCurrency}
477
- options={currency.available.map((c) => ({
478
- label: `${c.name} (${c.code})`,
479
- value: c.code,
480
- }))}
481
- />
482
- </View>
483
- ) : null}
484
-
485
- {isTimeSlot && !slotQuery.isLoading && tickets.length === 0 && slotDate ? (
486
- <MutedText>No sessions available on this date.</MutedText>
487
- ) : null}
488
-
489
- {isTimeSlot
490
- ? timeGroups.map((g) => (
491
- <Fragment key={g.time}>
492
- {g.time ? <Text style={s.slotTime}>{g.time}</Text> : null}
493
- {g.items.map(renderRow)}
494
- </Fragment>
495
- ))
496
- : tickets.map(renderRow)}
497
-
498
- {/* Promo / access code */}
499
- {showCodeInput ? (
500
- <View style={s.promo}>
501
- {!isAccess && !promoOpen ? (
502
- <Pressable accessibilityRole="button" onPress={() => setPromoOpen(true)}>
503
- <Text style={s.promoToggle}>Got a promo code? Tap here</Text>
504
- </Pressable>
505
- ) : (
506
- <>
507
- <Field>
508
- <Input
509
- placeholder={isAccess ? 'Access code' : 'Promo code'}
510
- value={code}
511
- autoCapitalize="characters"
512
- onChangeText={setCode}
513
- />
514
- </Field>
515
- <Button
516
- variant="ghost"
517
- onPress={applyCode}
518
- loading={codeState === 'busy'}
519
- disabled={codeState === 'busy' || !code.trim()}
520
- >
521
- {isAccess ? 'ENTER' : 'APPLY'}
522
- </Button>
523
- {promoMeta?.promoCodeMessage ? (
524
- promoMeta.promoCodeValid === false ? (
525
- <ErrorText>{promoMeta.promoCodeMessage}</ErrorText>
526
- ) : (
527
- <MutedText>{promoMeta.promoCodeMessage}</MutedText>
528
- )
529
- ) : null}
530
- </>
531
- )}
532
- </View>
533
- ) : null}
505
+ {listQuery.isLoading ? (
506
+ <View style={s.loading}>
507
+ <Loader />
508
+ </View>
509
+ ) : null}
510
+
511
+ {listQuery.error ? (
512
+ <ErrorText>{(listQuery.error as Error).message}</ErrorText>
513
+ ) : null}
514
+
515
+ {isTimeSlot && dates.length > 0 ? (
516
+ <View style={s.selectBlock}>
517
+ <Text style={s.selectLabel}>Date</Text>
518
+ <Select
519
+ value={slotDate}
520
+ onChange={(d) => {
521
+ setSlotDate(d)
522
+ setQty({})
523
+ }}
524
+ options={dates.map((d) => ({ label: d, value: d }))}
525
+ />
526
+ </View>
527
+ ) : null}
528
+
529
+ {currency && currency.available.length > 1 ? (
530
+ <View style={s.selectBlock}>
531
+ <Text style={s.selectLabel}>Currency</Text>
532
+ <Select
533
+ value={currency.currency}
534
+ onChange={changeCurrency}
535
+ options={currency.available.map((c) => ({
536
+ label: `${c.name} (${c.code})`,
537
+ value: c.code,
538
+ }))}
539
+ />
540
+ </View>
541
+ ) : null}
542
+
543
+ {isTimeSlot && !slotQuery.isLoading && tickets.length === 0 && slotDate ? (
544
+ <MutedText>No sessions available on this date.</MutedText>
545
+ ) : null}
546
+
547
+ {isTimeSlot
548
+ ? timeGroups.map((g) => (
549
+ <Fragment key={g.time}>
550
+ {g.time ? <Text style={s.slotTime}>{g.time}</Text> : null}
551
+ {g.items.map(renderRow)}
552
+ </Fragment>
553
+ ))
554
+ : tickets.map(renderRow)}
555
+
556
+ {/* Promo / access code */}
557
+ {showCodeInput ? (
558
+ <View style={s.promo}>
559
+ {!isAccess && !promoOpen ? (
560
+ <Pressable accessibilityRole="button" onPress={() => setPromoOpen(true)}>
561
+ <Text style={s.promoToggle}>Got a promo code? Tap here</Text>
562
+ </Pressable>
563
+ ) : (
564
+ <>
565
+ <Field>
566
+ <Input
567
+ placeholder={isAccess ? 'Access code' : 'Promo code'}
568
+ value={code}
569
+ autoCapitalize="characters"
570
+ onChangeText={setCode}
571
+ />
572
+ </Field>
573
+ <Button
574
+ variant="ghost"
575
+ onPress={applyCode}
576
+ loading={codeState === 'busy'}
577
+ disabled={codeState === 'busy' || !code.trim()}
578
+ >
579
+ {isAccess ? 'ENTER' : 'APPLY'}
580
+ </Button>
581
+ {promoMeta?.promoCodeMessage ? (
582
+ promoMeta.promoCodeValid === false ? (
583
+ <ErrorText>{promoMeta.promoCodeMessage}</ErrorText>
584
+ ) : (
585
+ <MutedText>{promoMeta.promoCodeMessage}</MutedText>
586
+ )
587
+ ) : null}
588
+ </>
589
+ )}
590
+ </View>
591
+ ) : null}
534
592
 
535
- {/* Direct-purchase CTA — hidden on a fully-seated event (nothing to buy by
593
+ {bundleStarting && (directPurchasable.length > 0 || hasSeatMapTickets) ? (
594
+ <MutedText>{createTranslator()(bundleWait ?? 'tickets.waitBundle')}</MutedText>
595
+ ) : null}
596
+ {/* Direct-purchase CTA — hidden on a fully-seated event (nothing to buy by
536
597
  quantity; the seat map is the only path). */}
537
- {directPurchasable.length > 0 ? (
538
- <Button
539
- onPress={prime}
540
- loading={checkout.isBusy}
541
- disabled={checkout.isBusy || selected.length === 0}
542
- >
543
- {checkout.isBusy
544
- ? 'Reserving…'
545
- : selectionIsTable
546
- ? 'Reserve tables'
547
- : buttonLabel}
548
- </Button>
549
- ) : null}
550
-
551
- {/* Seated events: hand off to the seat map instead of quantity selection. */}
552
- {hasSeatMapTickets ? (
553
- <Button variant="ghost" onPress={() => onSelectSeats?.()}>
554
- {seatCtaLabel}
555
- </Button>
556
- ) : null}
557
-
558
- {error || checkout.error ? <ErrorText>{error || checkout.error}</ErrorText> : null}
559
- </Card>
598
+ {directPurchasable.length > 0 ? (
599
+ renderContinue ? (
600
+ renderContinue({
601
+ onPress: prime,
602
+ loading: checkout.isBusy,
603
+ disabled:
604
+ checkout.orderFixed ||
605
+ checkout.isBusy ||
606
+ bundleStarting ||
607
+ selected.length === 0,
608
+ title: continueTitle,
609
+ })
610
+ ) : (
611
+ <Button
612
+ onPress={prime}
613
+ loading={checkout.isBusy}
614
+ disabled={checkout.isBusy || bundleStarting || selected.length === 0}
615
+ >
616
+ {checkout.isBusy
617
+ ? 'Reserving…'
618
+ : selectionIsTable
619
+ ? 'Reserve tables'
620
+ : buttonLabel}
621
+ </Button>
622
+ )
623
+ ) : null}
624
+
625
+ {/* Seated events: hand off to the seat map instead of quantity selection. */}
626
+ {hasSeatMapTickets ? (
627
+ <Button
628
+ variant="ghost"
629
+ disabled={bundleStarting}
630
+ onPress={() => onSelectSeats?.()}
631
+ >
632
+ {seatCtaLabel}
633
+ </Button>
634
+ ) : null}
635
+
636
+ {error || checkout.error ? (
637
+ <ErrorText>{error || checkout.error}</ErrorText>
638
+ ) : null}
639
+ </Container>
640
+ </FrozenFields>
560
641
  )
561
642
  }
562
643