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
@@ -0,0 +1,278 @@
1
+ import React, { useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from 'react-native'
12
+ import { hooks, type AccountPreferences } from 'tf-checkout-shared'
13
+ import { useTheme } from '../ui/CheckoutProvider'
14
+ import { resolveTheme, type CheckoutTheme } from '../ui/theme'
15
+ import { useAccountT } from '../ui/useAccountT'
16
+
17
+ type Flow = ReturnType<typeof hooks.useAccountPreferences>
18
+ export type StreamingProvider = keyof AccountPreferences['music']
19
+ type SupportedProvider = Exclude<StreamingProvider, 'youtubeMusic'>
20
+ export interface StreamingProps {
21
+ /** Open the first-party review browser, resolving when the person returns. */
22
+ onOpenMusic: (url: string, provider: SupportedProvider) => void | Promise<void>
23
+ onSignIn?: () => void
24
+ locale?: string
25
+ translate?: (english: string, vars?: Record<string, string | number>) => string
26
+ renderProviderIcon?: (provider: StreamingProvider) => ReactNode
27
+ showHeader?: boolean
28
+ theme?: CheckoutTheme
29
+ style?: StyleProp<ViewStyle>
30
+ }
31
+
32
+ /** Standalone native presentation; account rules and state stay in the common hook. */
33
+ export const Streaming = (props: StreamingProps) => {
34
+ const preferences = hooks.useAccountPreferences()
35
+ return <StreamingView {...props} preferences={preferences} />
36
+ }
37
+
38
+ /** Compose an existing shared flow, for example to refetch on native navigation focus. */
39
+ export const StreamingView = ({
40
+ preferences: flow,
41
+ onOpenMusic,
42
+ onSignIn,
43
+ locale,
44
+ translate,
45
+ renderProviderIcon,
46
+ showHeader = true,
47
+ theme: override,
48
+ style,
49
+ }: StreamingProps & { preferences: Flow }) => {
50
+ const defaultT = useAccountT(locale)
51
+ const t = translate ?? defaultT
52
+ const inherited = useTheme()
53
+ const theme = useMemo(
54
+ () => (override ? resolveTheme({ ...inherited, ...override }) : inherited),
55
+ [inherited, override]
56
+ )
57
+ const session = hooks.useAuthSession()
58
+ const assertOwner = hooks.useSessionAction()
59
+ const pendingBrowser = useRef({ scope: session.sessionScope, locked: false })
60
+ const [stored, setStored] = useState({
61
+ scope: session.sessionScope,
62
+ busy: false,
63
+ error: '',
64
+ })
65
+ const local =
66
+ stored.scope === session.sessionScope
67
+ ? stored
68
+ : { scope: session.sessionScope, busy: false, error: '' }
69
+ const busy = flow.busy || local.busy
70
+ const text = { color: theme.text, fontFamily: theme.fontFamily }
71
+ const muted = { color: theme.muted, fontFamily: theme.fontFamily }
72
+ const connect = async (provider: SupportedProvider) => {
73
+ try {
74
+ assertOwner()
75
+ } catch {
76
+ return
77
+ }
78
+ if (
79
+ pendingBrowser.current.scope === session.sessionScope &&
80
+ pendingBrowser.current.locked
81
+ )
82
+ return
83
+ pendingBrowser.current = { scope: session.sessionScope, locked: true }
84
+ setStored({ scope: session.sessionScope, busy: true, error: '' })
85
+ try {
86
+ const link = await (provider === 'spotify'
87
+ ? flow.connectSpotify()
88
+ : flow.connectAppleMusic())
89
+ assertOwner()
90
+ if (!link) return
91
+ await onOpenMusic(link.url, provider)
92
+ assertOwner()
93
+ await flow.query.refetch()
94
+ assertOwner()
95
+ } catch {
96
+ try {
97
+ assertOwner()
98
+ setStored({
99
+ scope: session.sessionScope,
100
+ busy: false,
101
+ error: 'Unable to open the connection page. Please try again.',
102
+ })
103
+ } catch {
104
+ /* A retired browser callback cannot publish into another buyer. */
105
+ }
106
+ } finally {
107
+ try {
108
+ assertOwner()
109
+ if (pendingBrowser.current.scope === session.sessionScope)
110
+ pendingBrowser.current.locked = false
111
+ setStored((state) => ({ ...state, scope: session.sessionScope, busy: false }))
112
+ } catch {
113
+ /* The new owner keeps its own presentation state. */
114
+ }
115
+ }
116
+ }
117
+ const disconnect = (provider: SupportedProvider) => {
118
+ try {
119
+ assertOwner()
120
+ } catch {
121
+ return
122
+ }
123
+ Alert.alert(
124
+ t(provider === 'spotify' ? 'Disconnect Spotify?' : 'Disconnect Apple Music?'),
125
+ t(
126
+ provider === 'spotify'
127
+ ? 'Your imported Spotify data and approved names will be removed. Your saved interests stay.'
128
+ : 'Your imported artists and genres will be removed. Your saved interests will stay.'
129
+ ),
130
+ [
131
+ { text: t('Cancel'), style: 'cancel' },
132
+ {
133
+ text: t('Disconnect'),
134
+ style: 'destructive',
135
+ onPress: () => {
136
+ try {
137
+ assertOwner()
138
+ void (provider === 'spotify'
139
+ ? flow.disconnectSpotify()
140
+ : flow.disconnectAppleMusic())
141
+ } catch {
142
+ /* Confirmation belongs to the buyer who opened it. */
143
+ }
144
+ },
145
+ },
146
+ ]
147
+ )
148
+ }
149
+ const action = (
150
+ label: string,
151
+ provider: string,
152
+ onPress: () => void,
153
+ disabled = busy
154
+ ) => (
155
+ <Pressable
156
+ accessibilityRole="button"
157
+ accessibilityLabel={
158
+ provider ? t('{{action}} {{provider}}', { action: t(label), provider }) : t(label)
159
+ }
160
+ accessibilityState={{ disabled, busy }}
161
+ disabled={disabled}
162
+ onPress={onPress}
163
+ style={[s.button, { borderColor: theme.accent }, disabled && s.disabled]}
164
+ >
165
+ <Text style={[text, { color: theme.accent, fontSize: theme.fontSize }]}>
166
+ {t(label)}
167
+ </Text>
168
+ </Pressable>
169
+ )
170
+ const music = flow.query.data?.music
171
+ return (
172
+ <View style={[s.container, style]}>
173
+ {showHeader && <Text style={[s.title, text]}>{t('Streaming')}</Text>}
174
+ <Text style={[s.intro, text]}>
175
+ {t('Choose which artist and genre names to use for event suggestions.')}
176
+ </Text>
177
+ {flow.query.isPending && (
178
+ <ActivityIndicator
179
+ accessibilityLabel={t('Loading streaming connections…')}
180
+ color={theme.accent}
181
+ />
182
+ )}
183
+ {music &&
184
+ (
185
+ [
186
+ { key: 'spotify', name: 'Spotify', connection: music.spotify },
187
+ { key: 'appleMusic', name: 'Apple Music', connection: music.appleMusic },
188
+ {
189
+ key: 'youtubeMusic',
190
+ name: 'YouTube Music',
191
+ connection: music.youtubeMusic,
192
+ },
193
+ ] as const
194
+ ).map(({ key, name, connection }) => (
195
+ <View key={key} style={[s.provider, { borderBottomColor: theme.border }]}>
196
+ <View style={s.identity}>
197
+ {renderProviderIcon?.(key)}
198
+ <View style={s.names}>
199
+ <Text style={[s.name, text]}>{name}</Text>
200
+ <Text style={[s.status, muted]}>
201
+ {connection.connected
202
+ ? t('Connected')
203
+ : connection.available
204
+ ? t('Not connected')
205
+ : t(connection.reason || 'Not available yet')}
206
+ </Text>
207
+ </View>
208
+ </View>
209
+ {key !== 'youtubeMusic' && (
210
+ <View style={s.actions}>
211
+ {connection.available &&
212
+ action(
213
+ connection.connected ? 'Review choices' : 'Connect',
214
+ name,
215
+ () => void connect(key)
216
+ )}
217
+ {connection.connected &&
218
+ action('Disconnect', name, () => disconnect(key))}
219
+ </View>
220
+ )}
221
+ {connection.connected && (
222
+ <Text style={[s.approved, muted]}>
223
+ {connection.artists.length || connection.genres.length
224
+ ? [...connection.artists, ...connection.genres].join(', ')
225
+ : t('No artist or genre names approved yet.')}
226
+ </Text>
227
+ )}
228
+ </View>
229
+ ))}
230
+ {(local.error || flow.error || flow.query.error) && (
231
+ <Text accessibilityRole="alert" style={[s.note, { color: theme.error }]}>
232
+ {t(local.error || flow.error || flow.query.error?.message || '')}
233
+ </Text>
234
+ )}
235
+ {flow.message && (
236
+ <Text accessibilityLiveRegion="polite" style={[s.note, muted]}>
237
+ {t(flow.message)}
238
+ </Text>
239
+ )}
240
+ {action('Refresh connections', '', () => void flow.query.refetch())}
241
+ {!music && onSignIn && action('Sign in', '', onSignIn)}
242
+ <Text style={[s.note, muted]}>
243
+ {t(
244
+ 'Only the artist and genre names you approve are used for these event suggestions.'
245
+ )}
246
+ </Text>
247
+ </View>
248
+ )
249
+ }
250
+ const s = StyleSheet.create({
251
+ container: { width: '100%' },
252
+ title: { fontSize: 20, fontWeight: '500', marginBottom: 16 },
253
+ intro: { fontSize: 20, lineHeight: 28, marginVertical: 24 },
254
+ provider: { borderBottomWidth: StyleSheet.hairlineWidth, paddingVertical: 20, gap: 16 },
255
+ identity: { flexDirection: 'row', alignItems: 'center', gap: 16 },
256
+ names: { flex: 1 },
257
+ name: { fontSize: 16, lineHeight: 24 },
258
+ status: { fontSize: 14, lineHeight: 20, marginTop: 4 },
259
+ actions: {
260
+ flexDirection: 'row',
261
+ flexWrap: 'wrap',
262
+ justifyContent: 'flex-end',
263
+ gap: 10,
264
+ },
265
+ button: {
266
+ alignSelf: 'flex-end',
267
+ borderWidth: 1,
268
+ borderRadius: 24,
269
+ paddingVertical: 10,
270
+ paddingHorizontal: 20,
271
+ minHeight: 44,
272
+ justifyContent: 'center',
273
+ marginTop: 4,
274
+ },
275
+ disabled: { opacity: 0.5 },
276
+ approved: { fontSize: 14, lineHeight: 20 },
277
+ note: { fontSize: 14, lineHeight: 20, marginTop: 24 },
278
+ })
@@ -0,0 +1,379 @@
1
+ /**
2
+ * `<TicketChange/>` — the RN analogue of the web section: a buyer changes or
3
+ * upgrades one ticket. It lists what the ticket can change to with the one
4
+ * price the server words, confirms, and makes the change; a paid change opens
5
+ * the hosted payment page. When no change is open it says why and offers
6
+ * "Sell it instead" or the upgrade waitlist. The flow lives in
7
+ * `hooks.useTicketChange`, shared with web.
8
+ *
9
+ * Pass `orderHash` (from the order email link) for a buyer with no account.
10
+ */
11
+ import React, { useMemo, type FC } from 'react'
12
+ import { Pressable, StyleSheet, Text, View } from 'react-native'
13
+
14
+ import {
15
+ createTranslator,
16
+ hooks,
17
+ ticketChangeCharge,
18
+ ticketChangeDoneCopy,
19
+ ticketChangeView,
20
+ type LocaleCode,
21
+ type Messages,
22
+ type TicketChangeOption,
23
+ type Translate,
24
+ } from 'tf-checkout-shared'
25
+
26
+ import { rnPorts } from '../instance'
27
+ import { useTheme } from '../ui/CheckoutProvider'
28
+ import { Button, Card, ErrorText, Loader, SectionHeader } from '../ui/primitives'
29
+ import type { ResolvedTheme } from '../ui/theme'
30
+
31
+ export interface TicketChangeProps {
32
+ ticketHash: string
33
+ /** The order's own link hash, for a buyer who is not signed in. */
34
+ orderHash?: string
35
+ /** Fired by the Back button after a change that needed no payment is made.
36
+ * Without it, Back returns to the start of the change flow. */
37
+ onChanged?: () => void
38
+ /** Opens server links (payment page, resale). Defaults to the system browser. */
39
+ openUrl?: (url: string) => void | Promise<void>
40
+ /** Opens the host's sign-in. When given, a credit change that needs an
41
+ * account shows a Sign in button beside the prompt. A sign-in through the
42
+ * SDK reads the options again by itself; a host with its own session
43
+ * returns a promise that settles once the buyer is signed in. */
44
+ onLogin?: () => void | Promise<unknown>
45
+ locale?: LocaleCode
46
+ messages?: Partial<Messages>
47
+ translate?: Translate
48
+ }
49
+
50
+ export const TicketChange: FC<TicketChangeProps> = ({
51
+ ticketHash,
52
+ orderHash,
53
+ onChanged,
54
+ openUrl,
55
+ onLogin,
56
+ locale,
57
+ messages,
58
+ translate,
59
+ }) => {
60
+ const theme = useTheme()
61
+ const s = useChangeStyles(theme)
62
+ const t = translate ?? createTranslator({ locale, messages })
63
+ const open = openUrl ?? rnPorts.openUrl
64
+ const flow = hooks.useTicketChange({ ticketHash, orderHash }, open)
65
+ const options = flow.query.data
66
+ // A credit change that needs an account: the prompt replaces Confirm, and
67
+ // the ticket stays as it is.
68
+ const signInLine = flow.signInRequired ? (
69
+ <View>
70
+ <Text style={s.muted} accessibilityLiveRegion="polite">
71
+ {t('ticketChange.signInRequired')}
72
+ </Text>
73
+ {onLogin ? (
74
+ <Button onPress={() => flow.signIn(onLogin)}>{t('ticketChange.signIn')}</Button>
75
+ ) : null}
76
+ </View>
77
+ ) : null
78
+ const label = (option: TicketChangeOption) =>
79
+ option.eventName
80
+ ? t('ticketChange.otherEvent', { name: option.name, event: option.eventName })
81
+ : option.name
82
+ // A change for this ticket from before the buyer left and came back is
83
+ // still running: the controls show busy, never dead.
84
+ const savingLine =
85
+ flow.saving && flow.step !== 'submitting' ? (
86
+ <Text style={s.muted} accessibilityLiveRegion="polite">
87
+ {t('common.stillSaving')}
88
+ </Text>
89
+ ) : null
90
+ // The options on screen may be old after a failed reread: say so, offer a
91
+ // retry, and keep every action disabled until it works.
92
+ const refreshLine = flow.refreshFailed ? (
93
+ <View>
94
+ <ErrorText>{t('ticketChange.refreshFailed')}</ErrorText>
95
+ <Button
96
+ variant="ghost"
97
+ disabled={!flow.can.retry}
98
+ onPress={() => void flow.retryRefresh()}
99
+ >
100
+ {t('ticketChange.retry')}
101
+ </Button>
102
+ </View>
103
+ ) : null
104
+ const header = (
105
+ <>
106
+ <SectionHeader title={t('ticketChange.heading')} />
107
+ {savingLine}
108
+ {refreshLine}
109
+ </>
110
+ )
111
+
112
+ // A payment for this ticket began in a flow that is gone: no new change
113
+ // until the buyer continues it, starts again, or it is over.
114
+ if (flow.paymentInProgress) {
115
+ return (
116
+ <Card>
117
+ {header}
118
+ <Text style={s.muted} accessibilityLiveRegion="polite">
119
+ {t('ticketChange.paymentInProgress')}
120
+ </Text>
121
+ {flow.canContinuePayment ? (
122
+ <Button onPress={flow.continuePayment}>
123
+ {t('ticketChange.continuePayment')}
124
+ </Button>
125
+ ) : null}
126
+ {flow.notice ? <ErrorText>{t(flow.notice)}</ErrorText> : null}
127
+ <Button variant="ghost" onPress={flow.startAgain}>
128
+ {t('ticketChange.startAgain')}
129
+ </Button>
130
+ </Card>
131
+ )
132
+ }
133
+
134
+ if (flow.query.isLoading) {
135
+ return (
136
+ <Card>
137
+ {header}
138
+ <View style={s.loader}>
139
+ <Loader />
140
+ </View>
141
+ </Card>
142
+ )
143
+ }
144
+ if (!options) {
145
+ return (
146
+ <Card>
147
+ {header}
148
+ <ErrorText>
149
+ {flow.query.error
150
+ ? (flow.query.error as Error).message
151
+ : t('ticketChange.closed')}
152
+ </ErrorText>
153
+ </Card>
154
+ )
155
+ }
156
+
157
+ if (flow.step === 'done') {
158
+ // Only a made change is reported as one: a refund to wait for, or an
159
+ // answer that is not confirmed, leaves the ticket as it is.
160
+ const done = ticketChangeDoneCopy(flow.result)
161
+ const priceText =
162
+ flow.result && 'priceText' in flow.result ? flow.result.priceText : ''
163
+ return (
164
+ <Card>
165
+ {header}
166
+ <Text style={s.strong}>
167
+ {'text' in done ? done.text : t(done.key, done.values)}
168
+ </Text>
169
+ {priceText ? <Text style={s.strong}>{priceText}</Text> : null}
170
+ <Button
171
+ onPress={() => (done.changed && onChanged ? onChanged() : flow.restart())}
172
+ >
173
+ {t('ticketChange.back')}
174
+ </Button>
175
+ </Card>
176
+ )
177
+ }
178
+
179
+ if (flow.step === 'payment' && flow.result?.status === 'payment_required') {
180
+ return (
181
+ <Card>
182
+ {header}
183
+ <Text style={s.muted}>{t('ticketChange.payment')}</Text>
184
+ <Button disabled={!flow.can.pay} onPress={flow.openPayment}>
185
+ {t('ticketChange.paymentOpen')}
186
+ </Button>
187
+ {flow.notice ? <ErrorText>{t(flow.notice)}</ErrorText> : null}
188
+ {/* Back rereads the options under the ticket's lock: wait for it. */}
189
+ <Button
190
+ variant="ghost"
191
+ loading={flow.busy}
192
+ disabled={!flow.can.back}
193
+ onPress={flow.back}
194
+ >
195
+ {t('ticketChange.back')}
196
+ </Button>
197
+ </Card>
198
+ )
199
+ }
200
+
201
+ if ((flow.step === 'confirm' || flow.step === 'submitting') && flow.selected) {
202
+ const option = flow.selected
203
+ const submitting = flow.step === 'submitting'
204
+ return (
205
+ <Card>
206
+ {header}
207
+ <Text style={s.strong}>
208
+ {t('ticketChange.confirmTitle', { name: label(option) })}
209
+ </Text>
210
+ <Text style={s.price}>{option.priceText}</Text>
211
+ {(() => {
212
+ // The server's price text is the difference; a change fee comes on top.
213
+ const charge = ticketChangeCharge(option)
214
+ if (!charge || option.fee <= 0) return null
215
+ return (
216
+ <Text style={s.muted}>
217
+ {t('ticketChange.fee', {
218
+ amount: `${option.currency} ${option.fee.toFixed(2)}`,
219
+ })}
220
+ {'\n'}
221
+ {t('ticketChange.total', {
222
+ amount: `${charge.currency} ${charge.amount.toFixed(2)}`,
223
+ })}
224
+ </Text>
225
+ )
226
+ })()}
227
+ {flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
228
+ {flow.notice ? <Text style={s.muted}>{t(flow.notice)}</Text> : null}
229
+ {signInLine}
230
+ {flow.alternatives.length > 0 ? (
231
+ <View style={s.slots}>
232
+ <Text style={s.muted}>{t('ticketChange.slotFull')}</Text>
233
+ {flow.alternatives.map((slot) => (
234
+ <Button
235
+ key={slot.slotValue}
236
+ variant="ghost"
237
+ disabled={!flow.can.chooseAlternative}
238
+ onPress={() => void flow.chooseAlternative(slot)}
239
+ >
240
+ {slot.label}
241
+ </Button>
242
+ ))}
243
+ </View>
244
+ ) : null}
245
+ {/* No Confirm for a slot just refused: the buyer picks an open one. */}
246
+ {flow.canConfirm || submitting ? (
247
+ <Button
248
+ loading={submitting}
249
+ disabled={!flow.can.confirm}
250
+ onPress={() => void flow.confirm()}
251
+ >
252
+ {submitting
253
+ ? t('ticketChange.submitting')
254
+ : option.paymentRequired
255
+ ? t('ticketChange.confirmPay')
256
+ : t('ticketChange.confirm')}
257
+ </Button>
258
+ ) : null}
259
+ <Button variant="ghost" disabled={!flow.can.back} onPress={flow.back}>
260
+ {t('ticketChange.back')}
261
+ </Button>
262
+ </Card>
263
+ )
264
+ }
265
+
266
+ if (ticketChangeView(options) === 'choose') {
267
+ return (
268
+ <Card>
269
+ {header}
270
+ <Text style={s.muted}>{t('ticketChange.choose')}</Text>
271
+ {options.options.map((option) => (
272
+ <Pressable
273
+ key={option.ticketTypeId}
274
+ accessibilityRole="button"
275
+ accessibilityState={{ disabled: !flow.can.select }}
276
+ disabled={!flow.can.select}
277
+ onPress={() => flow.select(option)}
278
+ style={[
279
+ s.option,
280
+ { borderColor: theme.border },
281
+ !flow.can.select && s.optionDisabled,
282
+ ]}
283
+ >
284
+ <Text style={s.strong}>{label(option)}</Text>
285
+ <Text style={s.price}>{option.priceText}</Text>
286
+ </Pressable>
287
+ ))}
288
+ {flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
289
+ {flow.notice ? <Text style={s.muted}>{t(flow.notice)}</Text> : null}
290
+ {signInLine}
291
+ </Card>
292
+ )
293
+ }
294
+
295
+ return (
296
+ <Card>
297
+ {header}
298
+ <Text style={s.muted}>{options.message || t('ticketChange.closed')}</Text>
299
+ {options.sellInstead.available ? (
300
+ <>
301
+ <Text style={s.muted}>{t('ticketChange.sellInsteadNote')}</Text>
302
+ <Button disabled={!flow.can.sellInstead} onPress={flow.sellInstead}>
303
+ {t('ticketChange.sellInstead')}
304
+ </Button>
305
+ </>
306
+ ) : null}
307
+ {options.waitlistOptions.map((wait) => (
308
+ <View key={wait.ticketTypeId} style={s.waitlist}>
309
+ {wait.entry ? (
310
+ <>
311
+ <Text style={s.muted}>
312
+ {t(
313
+ wait.entry.status === 'offered'
314
+ ? 'ticketChange.waitlist.offered'
315
+ : 'ticketChange.waitlist.waiting',
316
+ { name: wait.name }
317
+ )}
318
+ </Text>
319
+ <Button
320
+ variant="ghost"
321
+ loading={flow.busyWaitlist}
322
+ disabled={!flow.can.waitlist}
323
+ onPress={() => wait.entry && void flow.leaveWaitlist(wait.entry.id)}
324
+ >
325
+ {t('ticketChange.waitlist.leave')}
326
+ </Button>
327
+ </>
328
+ ) : (
329
+ <Button
330
+ variant="ghost"
331
+ loading={flow.busyWaitlist}
332
+ disabled={!flow.can.waitlist}
333
+ onPress={() => void flow.joinWaitlist(wait.ticketTypeId)}
334
+ >
335
+ {t('ticketChange.waitlist.join', { name: wait.name })}
336
+ </Button>
337
+ )}
338
+ </View>
339
+ ))}
340
+ {flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
341
+ {flow.notice ? <Text style={s.muted}>{t(flow.notice)}</Text> : null}
342
+ </Card>
343
+ )
344
+ }
345
+
346
+ const useChangeStyles = (theme: ResolvedTheme) =>
347
+ useMemo(
348
+ () =>
349
+ StyleSheet.create({
350
+ loader: { paddingVertical: 20, alignItems: 'center' },
351
+ muted: {
352
+ color: theme.muted,
353
+ fontSize: theme.fontSize - 1,
354
+ fontFamily: theme.fontFamily,
355
+ },
356
+ strong: {
357
+ color: theme.text,
358
+ fontSize: theme.fontSize,
359
+ fontWeight: '600',
360
+ fontFamily: theme.fontFamily,
361
+ },
362
+ price: {
363
+ color: theme.text,
364
+ fontSize: theme.fontSize,
365
+ fontWeight: '700',
366
+ fontFamily: theme.fontFamily,
367
+ },
368
+ option: {
369
+ borderWidth: StyleSheet.hairlineWidth,
370
+ borderRadius: 8,
371
+ padding: 12,
372
+ gap: 4,
373
+ },
374
+ optionDisabled: { opacity: 0.4 },
375
+ slots: { gap: 8 },
376
+ waitlist: { gap: 6, paddingTop: 8 },
377
+ }),
378
+ [theme]
379
+ )