tf-checkout-react-native 2.0.0-beta.17 → 2.0.0-beta.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tf-checkout-react-native",
3
- "version": "2.0.0-beta.17",
3
+ "version": "2.0.0-beta.19",
4
4
  "description": "TheTicketFairy ReactNative Checkout library",
5
5
  "homepage": "https://github.com/theticketfairy/tf-shared-libraries#readme",
6
6
  "author": "The Ticket Fairy",
@@ -103,7 +103,9 @@
103
103
  "react-dom": "19.1.0",
104
104
  "react-native": "0.81.4",
105
105
  "react-native-mmkv": "^3.3.1",
106
+ "react-native-render-html": "6.3.4",
106
107
  "react-native-webview": "13.15.0",
108
+ "react-test-renderer": "19.1.0",
107
109
  "release-it": "^19.0.4",
108
110
  "tf-checkout-shared": "workspace:*",
109
111
  "tf-seat-map-view": "9.4.7-beta.33",
@@ -113,6 +115,21 @@
113
115
  },
114
116
  "jest": {
115
117
  "preset": "react-native",
118
+ "transform": {
119
+ "^.+\\.(js|ts|tsx)$": [
120
+ "babel-jest",
121
+ {
122
+ "babelrc": false,
123
+ "configFile": false,
124
+ "presets": [
125
+ "module:@react-native/babel-preset"
126
+ ]
127
+ }
128
+ ]
129
+ },
130
+ "transformIgnorePatterns": [
131
+ "node_modules/(?!((jest-)?react-native|@react-native(-community)?|\\.pnpm)/)"
132
+ ],
116
133
  "modulePathIgnorePatterns": [
117
134
  "<rootDir>/example/node_modules",
118
135
  "<rootDir>/dist/"
@@ -160,6 +177,7 @@
160
177
  "react": ">=19.1",
161
178
  "react-native": ">=0.81",
162
179
  "react-native-mmkv": "^3.0.0",
180
+ "react-native-render-html": ">=6.3.4 <7",
163
181
  "react-native-webview": ">=13.15.0"
164
182
  },
165
183
  "//dependencies": "tf-checkout-shared is INLINED into this bundle (tsup noExternal), so its own runtime deps are ours too \u2014 axios, entities and htmlparser2 must stay declared here or tsup bundles them (axios drags Node's stream into a browser build) / the consumer never installs them.",
@@ -1,9 +1,10 @@
1
1
  import React from 'react'
2
2
  import { StyleSheet, Text, View } from 'react-native'
3
- import { hooks, htmlToPlainText, accommodationBookingDeadline } from 'tf-checkout-shared'
3
+ import { hooks, accommodationBookingDeadline } from 'tf-checkout-shared'
4
4
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
5
5
  import { Field, Input } from '../ui/Field'
6
6
  import { QuantitySelect } from '../ui/QuantitySelect'
7
+ import { RichText } from '../ui/RichText'
7
8
  import { Button, Card, ErrorText, Loader, MutedText, SectionHeader } from '../ui/primitives'
8
9
 
9
10
  /** Native controls over the shared stay draft, quote and reservation lifecycle. */
@@ -26,7 +27,7 @@ export const Accommodation = ({ eventId }: { eventId: string }) => {
26
27
  return (
27
28
  <View key={id} style={styles.room}>
28
29
  <Text style={[text, styles.title]}>{room.property_name} · {room.name}</Text>
29
- {!!room.description && <MutedText>{htmlToPlainText(room.description)}</MutedText>}
30
+ {!!room.description && <RichText html={room.description} />}
30
31
  {accommodationBookingDeadline(room) && <MutedText>{accommodationBookingDeadline(room)}</MutedText>}
31
32
  <MutedText>{room.sold_out ? 'Sold out' : `${room.currency} ${room.min_price.toFixed(2)} per night${room.max_price > room.min_price ? ' and up' : ''}`}</MutedText>
32
33
  <Field label="Rooms">
@@ -15,7 +15,6 @@ import {
15
15
  addOnMaxQuantity,
16
16
  errorMessage,
17
17
  hooks,
18
- htmlToPlainText,
19
18
  type EventAddon,
20
19
  } from 'tf-checkout-shared'
21
20
 
@@ -26,6 +25,7 @@ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
26
25
  // `CustomFieldGroupFields` API: { group, values, onChange(name, value) }.
27
26
  import { CustomFieldGroupFields } from '../ui/CustomFields'
28
27
  import { QuantitySelect } from '../ui/QuantitySelect'
28
+ import { RichText } from '../ui/RichText'
29
29
  import type { ResolvedTheme } from '../ui/theme'
30
30
 
31
31
  const money = (currency: string, n: number): string =>
@@ -135,7 +135,6 @@ export const AddOns: FC<AddOnsProps> = ({
135
135
  {g.items.map((a) => {
136
136
  const n = qty[a.id] ?? 0
137
137
  const group = fieldGroups[a.id]
138
- const desc = a.description ? htmlToPlainText(a.description) : ''
139
138
  return (
140
139
  <View key={a.id} style={s.item}>
141
140
  <View style={s.row}>
@@ -150,7 +149,9 @@ export const AddOns: FC<AddOnsProps> = ({
150
149
  <Text style={s.priceNote}> incl. fees</Text>
151
150
  ) : null}
152
151
  </Text>
153
- {desc ? <Text style={s.desc}>{desc}</Text> : null}
152
+ {a.description ? (
153
+ <RichText html={a.description} textStyle={s.desc} />
154
+ ) : null}
154
155
  </View>
155
156
  <View style={s.quantity}>
156
157
  {soldOut(a) ? (
@@ -17,7 +17,6 @@ import { StyleSheet, Text, View } from 'react-native'
17
17
  import {
18
18
  bundleAvailabilityCopy,
19
19
  bundleContentsLine,
20
- htmlToPlainText,
21
20
  bundleQuantityRange,
22
21
  createTranslator,
23
22
  hooks,
@@ -33,6 +32,7 @@ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
33
32
  import { Field, Input } from '../ui/Field'
34
33
  import { Button, Card, ErrorText, Loader } from '../ui/primitives'
35
34
  import { QuantitySelect } from '../ui/QuantitySelect'
35
+ import { RichText } from '../ui/RichText'
36
36
  import type { ResolvedTheme } from '../ui/theme'
37
37
 
38
38
  const money = (currency: string, amount: number) => `${currency} ${amount.toFixed(2)}`
@@ -361,7 +361,6 @@ export const Bundles: FC<BundlesProps> = ({
361
361
  const range = bundleQuantityRange(bundle)
362
362
  const stock = bundleAvailabilityCopy(bundle)
363
363
  const contents = bundleContentsLine(bundle)
364
- const desc = htmlToPlainText(bundle.description ?? '')
365
364
  return (
366
365
  <View key={bundle.id} style={[s.item, { borderTopColor: theme.border }]}>
367
366
  <View style={s.info}>
@@ -377,7 +376,9 @@ export const Bundles: FC<BundlesProps> = ({
377
376
  })}
378
377
  </Text>
379
378
  )}
380
- {desc ? <Text style={s.muted}>{desc}</Text> : null}
379
+ {bundle.description ? (
380
+ <RichText html={bundle.description} textStyle={s.muted} />
381
+ ) : null}
381
382
  {stock && stock.key !== 'bundles.soldOut' ? (
382
383
  <Text style={[s.muted, s.stock]}>{t(stock.key, stock.values)}</Text>
383
384
  ) : null}
@@ -15,7 +15,7 @@ import { Accommodation } from './Accommodation'
15
15
  * payment plans. Existing explicit wallet rows keep their own policy gates.
16
16
  * - CheckoutProvider owns provider-page windows for Xendit, redirect methods,
17
17
  * Razorpay and PayPal; imperative hosts provide that port themselves.
18
- * - Conditions render as plain text (HTML stripped) — no DOM to inject markup.
18
+ * - Conditions use the shared native rich renderer, preserving links/formatting.
19
19
  */
20
20
  import React, { useEffect, useMemo, useState, type FC } from 'react'
21
21
  import { Platform, View } from 'react-native'
@@ -30,7 +30,6 @@ import {
30
30
  cartTicketCount,
31
31
  getConfig,
32
32
  errorMessage,
33
- htmlToPlainText,
34
33
  missingCheckoutDetails,
35
34
  paymentAcknowledgementsMet,
36
35
  paymentPlanTerms,
@@ -52,8 +51,13 @@ import {
52
51
  import { CardInput, claimCardField } from '../CardInput'
53
52
  import { nativePaymentSheetAvailable } from '../paymentSheet'
54
53
  import { rnConfirmer } from '../instance'
55
- import { useCheckoutContext, useCheckoutValidation } from '../ui/CheckoutProvider'
54
+ import {
55
+ useCheckoutContext,
56
+ useCheckoutValidation,
57
+ useTheme,
58
+ } from '../ui/CheckoutProvider'
56
59
  import { Checkbox } from '../ui/Field'
60
+ import { RichText } from '../ui/RichText'
57
61
  import { PaymentMethods, type PaymentMethodOption } from '../ui/PaymentMethods'
58
62
  import {
59
63
  BodyText,
@@ -85,10 +89,6 @@ const REDIRECT_LABELS: Record<string, string> = {
85
89
  const redirectLabel = (t: string) =>
86
90
  REDIRECT_LABELS[t] ?? t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
87
91
 
88
- /** Condition copy as one line of plain text: native has no DOM to render
89
- * markup into, and the copy is a checkbox label. */
90
- const conditionText = (html: string) => htmlToPlainText(html).replace(/\s*\n\s*/g, ' ')
91
-
92
92
  export interface PaymentProps {
93
93
  onComplete?: (order: PlaceOrderResult) => void
94
94
  /** Optional event context (parity with web; native `OrderSummary` reads the
@@ -150,6 +150,7 @@ export const Payment: FC<PaymentProps> = ({
150
150
  }) => {
151
151
  const t = translate ?? createTranslator({ locale, messages })
152
152
  const checkout = useCheckoutContext()
153
+ const theme = useTheme()
153
154
  // This checkout's own card field: another checkout on the page has its own.
154
155
  const cardOwner = hooks.useTicketCardOwner()
155
156
  // A bundle purchase holds the payment SDK: Pay would do nothing now.
@@ -506,7 +507,17 @@ export const Payment: FC<PaymentProps> = ({
506
507
  checked={Boolean(checkout.acceptedConditions?.[c.id])}
507
508
  invalid={invalid}
508
509
  onChange={(next) => checkout.acceptCondition(c.id, next)}
509
- label={`${conditionText(c.content || c.name)}${c.isRequired ? ' *' : ''}`}
510
+ label={
511
+ <View style={{ flex: 1, minWidth: 0, flexDirection: 'row', gap: 4 }}>
512
+ <View style={{ flex: 1, minWidth: 0 }}>
513
+ <RichText
514
+ html={c.content || c.name}
515
+ textStyle={{ color: theme.text }}
516
+ />
517
+ </View>
518
+ {c.isRequired ? <BodyText>*</BodyText> : null}
519
+ </View>
520
+ }
510
521
  />
511
522
  </FrozenFields>
512
523
  {invalid ? (
@@ -25,11 +25,10 @@ import {
25
25
  changeTicketQuantity,
26
26
  errorMessage,
27
27
  hooks,
28
- htmlToPlainText,
29
28
  ticketQuantityRange,
29
+ waitingListProfilePrefill,
30
30
  type TicketSelection,
31
31
  type TicketType,
32
- type WaitingListEntry,
33
32
  createTranslator,
34
33
  } from 'tf-checkout-shared'
35
34
 
@@ -46,73 +45,13 @@ import { FrozenFields } from '../ui/Frozen'
46
45
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
47
46
  import { Field, Input } from '../ui/Field'
48
47
  import { QuantitySelect } from '../ui/QuantitySelect'
48
+ import { WaitingListForm } from '../ui/WaitingListForm'
49
+ import { RichText } from '../ui/RichText'
49
50
  import { Select } from '../ui/Select'
50
51
  import type { ResolvedTheme } from '../ui/theme'
51
52
 
52
53
  const money = (sym: string, n: number): string => `${sym} ${Number(n).toFixed(2)}`
53
54
 
54
- /** Inline waiting-list sign-up for a sold-out type. */
55
- const WaitingListForm: FC<{
56
- ticket: TicketType
57
- onJoin: (entry: WaitingListEntry) => Promise<void>
58
- }> = ({ ticket, onJoin }) => {
59
- const [form, setForm] = useState({ firstName: '', lastName: '', email: '' })
60
- const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle')
61
- const [error, setError] = useState('')
62
- const ok = Boolean(form.firstName.trim() && form.lastName.trim() && form.email.trim())
63
-
64
- if (state === 'done')
65
- return (
66
- <MutedText>You’re on the waiting list — we’ll email you if a spot opens.</MutedText>
67
- )
68
-
69
- return (
70
- <View style={staticStyles.wlForm}>
71
- <Field>
72
- <Input
73
- placeholder="First name"
74
- value={form.firstName}
75
- onChangeText={(t) => setForm((f) => ({ ...f, firstName: t }))}
76
- />
77
- </Field>
78
- <Field>
79
- <Input
80
- placeholder="Last name"
81
- value={form.lastName}
82
- onChangeText={(t) => setForm((f) => ({ ...f, lastName: t }))}
83
- />
84
- </Field>
85
- <Field>
86
- <Input
87
- placeholder="Email"
88
- value={form.email}
89
- keyboardType="email-address"
90
- autoCapitalize="none"
91
- onChangeText={(t) => setForm((f) => ({ ...f, email: t }))}
92
- />
93
- </Field>
94
- <Button
95
- variant="ghost"
96
- loading={state === 'busy'}
97
- disabled={!ok || state === 'busy'}
98
- onPress={() => {
99
- setState('busy')
100
- setError('')
101
- onJoin({ ...form, ticketTypeId: ticket.id })
102
- .then(() => setState('done'))
103
- .catch((e) => {
104
- setState('error')
105
- setError(errorMessage(e))
106
- })
107
- }}
108
- >
109
- Join the waiting list
110
- </Button>
111
- {error ? <ErrorText>{error}</ErrorText> : null}
112
- </View>
113
- )
114
- }
115
-
116
55
  /** Host-owned visuals over the shared catalog and reservation flow. */
117
56
  export interface TicketRowRenderProps {
118
57
  ticket: TicketType
@@ -177,7 +116,27 @@ export const Tickets: FC<TicketsProps> = ({
177
116
  const checkout = useCheckoutContext()
178
117
  const theme = useTheme()
179
118
  const s = useTicketStyles(theme)
180
- const ticketsQuery = hooks.useTicketsWithMeta(eventId)
119
+ const catalog = hooks.useTicketCatalog(eventId, timeSlotDates)
120
+ const {
121
+ ticketsQuery,
122
+ eventInfo,
123
+ slotQuery,
124
+ isTimeSlot,
125
+ dates,
126
+ slotDate,
127
+ setSlotDate,
128
+ qty,
129
+ setQty,
130
+ refetchCatalog,
131
+ current: catalogCurrent,
132
+ } = catalog
133
+ const waitingBuyer = hooks.useAuthSession()
134
+ const cachedProfile = hooks.useProfile({ enabled: false }).data
135
+ const waitingPrefill = waitingListProfilePrefill(
136
+ cachedProfile,
137
+ waitingBuyer.buyerId,
138
+ waitingBuyer.sessionScope
139
+ )
181
140
  // A bundle order is starting: wait, so the two flows never start at once.
182
141
  // The checkout start lock is held by a bundle start or pay (shown as a
183
142
  // wait), or by this checkout's own priming (shown as the busy button).
@@ -185,40 +144,18 @@ export const Tickets: FC<TicketsProps> = ({
185
144
  const bundleWait = hooks.useTicketGatewayWait(checkout.store)
186
145
  const bundleStarting = bundleWait !== null
187
146
  const promoMeta = ticketsQuery.data?.promo
188
- const showWaitingList = ticketsQuery.data?.showWaitingList ?? false
189
-
190
- const [qty, setQty] = useState<Record<string, number>>({})
191
- const [slotDate, setSlotDate] = useState('')
192
-
193
- // Time-slot / calendar events: pick a date, then buy from that session's slots.
194
- const eventInfo = hooks.useEvent(eventId).data
195
- const isTimeSlot = Boolean(timeSlotDates?.length || eventInfo?.isTimeSlotEvent)
196
- const dates = useMemo(
197
- () => timeSlotDates ?? eventInfo?.availableDates ?? [],
198
- [timeSlotDates, eventInfo?.availableDates]
199
- )
200
- useEffect(() => {
201
- if (isTimeSlot && !slotDate && dates.length) setSlotDate(dates[0])
202
- }, [isTimeSlot, slotDate, dates])
203
- const slotQuery = hooks.useTimeSlotTickets(eventId, isTimeSlot ? slotDate : undefined)
204
147
 
205
- const tickets = useMemo(
206
- () => (isTimeSlot ? (slotQuery.data ?? []) : (ticketsQuery.data?.tickets ?? [])),
207
- [isTimeSlot, slotQuery.data, ticketsQuery.data?.tickets]
148
+ const tickets = catalog.tickets
149
+ const [error, updateError] = hooks.useScopedState(catalog.scope, '')
150
+ const setError = (message: string) => {
151
+ if (catalogCurrent()) updateError(message)
152
+ }
153
+ const [code, setCode] = hooks.useScopedState(catalog.scope, '')
154
+ const [codeState, setCodeState] = hooks.useScopedState<'idle' | 'busy'>(
155
+ catalog.scope,
156
+ 'idle'
208
157
  )
209
- const [error, setError] = useState('')
210
- const [code, setCode] = useState('')
211
- const [codeState, setCodeState] = useState<'idle' | 'busy'>('idle')
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
- })
221
- const [wlOpen, setWlOpen] = useState<Record<string, boolean>>({})
158
+ const [promoOpen, setPromoOpen] = hooks.useScopedState(catalog.scope, false)
222
159
  const [visibleDescription, setVisibleDescription] = useState<string | null>(null)
223
160
  const attributionEvent = useRef<string | null>(null)
224
161
 
@@ -226,7 +163,12 @@ export const Tickets: FC<TicketsProps> = ({
226
163
  // per event. Explicit props win. URL reading (web-only) is intentionally
227
164
  // skipped on native — `readAttributionFromUrl` is accepted for prop parity.
228
165
  useEffect(() => {
229
- if (attributionEvent.current === eventId) return
166
+ if (
167
+ !catalogCurrent() ||
168
+ !catalog.attributionReady ||
169
+ attributionEvent.current === eventId
170
+ )
171
+ return
230
172
  attributionEvent.current = eventId
231
173
 
232
174
  const promo = initialPromoCode?.trim() ?? ''
@@ -243,13 +185,27 @@ export const Tickets: FC<TicketsProps> = ({
243
185
  .applyPromoCode(eventId, promo)
244
186
  .catch(() => undefined)
245
187
  .finally(() => {
246
- ticketsQuery.refetch().finally(() => setCodeState('idle'))
188
+ if (!catalogCurrent()) return
189
+ return refetchCatalog()
190
+ .catch((e) => {
191
+ if (catalogCurrent()) setError(errorMessage(e))
192
+ })
193
+ .finally(() => {
194
+ if (catalogCurrent()) setCodeState('idle')
195
+ })
247
196
  })
248
197
  }
249
198
  // Checkout actions and query functions are stable; this effect is scoped to
250
199
  // one attribution pass per event.
251
200
  // eslint-disable-next-line react-hooks/exhaustive-deps
252
- }, [eventId, initialPromoCode, referrer, readAttributionFromUrl])
201
+ }, [
202
+ eventId,
203
+ initialPromoCode,
204
+ referrer,
205
+ readAttributionFromUrl,
206
+ catalog.instance,
207
+ catalog.attributionReady,
208
+ ])
253
209
 
254
210
  // Seed the currency state once so a multi-currency event can offer a switcher
255
211
  // on the ticket screen (before the cart is primed). No-op for single-currency.
@@ -262,9 +218,11 @@ export const Tickets: FC<TicketsProps> = ({
262
218
  checkout
263
219
  .syncCurrency(eventId, nextCode)
264
220
  .then((res) => {
265
- if (res.requiresCatalogRefresh) {
221
+ if (catalogCurrent() && res.requiresCatalogRefresh) {
266
222
  setQty({})
267
- ticketsQuery.refetch()
223
+ refetchCatalog().catch((e) => {
224
+ if (catalogCurrent()) setError(errorMessage(e))
225
+ })
268
226
  }
269
227
  })
270
228
  .catch(() => undefined)
@@ -310,7 +268,9 @@ export const Tickets: FC<TicketsProps> = ({
310
268
  : undefined
311
269
 
312
270
  const prime = () => {
271
+ if (!catalogCurrent() || catalog.busy || codeState === 'busy' || catalog.error) return
313
272
  setError('')
273
+ const continuationCurrent = catalog.capturePrimeContinuation()
314
274
  const selections: TicketSelection[] = selected.map((t) => ({
315
275
  id: t.id,
316
276
  name: t.name,
@@ -323,7 +283,9 @@ export const Tickets: FC<TicketsProps> = ({
323
283
  // payment SDK nothing is primed, and the error names that flow.
324
284
  checkout
325
285
  .setTickets(eventId, selections)
326
- .then(() => onContinue?.())
286
+ .then(() => {
287
+ if (continuationCurrent()) onContinue?.()
288
+ })
327
289
  .catch((e) =>
328
290
  setError(
329
291
  e instanceof ConfirmerBusyError
@@ -334,19 +296,34 @@ export const Tickets: FC<TicketsProps> = ({
334
296
  }
335
297
 
336
298
  const applyCode = () => {
337
- if (!code.trim()) return
299
+ if (
300
+ !catalogCurrent() ||
301
+ !catalog.attributionReady ||
302
+ catalog.busy ||
303
+ codeState === 'busy' ||
304
+ !code.trim()
305
+ )
306
+ return
307
+ setError('')
338
308
  setCodeState('busy')
339
309
  checkout
340
310
  .applyPromoCode(eventId, code.trim())
341
311
  .catch(() => undefined)
342
312
  .finally(() => {
343
- ticketsQuery.refetch().finally(() => setCodeState('idle'))
313
+ if (!catalogCurrent()) return
314
+ return refetchCatalog()
315
+ .catch((e) => {
316
+ if (catalogCurrent()) setError(errorMessage(e))
317
+ })
318
+ .finally(() => {
319
+ if (catalogCurrent()) setCodeState('idle')
320
+ })
344
321
  })
345
322
  }
346
323
 
347
324
  const isAccess = promoMeta?.isAccessCodeEnabled
348
325
  const showCodeInput = promoMeta?.isPromotionsEnabled || isAccess
349
- const listQuery = isTimeSlot ? slotQuery : ticketsQuery
326
+ const listQuery = catalog
350
327
  const currency = checkout.currency
351
328
 
352
329
  const renderRow = (t: TicketType) => {
@@ -354,8 +331,6 @@ export const Tickets: FC<TicketsProps> = ({
354
331
  const unavailable = t.soldOut || t.salesEnded || notStarted
355
332
  const original = originals.current[t.id]
356
333
  const discounted = original != null && original > t.price
357
- const canWaitlist =
358
- t.soldOut && (t.waitingListEnabled || showWaitingList) && !t.salesEnded
359
334
  const isFree = Number(t.price) === 0
360
335
  const sym = t.priceSymbol || t.currency
361
336
  const range = ticketQuantityRange(t)
@@ -457,26 +432,10 @@ export const Tickets: FC<TicketsProps> = ({
457
432
  )}
458
433
  </View>
459
434
  {t.descriptionRich && visibleDescription === t.id ? (
460
- <Text style={s.description}>{htmlToPlainText(t.descriptionRich)}</Text>
435
+ <RichText html={t.descriptionRich} textStyle={s.description} />
461
436
  ) : null}
462
437
  </>
463
438
  )}
464
- {canWaitlist ? (
465
- wlOpen[t.id] ? (
466
- <WaitingListForm
467
- ticket={t}
468
- onJoin={(entry) => checkout.joinWaitingList(eventId, entry)}
469
- />
470
- ) : (
471
- <Pressable
472
- accessibilityRole="button"
473
- style={s.textAction}
474
- onPress={() => setWlOpen((w) => ({ ...w, [t.id]: true }))}
475
- >
476
- <Text style={s.wlToggle}>Join the waiting list</Text>
477
- </Pressable>
478
- )
479
- ) : null}
480
439
  </View>
481
440
  </Fragment>
482
441
  )
@@ -485,7 +444,7 @@ export const Tickets: FC<TicketsProps> = ({
485
444
  // Time-slot layout groups the date's tickets under their time heading.
486
445
  const timeGroups: { time: string; items: TicketType[] }[] = []
487
446
  if (isTimeSlot)
488
- for (const t of tickets) {
447
+ for (const t of catalog.datedTickets) {
489
448
  const time = t.timeSlot?.time ?? ''
490
449
  const g = timeGroups.find((x) => x.time === time)
491
450
  if (g) g.items.push(t)
@@ -521,7 +480,6 @@ export const Tickets: FC<TicketsProps> = ({
521
480
  value={slotDate}
522
481
  onChange={(d) => {
523
482
  setSlotDate(d)
524
- setQty({})
525
483
  }}
526
484
  options={dates.map((d) => ({ label: d, value: d }))}
527
485
  />
@@ -542,7 +500,7 @@ export const Tickets: FC<TicketsProps> = ({
542
500
  </View>
543
501
  ) : null}
544
502
 
545
- {isTimeSlot && !slotQuery.isLoading && tickets.length === 0 && slotDate ? (
503
+ {isTimeSlot && !slotQuery.isLoading && !slotQuery.data?.length && slotDate ? (
546
504
  <MutedText>No sessions available on this date.</MutedText>
547
505
  ) : null}
548
506
 
@@ -555,6 +513,13 @@ export const Tickets: FC<TicketsProps> = ({
555
513
  ))
556
514
  : tickets.map(renderRow)}
557
515
 
516
+ {isTimeSlot && catalog.regularTickets.length > 0 ? (
517
+ <>
518
+ <Text style={s.slotTime}>{createTranslator()('tickets.getTickets')}</Text>
519
+ {catalog.regularTickets.map(renderRow)}
520
+ </>
521
+ ) : null}
522
+
558
523
  {/* Promo / access code */}
559
524
  {showCodeInput ? (
560
525
  <View style={s.promo}>
@@ -580,7 +545,12 @@ export const Tickets: FC<TicketsProps> = ({
580
545
  variant="ghost"
581
546
  onPress={applyCode}
582
547
  loading={codeState === 'busy'}
583
- disabled={codeState === 'busy' || !code.trim()}
548
+ disabled={
549
+ !catalog.attributionReady ||
550
+ catalog.busy ||
551
+ codeState === 'busy' ||
552
+ !code.trim()
553
+ }
584
554
  >
585
555
  {isAccess ? 'ENTER' : 'APPLY'}
586
556
  </Button>
@@ -610,6 +580,9 @@ export const Tickets: FC<TicketsProps> = ({
610
580
  checkout.orderFixed ||
611
581
  checkout.isBusy ||
612
582
  bundleStarting ||
583
+ catalog.busy ||
584
+ codeState === 'busy' ||
585
+ !!catalog.error ||
613
586
  selected.length === 0,
614
587
  title: continueTitle,
615
588
  })
@@ -617,7 +590,14 @@ export const Tickets: FC<TicketsProps> = ({
617
590
  <Button
618
591
  onPress={prime}
619
592
  loading={checkout.isBusy}
620
- disabled={checkout.isBusy || bundleStarting || selected.length === 0}
593
+ disabled={
594
+ checkout.isBusy ||
595
+ bundleStarting ||
596
+ catalog.busy ||
597
+ codeState === 'busy' ||
598
+ !!catalog.error ||
599
+ selected.length === 0
600
+ }
621
601
  >
622
602
  {checkout.isBusy
623
603
  ? 'Reserving…'
@@ -639,6 +619,13 @@ export const Tickets: FC<TicketsProps> = ({
639
619
  </Button>
640
620
  ) : null}
641
621
 
622
+ <WaitingListForm
623
+ eventId={eventId}
624
+ definition={ticketsQuery.data?.waitingList}
625
+ prefill={waitingPrefill}
626
+ onJoin={(entry) => checkout.joinWaitingList(eventId, entry)}
627
+ />
628
+
642
629
  {error || checkout.error ? (
643
630
  <ErrorText>{error || checkout.error}</ErrorText>
644
631
  ) : null}
@@ -647,11 +634,6 @@ export const Tickets: FC<TicketsProps> = ({
647
634
  )
648
635
  }
649
636
 
650
- /** Theme-independent layout bits (used by the WaitingListForm subcomponent). */
651
- const staticStyles = StyleSheet.create({
652
- wlForm: { gap: 8 },
653
- })
654
-
655
637
  const useTicketStyles = (theme: ResolvedTheme) =>
656
638
  useMemo(
657
639
  () =>
@@ -775,12 +757,6 @@ const useTicketStyles = (theme: ResolvedTheme) =>
775
757
  lineHeight: theme.fontSize * 1.5,
776
758
  fontFamily: theme.fontFamily,
777
759
  },
778
- wlToggle: {
779
- color: theme.accent,
780
- fontSize: theme.fontSize - 1,
781
- fontWeight: '600',
782
- fontFamily: theme.fontFamily,
783
- },
784
760
  promo: { gap: 8 },
785
761
  promoToggle: {
786
762
  color: theme.accent,
@@ -20,7 +20,6 @@ import {
20
20
  emptyWaiverDraft,
21
21
  formatDateDigits,
22
22
  hooks,
23
- htmlToPlainText,
24
23
  holderBackfilledFrom,
25
24
  signatureMatchesHolder,
26
25
  waiverTicketFormKey,
@@ -38,6 +37,7 @@ import {
38
37
 
39
38
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
40
39
  import { SignaturePad } from '../ui/SignaturePad'
40
+ import { RichText } from '../ui/RichText'
41
41
  import { Checkbox, Field, Input } from '../ui/Field'
42
42
  import { Button, Card, ErrorText, SectionHeader } from '../ui/primitives'
43
43
  import type { ResolvedTheme } from '../ui/theme'
@@ -116,7 +116,7 @@ export const WaiverSigning: FC<WaiverSigningProps> = ({
116
116
  )}
117
117
  </Text>
118
118
  <ScrollView style={[s.textBox, { borderColor: theme.border }]} nestedScrollEnabled>
119
- <Text style={s.body}>{htmlToPlainText(waiver.bodyHtml)}</Text>
119
+ <RichText html={waiver.bodyHtml} textStyle={s.body} selectable />
120
120
  </ScrollView>
121
121
 
122
122
  {tickets.map((ticket) => {