tf-checkout-react-native 2.0.0-beta.16 → 2.0.0-beta.18

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.16",
3
+ "version": "2.0.0-beta.18",
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.",
@@ -59,12 +59,21 @@ export const nativeSheetIntentIdentity = (
59
59
  )
60
60
  }
61
61
 
62
- /** The bridge must preserve the complete raw PI payment_method_types list.
63
- * Stripe may offer card-mode Link within this existing card payment contract. */
64
- const nativeSheetCardMethods = (intent: unknown): boolean => {
62
+ /** Keep the complete original list: the server can enable immediate USD Cash
63
+ * App Pay beside card. Other rails still need their own canonical contract. */
64
+ const nativeSheetMethods = (intent: unknown): boolean => {
65
65
  if (!intent || typeof intent !== 'object') return false
66
66
  const methods = Object.getOwnPropertyDescriptor(intent, 'paymentMethodTypes')?.value
67
- return Array.isArray(methods) && methods.length === 1 && methods[0] === 'card'
67
+ if (!Array.isArray(methods)) return false
68
+ if (methods.length === 1) return methods[0] === 'card'
69
+ const currency = Object.getOwnPropertyDescriptor(intent, 'currency')?.value
70
+ return (
71
+ methods.length === 2 &&
72
+ methods.includes('card') &&
73
+ methods.includes('cashapp') &&
74
+ typeof currency === 'string' &&
75
+ currency.toUpperCase() === 'USD'
76
+ )
68
77
  }
69
78
 
70
79
  /** Internal building block. The caller must hold
@@ -111,7 +120,7 @@ export const createPaymentSheetConfirmation = (
111
120
  !nativeSheetIntentIdentity(result.paymentIntent, intentId, options.reviewed)
112
121
  )
113
122
  throw paymentProviderError(NATIVE_SHEET_REVIEW_REQUIRED, 'notStarted')
114
- if (!nativeSheetCardMethods(result.paymentIntent))
123
+ if (!nativeSheetMethods(result.paymentIntent))
115
124
  throw paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted', {
116
125
  code: NATIVE_SHEET_CARD_FIELD_REQUIRED,
117
126
  })
@@ -155,7 +164,7 @@ export const createPaymentSheetConfirmation = (
155
164
  if (
156
165
  result.error ||
157
166
  !nativeSheetIntentIdentity(result.paymentIntent, intentId, options.reviewed) ||
158
- !nativeSheetCardMethods(result.paymentIntent)
167
+ !nativeSheetMethods(result.paymentIntent)
159
168
  )
160
169
  throw paymentProviderError(
161
170
  'The original payment could not be verified.',
@@ -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
@@ -178,6 +117,13 @@ export const Tickets: FC<TicketsProps> = ({
178
117
  const theme = useTheme()
179
118
  const s = useTicketStyles(theme)
180
119
  const ticketsQuery = hooks.useTicketsWithMeta(eventId)
120
+ const waitingBuyer = hooks.useAuthSession()
121
+ const cachedProfile = hooks.useProfile({ enabled: false }).data
122
+ const waitingPrefill = waitingListProfilePrefill(
123
+ cachedProfile,
124
+ waitingBuyer.buyerId,
125
+ waitingBuyer.sessionScope
126
+ )
181
127
  // A bundle order is starting: wait, so the two flows never start at once.
182
128
  // The checkout start lock is held by a bundle start or pay (shown as a
183
129
  // wait), or by this checkout's own priming (shown as the busy button).
@@ -185,7 +131,6 @@ export const Tickets: FC<TicketsProps> = ({
185
131
  const bundleWait = hooks.useTicketGatewayWait(checkout.store)
186
132
  const bundleStarting = bundleWait !== null
187
133
  const promoMeta = ticketsQuery.data?.promo
188
- const showWaitingList = ticketsQuery.data?.showWaitingList ?? false
189
134
 
190
135
  const [qty, setQty] = useState<Record<string, number>>({})
191
136
  const [slotDate, setSlotDate] = useState('')
@@ -201,6 +146,11 @@ export const Tickets: FC<TicketsProps> = ({
201
146
  if (isTimeSlot && !slotDate && dates.length) setSlotDate(dates[0])
202
147
  }, [isTimeSlot, slotDate, dates])
203
148
  const slotQuery = hooks.useTimeSlotTickets(eventId, isTimeSlot ? slotDate : undefined)
149
+ const refetchCatalog = () =>
150
+ Promise.all([
151
+ ticketsQuery.refetch(),
152
+ ...(isTimeSlot && slotDate ? [slotQuery.refetch()] : []),
153
+ ])
204
154
 
205
155
  const tickets = useMemo(
206
156
  () => (isTimeSlot ? (slotQuery.data ?? []) : (ticketsQuery.data?.tickets ?? [])),
@@ -218,7 +168,6 @@ export const Tickets: FC<TicketsProps> = ({
218
168
  setPromoOpen(false)
219
169
  setError('')
220
170
  })
221
- const [wlOpen, setWlOpen] = useState<Record<string, boolean>>({})
222
171
  const [visibleDescription, setVisibleDescription] = useState<string | null>(null)
223
172
  const attributionEvent = useRef<string | null>(null)
224
173
 
@@ -243,7 +192,7 @@ export const Tickets: FC<TicketsProps> = ({
243
192
  .applyPromoCode(eventId, promo)
244
193
  .catch(() => undefined)
245
194
  .finally(() => {
246
- ticketsQuery.refetch().finally(() => setCodeState('idle'))
195
+ refetchCatalog().finally(() => setCodeState('idle'))
247
196
  })
248
197
  }
249
198
  // Checkout actions and query functions are stable; this effect is scoped to
@@ -264,7 +213,7 @@ export const Tickets: FC<TicketsProps> = ({
264
213
  .then((res) => {
265
214
  if (res.requiresCatalogRefresh) {
266
215
  setQty({})
267
- ticketsQuery.refetch()
216
+ refetchCatalog()
268
217
  }
269
218
  })
270
219
  .catch(() => undefined)
@@ -340,7 +289,7 @@ export const Tickets: FC<TicketsProps> = ({
340
289
  .applyPromoCode(eventId, code.trim())
341
290
  .catch(() => undefined)
342
291
  .finally(() => {
343
- ticketsQuery.refetch().finally(() => setCodeState('idle'))
292
+ refetchCatalog().finally(() => setCodeState('idle'))
344
293
  })
345
294
  }
346
295
 
@@ -354,8 +303,6 @@ export const Tickets: FC<TicketsProps> = ({
354
303
  const unavailable = t.soldOut || t.salesEnded || notStarted
355
304
  const original = originals.current[t.id]
356
305
  const discounted = original != null && original > t.price
357
- const canWaitlist =
358
- t.soldOut && (t.waitingListEnabled || showWaitingList) && !t.salesEnded
359
306
  const isFree = Number(t.price) === 0
360
307
  const sym = t.priceSymbol || t.currency
361
308
  const range = ticketQuantityRange(t)
@@ -457,26 +404,10 @@ export const Tickets: FC<TicketsProps> = ({
457
404
  )}
458
405
  </View>
459
406
  {t.descriptionRich && visibleDescription === t.id ? (
460
- <Text style={s.description}>{htmlToPlainText(t.descriptionRich)}</Text>
407
+ <RichText html={t.descriptionRich} textStyle={s.description} />
461
408
  ) : null}
462
409
  </>
463
410
  )}
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
411
  </View>
481
412
  </Fragment>
482
413
  )
@@ -639,6 +570,13 @@ export const Tickets: FC<TicketsProps> = ({
639
570
  </Button>
640
571
  ) : null}
641
572
 
573
+ <WaitingListForm
574
+ eventId={eventId}
575
+ definition={ticketsQuery.data?.waitingList}
576
+ prefill={waitingPrefill}
577
+ onJoin={(entry) => checkout.joinWaitingList(eventId, entry)}
578
+ />
579
+
642
580
  {error || checkout.error ? (
643
581
  <ErrorText>{error || checkout.error}</ErrorText>
644
582
  ) : null}
@@ -647,11 +585,6 @@ export const Tickets: FC<TicketsProps> = ({
647
585
  )
648
586
  }
649
587
 
650
- /** Theme-independent layout bits (used by the WaitingListForm subcomponent). */
651
- const staticStyles = StyleSheet.create({
652
- wlForm: { gap: 8 },
653
- })
654
-
655
588
  const useTicketStyles = (theme: ResolvedTheme) =>
656
589
  useMemo(
657
590
  () =>
@@ -775,12 +708,6 @@ const useTicketStyles = (theme: ResolvedTheme) =>
775
708
  lineHeight: theme.fontSize * 1.5,
776
709
  fontFamily: theme.fontFamily,
777
710
  },
778
- wlToggle: {
779
- color: theme.accent,
780
- fontSize: theme.fontSize - 1,
781
- fontWeight: '600',
782
- fontFamily: theme.fontFamily,
783
- },
784
711
  promo: { gap: 8 },
785
712
  promoToggle: {
786
713
  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) => {