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
@@ -0,0 +1,110 @@
1
+ import React, { useMemo, type FC } from 'react'
2
+ import { View } from 'react-native'
3
+ import { WebView } from 'react-native-webview'
4
+ import {
5
+ hooks,
6
+ seatsIoChartDocument,
7
+ seatsIoLineNotes,
8
+ seatsIoRendererNavigation,
9
+ type SeatsIoMapData,
10
+ } from 'tf-checkout-shared'
11
+ import { useCheckoutContext } from '../ui/CheckoutProvider'
12
+ import { FrozenFields } from '../ui/Frozen'
13
+ import { Select } from '../ui/Select'
14
+ import {
15
+ Button,
16
+ Card,
17
+ ErrorText,
18
+ Loader,
19
+ MutedText,
20
+ SectionHeader,
21
+ } from '../ui/primitives'
22
+
23
+ export const SeatsIoMap: FC<{
24
+ eventId: string
25
+ initial: SeatsIoMapData
26
+ onContinue?: () => void
27
+ }> = ({ eventId, initial, onContinue }) => {
28
+ const checkout = useCheckoutContext()
29
+ const flow = hooks.useSeatsIoSelection(eventId, checkout, initial, onContinue)
30
+ const bundleStarting = hooks.useBundleStarting(checkout.store)
31
+ const html = useMemo(
32
+ () => (flow.data.chart ? seatsIoChartDocument(flow.data.chart, flow.nonce) : ''),
33
+ [flow.data.chart, flow.nonce]
34
+ )
35
+ return (
36
+ <FrozenFields frozen={checkout.orderFixed}>
37
+ <Card>
38
+ <SectionHeader title="Select your seats" />
39
+ {flow.data.maps.length > 1 || !flow.data.chart ? (
40
+ <Select
41
+ value={flow.data.chart?.mapId ?? ''}
42
+ placeholder="Choose a date or seating chart"
43
+ disabled={flow.loading || flow.adding || checkout.isBusy || bundleStarting}
44
+ options={flow.data.maps.map((map) => ({
45
+ value: map.id,
46
+ label: `${map.name}${map.eventStartsAt ? ` · ${map.eventStartsAt}` : ''}`,
47
+ }))}
48
+ onChange={(value) => void flow.selectMap(value)}
49
+ />
50
+ ) : null}
51
+ {flow.data.nextCursor && (
52
+ <Button
53
+ variant="ghost"
54
+ disabled={flow.loading}
55
+ onPress={() =>
56
+ void flow.selectMap(flow.data.chart?.mapId ?? '', flow.data.nextCursor!)
57
+ }
58
+ >
59
+ Show more dates
60
+ </Button>
61
+ )}
62
+ {flow.loading && <Loader />}
63
+ {flow.data.chart && (
64
+ <View style={{ height: 440 }}>
65
+ <WebView
66
+ key={flow.nonce}
67
+ source={{ html }}
68
+ javaScriptEnabled
69
+ incognito
70
+ originWhitelist={['about:*', 'https://*.seatsio.net']}
71
+ scrollEnabled={false}
72
+ onShouldStartLoadWithRequest={({ url }) => seatsIoRendererNavigation(url)}
73
+ onMessage={({ nativeEvent }) => flow.onMessage(nativeEvent.data)}
74
+ onError={() =>
75
+ flow.onMessage(JSON.stringify({ type: 'error', nonce: flow.nonce }))
76
+ }
77
+ />
78
+ </View>
79
+ )}
80
+ {flow.preview.map((line) => (
81
+ <MutedText key={line.ticketTypeId}>
82
+ {line.labels.length} × {line.name} · {flow.data.chart?.currency} {line.price}{' '}
83
+ each · {line.labels.join(', ')} {seatsIoLineNotes(line).join(' ')}
84
+ </MutedText>
85
+ ))}
86
+ {flow.secondsLeft != null && (
87
+ <MutedText>
88
+ Seats held for {Math.floor(flow.secondsLeft / 60)}:
89
+ {String(flow.secondsLeft % 60).padStart(2, '0')}
90
+ </MutedText>
91
+ )}
92
+ {flow.error && (
93
+ <>
94
+ <ErrorText>{flow.error}</ErrorText>
95
+ <Button variant="ghost" onPress={flow.restart}>
96
+ Start again
97
+ </Button>
98
+ </>
99
+ )}
100
+ <Button
101
+ onPress={flow.getTickets}
102
+ loading={flow.adding}
103
+ disabled={!flow.canContinue || bundleStarting}
104
+ >
105
+ Get tickets
106
+ </Button>
107
+ </Card>
108
+ </FrozenFields>
109
+ )
110
+ }
@@ -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
+ )
@@ -0,0 +1,206 @@
1
+ import React, { type FC } from 'react'
2
+ import { View } from 'react-native'
3
+ import { hooks, type TicketGiftProps } from 'tf-checkout-shared'
4
+ import {
5
+ BodyText,
6
+ Button,
7
+ Card,
8
+ ErrorText,
9
+ Loader,
10
+ SectionHeader,
11
+ } from '../ui/primitives'
12
+ import { Checkbox, Field, Input } from '../ui/Field'
13
+ import { useAccountT } from '../ui/useAccountT'
14
+
15
+ export const TicketGift: FC<TicketGiftProps> = ({
16
+ ticketHash,
17
+ onTicketsChanged,
18
+ onDone,
19
+ locale,
20
+ }) => {
21
+ const t = useAccountT(locale)
22
+ const flow = hooks.useTicketGift(ticketHash, onTicketsChanged)
23
+ const { state, query } = flow
24
+ const command = state.command ?? state.submitted
25
+ if (command)
26
+ return (
27
+ <Card>
28
+ <SectionHeader
29
+ title={t(command.kind === 'send' ? 'Review your gift' : 'Cancel gift')}
30
+ />
31
+ {command.kind === 'send' ? (
32
+ <>
33
+ <BodyText>
34
+ {command.intent.first_name} {command.intent.last_name}
35
+ </BodyText>
36
+ <BodyText>{command.intent.email}</BodyText>
37
+ {query.data?.add_ons
38
+ .filter(
39
+ (item) =>
40
+ !query.data?.select_add_ons ||
41
+ command.intent.add_on_ids.includes(item.id)
42
+ )
43
+ .map((item) => (
44
+ <BodyText key={item.id}>{item.name}</BodyText>
45
+ ))}
46
+ <BodyText>
47
+ {t(
48
+ 'Once the gift is accepted, this ticket will belong to the recipient and you will no longer be able to use it.'
49
+ )}
50
+ </BodyText>
51
+ </>
52
+ ) : (
53
+ <BodyText>
54
+ {t(
55
+ 'Cancel this pending gift so you can keep or send your ticket to someone else?'
56
+ )}
57
+ </BodyText>
58
+ )}
59
+ {state.error && <ErrorText>{t(state.error)}</ErrorText>}
60
+ <Button
61
+ loading={state.busy}
62
+ disabled={state.busy}
63
+ onPress={() => void flow.submit()}
64
+ >
65
+ {t(
66
+ state.submitted
67
+ ? 'Check gift status'
68
+ : command.kind === 'send'
69
+ ? 'Send gift'
70
+ : 'Cancel gift'
71
+ )}
72
+ </Button>
73
+ {!state.submitted && (
74
+ <Button variant="ghost" onPress={flow.back}>
75
+ {t('Back')}
76
+ </Button>
77
+ )}
78
+ </Card>
79
+ )
80
+ if (query.isLoading) return <Loader />
81
+ if (query.error)
82
+ return (
83
+ <Card>
84
+ <ErrorText>{t(query.error.message)}</ErrorText>
85
+ <Button onPress={() => void query.refetch()}>{t('Try again')}</Button>
86
+ </Card>
87
+ )
88
+ const gift = query.data
89
+ if (!gift) return null
90
+ return (
91
+ <Card>
92
+ <SectionHeader title={t('Gift ticket')} />
93
+ {state.result === 'cancelled' && (
94
+ <BodyText>{t('Your gift was cancelled. Your ticket is still yours.')}</BodyText>
95
+ )}
96
+ {gift.status === 'sent' && (
97
+ <BodyText>
98
+ {t('Your ticket has been given to the recipient. You can no longer use it.')}
99
+ </BodyText>
100
+ )}
101
+ {gift.pending && (
102
+ <>
103
+ <BodyText>
104
+ {t(
105
+ gift.status === 'processing'
106
+ ? 'Your gift is being completed.'
107
+ : 'Your gift is waiting for the recipient to accept it.'
108
+ )}
109
+ </BodyText>
110
+ <BodyText>
111
+ {gift.pending.first_name} {gift.pending.last_name}
112
+ </BodyText>
113
+ <BodyText>{gift.pending.email}</BodyText>
114
+ {gift.pending.can_cancel && (
115
+ <Button onPress={flow.confirmCancel}>{t('Cancel gift')}</Button>
116
+ )}
117
+ <Button variant="ghost" onPress={() => void query.refetch()}>
118
+ {t('Check gift status')}
119
+ </Button>
120
+ </>
121
+ )}
122
+ {gift.can_send ? (
123
+ <View style={{ gap: 12 }}>
124
+ <BodyText>
125
+ {t(
126
+ 'Give this ticket to someone else. Once they receive it, you will no longer own it or be able to use it.'
127
+ )}
128
+ </BodyText>
129
+ <Field label={t('First name')}>
130
+ <Input
131
+ value={state.draft.first_name}
132
+ accessibilityLabel={t('First name')}
133
+ maxLength={100}
134
+ onChangeText={(value) => flow.edit({ first_name: value })}
135
+ autoCapitalize="words"
136
+ />
137
+ </Field>
138
+ <Field label={t('Last name')}>
139
+ <Input
140
+ value={state.draft.last_name}
141
+ accessibilityLabel={t('Last name')}
142
+ maxLength={100}
143
+ onChangeText={(value) => flow.edit({ last_name: value })}
144
+ autoCapitalize="words"
145
+ />
146
+ </Field>
147
+ <Field label={t('Email address')}>
148
+ <Input
149
+ value={state.draft.email}
150
+ accessibilityLabel={t('Email address')}
151
+ maxLength={254}
152
+ onChangeText={(value) => flow.edit({ email: value })}
153
+ keyboardType="email-address"
154
+ autoCapitalize="none"
155
+ />
156
+ </Field>
157
+ <Field label={t('Confirm email address')}>
158
+ <Input
159
+ value={state.draft.confirm_email}
160
+ accessibilityLabel={t('Confirm email address')}
161
+ maxLength={254}
162
+ onChangeText={(value) => flow.edit({ confirm_email: value })}
163
+ keyboardType="email-address"
164
+ autoCapitalize="none"
165
+ />
166
+ </Field>
167
+ {gift.add_ons.length > 0 && (
168
+ <BodyText>
169
+ {t(
170
+ gift.select_add_ons
171
+ ? 'Choose add-ons to include'
172
+ : 'These add-ons are included with your gift'
173
+ )}
174
+ </BodyText>
175
+ )}
176
+ {gift.add_ons.map((item) =>
177
+ gift.select_add_ons ? (
178
+ <Checkbox
179
+ key={item.id}
180
+ label={item.name}
181
+ checked={state.draft.add_on_ids.includes(item.id)}
182
+ onChange={() => flow.toggleAddOn(item.id)}
183
+ />
184
+ ) : (
185
+ <BodyText key={item.id}>{item.name}</BodyText>
186
+ )
187
+ )}
188
+ <Checkbox
189
+ checked={state.draft.confirm_terms}
190
+ onChange={(value) => flow.edit({ confirm_terms: value })}
191
+ label={t('I understand that this ticket will belong to the recipient.')}
192
+ />
193
+ {state.error && <ErrorText>{t(state.error)}</ErrorText>}
194
+ <Button onPress={flow.review}>{t('Review gift')}</Button>
195
+ </View>
196
+ ) : gift.status === 'unavailable' ? (
197
+ <BodyText>{t('This ticket cannot be gifted now.')}</BodyText>
198
+ ) : null}
199
+ {onDone && (
200
+ <Button variant="ghost" onPress={onDone}>
201
+ {t('View my tickets')}
202
+ </Button>
203
+ )}
204
+ </Card>
205
+ )
206
+ }