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

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 (59) hide show
  1. package/README.md +92 -8
  2. package/dist/index.d.mts +7374 -288
  3. package/dist/index.d.ts +7374 -288
  4. package/dist/index.js +23316 -5980
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23461 -6058
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +22 -10
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/src/CardInput.tsx +64 -3
  12. package/src/EmbeddedCheckout.tsx +129 -36
  13. package/src/auth/AuthModal.tsx +90 -6
  14. package/src/auth/EmailCodeForm.tsx +91 -0
  15. package/src/auth/RegisterForm.tsx +91 -5
  16. package/src/auth/TwoFactorForm.tsx +74 -0
  17. package/src/auth/index.ts +4 -0
  18. package/src/configure.ts +8 -1
  19. package/src/confirmer.ts +112 -20
  20. package/src/generated/seatMapWidget.ts +3 -0
  21. package/src/index.tsx +1 -1
  22. package/src/sections/Accommodation.tsx +57 -0
  23. package/src/sections/AccountRewards.tsx +108 -0
  24. package/src/sections/AddOns.tsx +99 -148
  25. package/src/sections/BillingInfo.tsx +63 -64
  26. package/src/sections/Bundles.tsx +467 -0
  27. package/src/sections/EventDiscussionView.tsx +633 -0
  28. package/src/sections/Passes.tsx +177 -0
  29. package/src/sections/Payment.tsx +169 -52
  30. package/src/sections/PostReaction.tsx +122 -0
  31. package/src/sections/Rewards.tsx +320 -0
  32. package/src/sections/SeatMap.tsx +225 -0
  33. package/src/sections/SeatsIoMap.tsx +110 -0
  34. package/src/sections/TicketChange.tsx +379 -0
  35. package/src/sections/TicketGift.tsx +206 -0
  36. package/src/sections/TicketRefund.tsx +104 -0
  37. package/src/sections/Tickets.tsx +289 -208
  38. package/src/sections/Trips.tsx +87 -0
  39. package/src/sections/WaiverSigning.tsx +429 -0
  40. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  41. package/src/sections/discussion/CommentImages.tsx +342 -0
  42. package/src/sections/discussion/SwipeReply.tsx +88 -0
  43. package/src/sections/index.ts +20 -0
  44. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  45. package/src/sections/rewards/RewardsTree.tsx +117 -0
  46. package/src/storage.ts +8 -9
  47. package/src/ui/CheckoutProvider.tsx +25 -13
  48. package/src/ui/Field.tsx +21 -3
  49. package/src/ui/Frozen.tsx +22 -0
  50. package/src/ui/InstallmentPicker.tsx +15 -18
  51. package/src/ui/PaymentMethods.tsx +6 -3
  52. package/src/ui/ProviderPages.tsx +134 -0
  53. package/src/ui/QuantitySelect.tsx +26 -2
  54. package/src/ui/Select.tsx +4 -1
  55. package/src/ui/SignaturePad.tsx +218 -0
  56. package/src/ui/index.ts +1 -0
  57. package/src/ui/primitives.tsx +13 -2
  58. package/src/ui/useAccountT.ts +16 -0
  59. package/src/useCheckout.ts +3 -1
@@ -25,6 +25,16 @@ import React, {
25
25
  import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
26
26
 
27
27
  import {
28
+ billingDobIso,
29
+ changeBillingPhone,
30
+ changeBillingField,
31
+ billingDraftFrom,
32
+ billingFilledHolderIndexes,
33
+ holderDraftsForSlots,
34
+ holderDraftsFrom,
35
+ holderDraftsReloaded,
36
+ holderDraftsMirroringBuyer,
37
+ patchedHolderDraft,
28
38
  hooks,
29
39
  mapProfileToBilling,
30
40
  missingCheckoutDetails,
@@ -43,9 +53,11 @@ import {
43
53
  } from '../ui/CheckoutProvider'
44
54
  import { CustomFieldInput } from '../ui/CustomFields'
45
55
  import { Checkbox, Field, Input } from '../ui/Field'
56
+ import { FrozenFields } from '../ui/Frozen'
46
57
  import { Button, Card, ErrorText, SectionHeader } from '../ui/primitives'
47
58
  import type { ResolvedTheme } from '../ui/theme'
48
59
  import { BillingAddressFields } from './billing/BillingAddressFields'
60
+ import { BillingPhoneIdentity } from './billing/PhoneIdentity'
49
61
  import { BuyerField } from './billing/BuyerField'
50
62
  import { TicketHoldersCard } from './billing/TicketHoldersCard'
51
63
  import type { BillingDraft, HolderDraft } from './billing/types'
@@ -110,52 +122,42 @@ export const BillingInfo: FC<BillingInfoProps> = ({
110
122
  const validation = useCheckoutValidation()
111
123
  const theme = useTheme()
112
124
  const s = useStyles(theme)
113
- const profile = hooks.useProfile({ retry: false })
125
+ const profile = hooks.useCheckoutBuyerProfile(eventId)
114
126
  const event = hooks.useEvent(eventId)
115
127
  // No onContinue ⇒ this is one combined form (inside <EmbeddedCheckout/>): mirror the
116
128
  // fields into the engine live and drop the "continue" button, so the buyer
117
129
  // fills billing + pays with ONE button — the way the old checkout flowed.
118
130
  const combined = !onContinue
119
131
 
120
- const [billing, setBilling] = useState<BillingDraft>({
121
- firstName: checkout.billing?.firstName ?? '',
122
- lastName: checkout.billing?.lastName ?? '',
123
- email: checkout.billing?.email ?? '',
124
- confirmEmail: checkout.billing?.confirmEmail ?? '',
125
- phone: checkout.billing?.phone,
126
- streetAddress: checkout.billing?.streetAddress,
127
- city: checkout.billing?.city,
128
- zip: checkout.billing?.zip,
129
- countryId: checkout.billing?.countryId,
130
- stateId: checkout.billing?.stateId,
131
- countryCode: checkout.billing?.countryCode,
132
- stateCode: checkout.billing?.stateCode,
133
- nationality: checkout.billing?.nationality,
134
- gender: checkout.billing?.gender,
135
- instagramHandle: checkout.billing?.instagramHandle,
136
- })
132
+ const [billing, setBilling] = useState<BillingDraft>(() =>
133
+ billingDraftFrom(checkout.billing)
134
+ )
137
135
  const [dataCapture, setDataCapture] = useState<CustomFieldValues>(
138
136
  checkout.billing?.dataCapture ?? {}
139
137
  )
140
138
  const [holders, setHolders] = useState<HolderDraft[]>(() =>
141
- checkout.holders.map((holder) => ({
142
- firstName: holder.firstName,
143
- lastName: holder.lastName,
144
- email: holder.email,
145
- phone: holder.phone ?? '',
146
- ticketTypeId: holder.ticketTypeId,
147
- ticketDataCapture: holder.ticketDataCapture ?? {},
148
- }))
139
+ holderDraftsFrom(checkout.holders, checkout.backfilledHolderIndexes)
149
140
  )
150
141
  const [consent, setConsent] = useState({ ttfOptIn: false, brandOptIn: false })
151
- const [dob, setDob] = useState(() => {
152
- const b = checkout.billing
153
- if (!b?.dobDay || !b.dobMonth || !b.dobYear) return ''
154
- return `${b.dobYear}-${String(b.dobMonth).padStart(2, '0')}-${String(
155
- b.dobDay
156
- ).padStart(2, '0')}`
157
- }) // ISO yyyy-mm-dd
142
+ const [dob, setDob] = useState(() => billingDobIso(checkout.billing)) // ISO yyyy-mm-dd
143
+ // A placement that ended without a charge: show what a retry submits.
144
+ hooks.useOnOrderReleased(checkout.orderFixed, () => {
145
+ setBilling(billingDraftFrom(checkout.billing))
146
+ setDataCapture(checkout.billing?.dataCapture ?? {})
147
+ setDob(billingDobIso(checkout.billing))
148
+ setConsent(checkout.consent)
149
+ setHolders((prev) =>
150
+ holderDraftsReloaded(prev, checkout.holders, checkout.backfilledHolderIndexes)
151
+ )
152
+ })
158
153
  const restoredHolders = useRef(checkout.holders)
154
+ // Another event: its holders and custom answers start empty; the buyer's
155
+ // own details stay.
156
+ hooks.useOnEventChange(checkout.eventId, () => {
157
+ restoredHolders.current = []
158
+ setHolders([])
159
+ setDataCapture({})
160
+ })
159
161
  const profileSeeded = useRef(false)
160
162
  const countryDefaultApplied = useRef(Boolean(checkout.billing?.countryId))
161
163
 
@@ -233,38 +235,21 @@ export const BillingInfo: FC<BillingInfoProps> = ({
233
235
 
234
236
  // Resize holders to the slot count, preserving typed values; tag ticket type.
235
237
  useEffect(() => {
236
- setHolders((prev) =>
237
- slots.map((slot, i) => ({
238
- firstName: prev[i]?.firstName ?? restoredHolders.current[i]?.firstName ?? '',
239
- lastName: prev[i]?.lastName ?? restoredHolders.current[i]?.lastName ?? '',
240
- email: prev[i]?.email ?? restoredHolders.current[i]?.email ?? '',
241
- phone: prev[i]?.phone ?? restoredHolders.current[i]?.phone ?? '',
242
- ticketDataCapture:
243
- prev[i]?.ticketDataCapture ??
244
- restoredHolders.current[i]?.ticketDataCapture ??
245
- {},
246
- ticketTypeId: slot.ticketTypeId,
247
- ticketName: slot.ticketName,
248
- }))
249
- )
238
+ setHolders((prev) => holderDraftsForSlots(prev, slots, restoredHolders.current))
250
239
  }, [slots])
251
240
 
252
241
  // Match the storefront: ticket 1 silently starts as the buyer. The legacy
253
242
  // checkout has no extra "use buyer details" control.
254
243
  useEffect(() => {
255
244
  if (!showHolders) return
245
+ // Ticket 1 stops copying the buyer once the buyer edits it.
256
246
  setHolders((prev) =>
257
- prev.map((holder, index) =>
258
- index === 0
259
- ? {
260
- ...holder,
261
- firstName: billing.firstName,
262
- lastName: billing.lastName,
263
- email: billing.email,
264
- phone: billing.phone ?? '',
265
- }
266
- : holder
267
- )
247
+ holderDraftsMirroringBuyer(prev, {
248
+ firstName: billing.firstName,
249
+ lastName: billing.lastName,
250
+ email: billing.email,
251
+ phone: billing.phone,
252
+ })
268
253
  )
269
254
  }, [showHolders, billing.firstName, billing.lastName, billing.email, billing.phone])
270
255
 
@@ -357,10 +342,10 @@ export const BillingInfo: FC<BillingInfoProps> = ({
357
342
  }, [billing.stateId, billing.stateCode, states])
358
343
 
359
344
  const setB = (k: keyof BillingDraft) => (value: string) =>
360
- setBilling((b) => ({ ...b, [k]: value }))
345
+ setBilling((b) => changeBillingField(b, k, value))
361
346
 
362
347
  const patchHolder = (i: number, patch: Partial<HolderDraft>) =>
363
- setHolders((hs) => hs.map((h, j) => (j === i ? { ...h, ...patch } : h)))
348
+ setHolders((hs) => hs.map((h, j) => (j === i ? patchedHolderDraft(h, patch) : h)))
364
349
 
365
350
  const [dobYear, dobMonth, dobDay] = dob ? dob.split('-').map(Number) : []
366
351
  const committedBilling: CheckoutBilling = {
@@ -413,7 +398,11 @@ export const BillingInfo: FC<BillingInfoProps> = ({
413
398
 
414
399
  const commit = () => {
415
400
  commitBilling(committedBilling)
416
- commitHolders(committedHolders)
401
+ // Holders that take the buyer's details are marked, so a guardian's
402
+ // signature for a child counts on them (billingFilledHolderIndexes).
403
+ commitHolders(committedHolders, {
404
+ backfilled: billingFilledHolderIndexes(holders, { holderNamesRequired }),
405
+ })
417
406
  if (ttfOptInLabel || brandOptInLabel) commitConsent(consent)
418
407
  }
419
408
 
@@ -478,7 +467,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
478
467
  const privacyInvalid = (showPrivacyError || showValidation) && !consent.ttfOptIn
479
468
 
480
469
  return (
481
- <>
470
+ <FrozenFields frozen={checkout.orderFixed}>
482
471
  <Card>
483
472
  <SectionHeader title={heading} />
484
473
 
@@ -491,6 +480,14 @@ export const BillingInfo: FC<BillingInfoProps> = ({
491
480
  </View>
492
481
  ) : null}
493
482
 
483
+ {gates.phoneFirstCheckout && !checkout.isLoggedIn ? (
484
+ <BillingPhoneIdentity
485
+ billing={billing}
486
+ setBilling={setBilling}
487
+ eventId={eventId ?? checkout.eventId ?? undefined}
488
+ whatsappAvailable={Boolean(gates.phoneFirstWhatsapp)}
489
+ />
490
+ ) : null}
494
491
  <View style={s.row}>
495
492
  <View style={s.col}>
496
493
  <Field error={fieldError('First name', 'First name is required.')}>
@@ -598,7 +595,9 @@ export const BillingInfo: FC<BillingInfoProps> = ({
598
595
  <Input
599
596
  placeholder="Phone number"
600
597
  value={billing.phone ?? ''}
601
- onChangeText={(v) => setBilling((current) => ({ ...current, phone: v }))}
598
+ onChangeText={(v) =>
599
+ setBilling((current) => changeBillingPhone(current, v))
600
+ }
602
601
  keyboardType="phone-pad"
603
602
  invalid={Boolean(fieldError('Valid phone number'))}
604
603
  />
@@ -730,7 +729,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
730
729
  }
731
730
  />
732
731
  ) : null}
733
- </>
732
+ </FrozenFields>
734
733
  )
735
734
  }
736
735
 
@@ -0,0 +1,467 @@
1
+ /**
2
+ * `<Bundles/>` — the RN analogue of the web section: the event's bundles,
3
+ * bought on their own order. Choose bundles, enter your details (and, when
4
+ * offered, a name on each ticket), pay by card, see the confirmation.
5
+ * Everything the flow decides is in the shared core (`hooks.useBundleFlow`);
6
+ * this file only renders it.
7
+ *
8
+ * At most one native card field exists at a time: the bundle card field is
9
+ * mounted only while this section is visible.
10
+ *
11
+ * Renders nothing when the event sells no bundles. Translated through the
12
+ * shared translator; `locale`, `messages` or `translate` override it.
13
+ */
14
+ import React, { useMemo, type FC } from 'react'
15
+ import { StyleSheet, Text, View } from 'react-native'
16
+
17
+ import {
18
+ bundleAvailabilityCopy,
19
+ bundleContentsLine,
20
+ htmlToPlainText,
21
+ bundleQuantityRange,
22
+ createTranslator,
23
+ hooks,
24
+ type LocaleCode,
25
+ type Messages,
26
+ type PlaceOrderResult,
27
+ type Translate,
28
+ } from 'tf-checkout-shared'
29
+
30
+ import { CardInput, claimCardField } from '../CardInput'
31
+ import { rnConfirmer } from '../instance'
32
+ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
33
+ import { Field, Input } from '../ui/Field'
34
+ import { Button, Card, ErrorText, Loader } from '../ui/primitives'
35
+ import { QuantitySelect } from '../ui/QuantitySelect'
36
+ import type { ResolvedTheme } from '../ui/theme'
37
+
38
+ const money = (currency: string, amount: number) => `${currency} ${amount.toFixed(2)}`
39
+
40
+ export interface BundlesProps {
41
+ eventId: string
42
+ /** Fired once when a bundle purchase is complete, with its order hash. */
43
+ onPurchased?: (orderHash: string) => void
44
+ /**
45
+ * Fired once when a bundle purchase is complete, in the same shape as a
46
+ * ticket order, so the host routes both the same way.
47
+ */
48
+ onComplete?: (order: PlaceOrderResult) => void
49
+ /** `redirect`: skip the inline receipt while the host navigates. */
50
+ confirmationMode?: 'inline' | 'redirect'
51
+ /** False while the section is hidden: no card field is mounted then. */
52
+ visible?: boolean
53
+ /** Section heading. Defaults to the translated "Bundles". */
54
+ heading?: string
55
+ locale?: LocaleCode
56
+ messages?: Partial<Messages>
57
+ translate?: Translate
58
+ }
59
+
60
+ export const Bundles: FC<BundlesProps> = ({
61
+ eventId,
62
+ onPurchased,
63
+ onComplete,
64
+ confirmationMode = 'inline',
65
+ visible = true,
66
+ heading,
67
+ locale,
68
+ messages,
69
+ translate,
70
+ }) => {
71
+ const checkout = useCheckoutContext()
72
+ const theme = useTheme()
73
+ const s = useBundleStyles(theme)
74
+ const t = translate ?? createTranslator({ locale, messages })
75
+ const flow = hooks.useBundleFlow(rnConfirmer, eventId, {
76
+ claimCard: claimCardField,
77
+ defaults: {
78
+ firstName: checkout.billing?.firstName ?? '',
79
+ lastName: checkout.billing?.lastName ?? '',
80
+ email: checkout.billing?.email ?? '',
81
+ },
82
+ store: checkout.store,
83
+ visible,
84
+ confirmationMode,
85
+ onComplete,
86
+ onPurchased,
87
+ })
88
+
89
+ if (flow.view === 'loading') {
90
+ return (
91
+ <Card>
92
+ <View style={s.loader}>
93
+ <Loader />
94
+ </View>
95
+ </Card>
96
+ )
97
+ }
98
+ if (flow.view === 'catalogError') {
99
+ return (
100
+ <Card>
101
+ <ErrorText>{t('bundles.error.catalog')}</ErrorText>
102
+ <Button
103
+ variant="ghost"
104
+ loading={flow.catalogRetrying}
105
+ disabled={flow.catalogRetrying}
106
+ onPress={flow.retryCatalog}
107
+ >
108
+ {t('bundles.retryCard')}
109
+ </Button>
110
+ </Card>
111
+ )
112
+ }
113
+ // No bundles to sell and no purchase to show.
114
+ if (flow.view === 'hidden') return null
115
+
116
+ const title = <Text style={s.heading}>{heading ?? t('bundles.heading')}</Text>
117
+
118
+ if (flow.view === 'redirecting') {
119
+ return (
120
+ <Card>
121
+ {title}
122
+ <Text style={s.muted} accessibilityLiveRegion="polite">
123
+ {t('bundles.redirecting')}
124
+ </Text>
125
+ <Loader />
126
+ </Card>
127
+ )
128
+ }
129
+ if (flow.view === 'receipt' && flow.result) {
130
+ const { result } = flow
131
+ return (
132
+ <Card>
133
+ {title}
134
+ <Text style={s.strong}>{t('bundles.done.title')}</Text>
135
+ <Text style={s.muted}>
136
+ {result.pending
137
+ ? t('bundles.done.pending')
138
+ : result.email
139
+ ? t('bundles.done.email', { email: result.email })
140
+ : t('bundles.done.emailUnknown')}
141
+ </Text>
142
+ <Text style={s.muted}>
143
+ {t('bundles.done.order', { orderHash: result.orderHash })}
144
+ </Text>
145
+ {flow.gatewayWait ? (
146
+ <Text style={s.muted} accessibilityLiveRegion="polite">
147
+ {t(flow.gatewayWait)}
148
+ </Text>
149
+ ) : null}
150
+ <Button
151
+ variant="ghost"
152
+ disabled={flow.gatewayBlocked}
153
+ onPress={() => void flow.again()}
154
+ >
155
+ {t('bundles.done.again')}
156
+ </Button>
157
+ </Card>
158
+ )
159
+ }
160
+
161
+ const anySelected = Object.values(flow.qty).some((n) => n > 0)
162
+
163
+ if (flow.view === 'details') {
164
+ const amount = money(flow.payAmount.currency || flow.currency, flow.payAmount.amount)
165
+ return (
166
+ <Card>
167
+ {title}
168
+ {flow.notice ? (
169
+ <Text style={s.muted} accessibilityLiveRegion="polite">
170
+ {t(flow.notice.key, flow.notice.values)}
171
+ </Text>
172
+ ) : null}
173
+ <Text style={s.strong}>{t('bundles.buyerHeading')}</Text>
174
+ <Field label={t('bundles.firstName')}>
175
+ <Input
176
+ value={flow.buyer.firstName}
177
+ autoCapitalize="words"
178
+ editable={!flow.busy}
179
+ onChangeText={(firstName) => flow.patchBuyer({ firstName })}
180
+ />
181
+ </Field>
182
+ <Field label={t('bundles.lastName')}>
183
+ <Input
184
+ value={flow.buyer.lastName}
185
+ autoCapitalize="words"
186
+ editable={!flow.busy}
187
+ onChangeText={(lastName) => flow.patchBuyer({ lastName })}
188
+ />
189
+ </Field>
190
+ <Field label={t('bundles.email')}>
191
+ <Input
192
+ value={flow.buyer.email}
193
+ keyboardType="email-address"
194
+ autoCapitalize="none"
195
+ editable={!flow.busy}
196
+ onChangeText={(email) => flow.patchBuyer({ email })}
197
+ />
198
+ </Field>
199
+
200
+ {flow.slots.length > 0 ? (
201
+ <>
202
+ <Text style={s.strong}>{t('bundles.holdersHeading')}</Text>
203
+ {!flow.namesRequired ? (
204
+ <Text style={s.muted}>{t('bundles.holdersOptional')}</Text>
205
+ ) : null}
206
+ {flow.slots.map((slot) => {
207
+ const value = flow.holderValue(slot)
208
+ const label = t('bundles.holderLabel', {
209
+ ticket: slot.ticketName,
210
+ number: String(slot.ticketIndex + 1),
211
+ })
212
+ return (
213
+ <View key={`${slot.bundleId}-${slot.ticketIndex}`}>
214
+ <Field label={`${label} · ${t('bundles.holderFirstName')}`}>
215
+ <Input
216
+ value={value?.firstName ?? ''}
217
+ autoCapitalize="words"
218
+ editable={!flow.busy}
219
+ onChangeText={(firstName) => flow.setHolder(slot, { firstName })}
220
+ />
221
+ </Field>
222
+ <Field label={`${label} · ${t('bundles.holderLastName')}`}>
223
+ <Input
224
+ value={value?.lastName ?? ''}
225
+ autoCapitalize="words"
226
+ editable={!flow.busy}
227
+ onChangeText={(lastName) => flow.setHolder(slot, { lastName })}
228
+ />
229
+ </Field>
230
+ </View>
231
+ )
232
+ })}
233
+ </>
234
+ ) : null}
235
+ {flow.problems.map((key) => (
236
+ <ErrorText key={key}>{t(key)}</ErrorText>
237
+ ))}
238
+
239
+ {flow.awaitingCard ? (
240
+ <View style={s.card}>
241
+ <Text style={s.strong}>{t('bundles.cardHeading')}</Text>
242
+ {flow.cardElsewhere ? (
243
+ <>
244
+ <Text style={s.muted}>{t('bundles.payingElsewhere')}</Text>
245
+ <Button variant="ghost" onPress={flow.payHere}>
246
+ {t('bundles.payHere')}
247
+ </Button>
248
+ </>
249
+ ) : flow.cardFailed ? (
250
+ <Button variant="ghost" onPress={flow.retryCard}>
251
+ {t('bundles.retryCard')}
252
+ </Button>
253
+ ) : flow.cardReady && visible ? (
254
+ <CardInput owner={flow.cardOwner} visible={visible} />
255
+ ) : (
256
+ <Loader />
257
+ )}
258
+ </View>
259
+ ) : null}
260
+
261
+ {flow.stillSaving ? (
262
+ <Text style={s.muted} accessibilityLiveRegion="polite">
263
+ {t('common.stillSaving')}
264
+ </Text>
265
+ ) : null}
266
+ {flow.checkingAttempt ? (
267
+ <Text style={s.muted} accessibilityLiveRegion="polite">
268
+ {t('bundles.checkingAttempt')}
269
+ </Text>
270
+ ) : null}
271
+ {flow.waitingForTickets ? (
272
+ <Text style={s.muted} accessibilityLiveRegion="polite">
273
+ {t('bundles.waitTickets')}
274
+ </Text>
275
+ ) : null}
276
+ {flow.waitingForBundle ? (
277
+ <Text style={s.muted} accessibilityLiveRegion="polite">
278
+ {t(flow.gatewayWait ?? 'bundles.waitOtherBundle')}
279
+ </Text>
280
+ ) : null}
281
+ <Button
282
+ onPress={() => void flow.submit()}
283
+ loading={flow.busy}
284
+ disabled={flow.submitDisabled}
285
+ >
286
+ {flow.paying
287
+ ? t('bundles.paying')
288
+ : flow.awaitingCard
289
+ ? t('bundles.pay', { amount })
290
+ : t('bundles.continue')}
291
+ </Button>
292
+ <Button
293
+ variant="ghost"
294
+ disabled={flow.busy || flow.gatewayBlocked}
295
+ onPress={() => void flow.back()}
296
+ >
297
+ {t('bundles.back')}
298
+ </Button>
299
+ {flow.error ? <ErrorText>{t(flow.error)}</ErrorText> : null}
300
+ {flow.orderInProgress ? (
301
+ <Button variant="ghost" onPress={flow.continueOrder}>
302
+ {t('bundles.continueOrder')}
303
+ </Button>
304
+ ) : null}
305
+ {flow.canStartOver ? (
306
+ <Button
307
+ variant="ghost"
308
+ disabled={flow.gatewayBlocked}
309
+ onPress={() => void flow.startOver()}
310
+ >
311
+ {t('bundles.startOver')}
312
+ </Button>
313
+ ) : null}
314
+ </Card>
315
+ )
316
+ }
317
+
318
+ const ticketsInCart = checkout.selections.length > 0
319
+ return (
320
+ <Card>
321
+ {title}
322
+ <Text style={s.muted}>{t('bundles.subtitle')}</Text>
323
+ {flow.notice ? (
324
+ <Text style={s.muted} accessibilityLiveRegion="polite">
325
+ {t(flow.notice.key, flow.notice.values)}
326
+ </Text>
327
+ ) : null}
328
+ {flow.view === 'heldOrder' ? (
329
+ <>
330
+ <Text style={s.muted} accessibilityLiveRegion="polite">
331
+ {t('bundles.heldOrder')}
332
+ </Text>
333
+ {flow.error ? <ErrorText>{t(flow.error)}</ErrorText> : null}
334
+ {flow.checkingAttempt ? (
335
+ <Text style={s.muted} accessibilityLiveRegion="polite">
336
+ {t('bundles.checkingAttempt')}
337
+ </Text>
338
+ ) : null}
339
+ {flow.gatewayWait ? (
340
+ <Text style={s.muted} accessibilityLiveRegion="polite">
341
+ {t(flow.gatewayWait)}
342
+ </Text>
343
+ ) : null}
344
+ <Button
345
+ disabled={flow.busy || flow.checkingAttempt}
346
+ onPress={flow.continueOrder}
347
+ >
348
+ {t('bundles.continueOrder')}
349
+ </Button>
350
+ <Button
351
+ variant="ghost"
352
+ disabled={flow.busy || flow.checkingAttempt || flow.gatewayBlocked}
353
+ onPress={() => void flow.startOver()}
354
+ >
355
+ {t('bundles.startOver')}
356
+ </Button>
357
+ </>
358
+ ) : null}
359
+
360
+ {flow.bundles.map((bundle) => {
361
+ const range = bundleQuantityRange(bundle)
362
+ const stock = bundleAvailabilityCopy(bundle)
363
+ const contents = bundleContentsLine(bundle)
364
+ const desc = htmlToPlainText(bundle.description ?? '')
365
+ return (
366
+ <View key={bundle.id} style={[s.item, { borderTopColor: theme.border }]}>
367
+ <View style={s.info}>
368
+ <Text style={s.name}>{bundle.name}</Text>
369
+ <Text style={s.price}>{money(bundle.currency, bundle.price)}</Text>
370
+ {contents ? (
371
+ <Text style={s.muted}>{t('bundles.includes', { contents })}</Text>
372
+ ) : null}
373
+ {bundle.bookingFee > 0 && (
374
+ <Text style={s.muted}>
375
+ {t('bundles.bookingFee', {
376
+ amount: money(bundle.currency, bundle.bookingFee),
377
+ })}
378
+ </Text>
379
+ )}
380
+ {desc ? <Text style={s.muted}>{desc}</Text> : null}
381
+ {stock && stock.key !== 'bundles.soldOut' ? (
382
+ <Text style={[s.muted, s.stock]}>{t(stock.key, stock.values)}</Text>
383
+ ) : null}
384
+ </View>
385
+ {range.max === 0 ? (
386
+ <Text style={s.soldOut}>{t('bundles.soldOut')}</Text>
387
+ ) : (
388
+ <QuantitySelect
389
+ label={t('bundles.quantity', { name: bundle.name })}
390
+ value={flow.qty[bundle.id] ?? 0}
391
+ min={range.min}
392
+ max={range.max}
393
+ onChange={(v) => flow.setQuantity(bundle.id, v)}
394
+ />
395
+ )}
396
+ </View>
397
+ )
398
+ })}
399
+
400
+ {anySelected ? (
401
+ <Text style={s.strong}>
402
+ {t('bundles.total', { amount: money(flow.currency, flow.total) })}
403
+ </Text>
404
+ ) : null}
405
+ {anySelected && ticketsInCart ? (
406
+ <Text style={s.muted}>{t('bundles.boughtSeparately')}</Text>
407
+ ) : null}
408
+ <Button onPress={flow.continueToDetails} disabled={!anySelected}>
409
+ {t('bundles.continue')}
410
+ </Button>
411
+ </Card>
412
+ )
413
+ }
414
+
415
+ const useBundleStyles = (theme: ResolvedTheme) =>
416
+ useMemo(
417
+ () =>
418
+ StyleSheet.create({
419
+ loader: { paddingVertical: 20, alignItems: 'center' },
420
+ heading: {
421
+ color: theme.text,
422
+ fontSize: theme.fontSize + 2,
423
+ fontWeight: '700',
424
+ fontFamily: theme.fontFamily,
425
+ },
426
+ strong: {
427
+ color: theme.text,
428
+ fontSize: theme.fontSize,
429
+ fontWeight: '700',
430
+ fontFamily: theme.fontFamily,
431
+ },
432
+ muted: {
433
+ color: theme.muted,
434
+ fontSize: theme.fontSize - 1,
435
+ fontFamily: theme.fontFamily,
436
+ },
437
+ stock: { fontWeight: '600' },
438
+ card: { gap: 8 },
439
+ item: {
440
+ flexDirection: 'row',
441
+ alignItems: 'flex-start',
442
+ gap: 12,
443
+ paddingTop: 12,
444
+ borderTopWidth: StyleSheet.hairlineWidth,
445
+ },
446
+ info: { flex: 1, gap: 2 },
447
+ name: {
448
+ color: theme.text,
449
+ fontSize: theme.fontSize,
450
+ fontWeight: '600',
451
+ fontFamily: theme.fontFamily,
452
+ },
453
+ price: {
454
+ color: theme.text,
455
+ fontSize: theme.fontSize - 1,
456
+ fontVariant: ['tabular-nums'],
457
+ fontFamily: theme.fontFamily,
458
+ },
459
+ soldOut: {
460
+ color: theme.muted,
461
+ fontSize: theme.fontSize - 1,
462
+ fontWeight: '600',
463
+ fontFamily: theme.fontFamily,
464
+ },
465
+ }),
466
+ [theme]
467
+ )