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,87 @@
1
+ import React, { type FC } from 'react'
2
+ import { View } from 'react-native'
3
+ import {
4
+ createTranslator,
5
+ resolveLocale,
6
+ tripCopy,
7
+ tripsView,
8
+ type TripsProps,
9
+ } from 'tf-checkout-shared'
10
+ import { BodyText, Button, Card, ErrorText, SectionHeader } from '../ui/primitives'
11
+
12
+ /** onOpen delegates authenticated first-party browser/navigation handoff to the host app. */
13
+ export const Trips: FC<TripsProps> = ({
14
+ data,
15
+ locale,
16
+ loading,
17
+ errorMessage,
18
+ onRetry,
19
+ onOpen,
20
+ onPage,
21
+ }) => {
22
+ const copy = tripCopy(locale)
23
+ const t = createTranslator({ locale: resolveLocale(locale) })
24
+ const view = data ? tripsView(data, locale) : null
25
+ return (
26
+ <View accessibilityLabel={copy.title} accessibilityState={{ busy: Boolean(loading) }}>
27
+ {loading ? <BodyText>{t('common.loading')}</BodyText> : null}
28
+ {errorMessage ? (
29
+ <View accessibilityRole="alert">
30
+ <ErrorText>{errorMessage}</ErrorText>
31
+ {onRetry ? <Button onPress={onRetry}>{t('rewards.retry')}</Button> : null}
32
+ </View>
33
+ ) : null}
34
+ {view ? (
35
+ <>
36
+ <SectionHeader title={view.title} subtitle={view.subtitle} />
37
+ {view.sections.map((section) => (
38
+ <View key={section.key}>
39
+ {section.title ? <SectionHeader title={section.title} /> : null}
40
+ {section.cards.length === 0 ? <BodyText>{section.empty}</BodyText> : null}
41
+ {section.cards.map((card) => (
42
+ <Card key={card.key}>
43
+ <SectionHeader title={card.title} />
44
+ {card.lines.map((line, index) => (
45
+ <BodyText key={index}>{line}</BodyText>
46
+ ))}
47
+ {card.actions.map((action) => (
48
+ <Button
49
+ key={action.url}
50
+ variant="ghost"
51
+ onPress={() => onOpen(action.url)}
52
+ >
53
+ {action.label}
54
+ </Button>
55
+ ))}
56
+ </Card>
57
+ ))}
58
+ {section.page > 1 ? (
59
+ <Button
60
+ disabled={loading}
61
+ variant="ghost"
62
+ onPress={() =>
63
+ onPage({ page: section.page - 1, section: section.section })
64
+ }
65
+ >
66
+ {copy.previous}
67
+ </Button>
68
+ ) : null}
69
+ {section.hasMore ? (
70
+ <Button
71
+ disabled={loading}
72
+ variant="ghost"
73
+ onPress={() =>
74
+ onPage({ page: section.page + 1, section: section.section })
75
+ }
76
+ >
77
+ {copy.next}
78
+ </Button>
79
+ ) : null}
80
+ </View>
81
+ ))}
82
+ </>
83
+ ) : null}
84
+ </View>
85
+ )
86
+ }
87
+ export type { TripsProps } from 'tf-checkout-shared'
@@ -0,0 +1,429 @@
1
+ /**
2
+ * `<WaiverSigning/>` — the RN analogue of the web section: sign the event's
3
+ * waiver at checkout, one signature per ticket it covers. Each signature is
4
+ * uploaded on its own and the order carries only its reference. When the
5
+ * organiser made signing optional, a buyer can skip it: each guest gets a
6
+ * link to sign later.
7
+ *
8
+ * The signer draws a signature (`SignaturePad`, React Native core only, no
9
+ * native module) or types their name, from the methods the backend accepts
10
+ * for the event (`waiverSignatureMethods`).
11
+ *
12
+ * Renders nothing when the event has no waiver.
13
+ */
14
+ import React, { useMemo, useRef, useState, type FC } from 'react'
15
+ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
16
+
17
+ import {
18
+ createTranslator,
19
+ draftWithAllowedMethod,
20
+ emptyWaiverDraft,
21
+ formatDateDigits,
22
+ hooks,
23
+ htmlToPlainText,
24
+ holderBackfilledFrom,
25
+ signatureMatchesHolder,
26
+ waiverTicketFormKey,
27
+ waiverTickets,
28
+ withSignatureMethod,
29
+ type LocaleCode,
30
+ type Messages,
31
+ type Translate,
32
+ type WaiverSignatureDraft,
33
+ type WaiverSignatureMethod,
34
+ type WaiverTicket,
35
+ waiverSignableAtCheckout,
36
+ waiverSignatureMethods,
37
+ } from 'tf-checkout-shared'
38
+
39
+ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
40
+ import { SignaturePad } from '../ui/SignaturePad'
41
+ import { Checkbox, Field, Input } from '../ui/Field'
42
+ import { Button, Card, ErrorText, SectionHeader } from '../ui/primitives'
43
+ import type { ResolvedTheme } from '../ui/theme'
44
+
45
+ export interface WaiverSigningProps {
46
+ eventId: string
47
+ locale?: LocaleCode
48
+ messages?: Partial<Messages>
49
+ translate?: Translate
50
+ }
51
+
52
+ export const WaiverSigning: FC<WaiverSigningProps> = ({
53
+ eventId,
54
+ locale,
55
+ messages,
56
+ translate,
57
+ }) => {
58
+ const checkout = useCheckoutContext()
59
+ // While the order is placed or charged its signatures are fixed (`orderFixed`).
60
+ const locked = Boolean(checkout.orderFixed)
61
+ const theme = useTheme()
62
+ const s = useWaiverStyles(theme)
63
+ const t = translate ?? createTranslator({ locale, messages })
64
+ const waiver = checkout.waiver
65
+ const signing = hooks.useWaiverSigning(
66
+ eventId,
67
+ waiver,
68
+ checkout.setWaiverSignature,
69
+ checkout.holdWaiverSigning
70
+ )
71
+ const [open, setOpen] = useState<Record<string, boolean>>({})
72
+ const [reloading, setReloading] = useState(false)
73
+
74
+ // The waiver could not be read: the order waits until it is.
75
+ if (checkout.unavailableDetails?.includes('waiver')) {
76
+ return (
77
+ <Card>
78
+ <SectionHeader title={t('waivers.heading')} />
79
+ <ErrorText>{t('waivers.error.load')}</ErrorText>
80
+ <Button
81
+ variant="ghost"
82
+ loading={reloading}
83
+ onPress={() => {
84
+ setReloading(true)
85
+ checkout.reloadCheckoutDetails().finally(() => setReloading(false))
86
+ }}
87
+ >
88
+ {t('waivers.retry')}
89
+ </Button>
90
+ </Card>
91
+ )
92
+ }
93
+
94
+ const tickets = waiverTickets(waiver, checkout)
95
+ if (!waiver || tickets.length === 0) return null
96
+ // The backend accepts no method this checkout offers: the guests sign
97
+ // through the emailed link, and payment does not wait.
98
+ if (!waiverSignableAtCheckout(waiver)) {
99
+ return (
100
+ <Card>
101
+ <SectionHeader title={waiver.title || t('waivers.heading')} />
102
+ <Text style={s.muted}>{t('waivers.signLater')}</Text>
103
+ </Card>
104
+ )
105
+ }
106
+ const signatures = checkout.waiverSignatures ?? {}
107
+
108
+ return (
109
+ <Card>
110
+ <SectionHeader title={waiver.title || t('waivers.heading')} />
111
+ <Text style={s.muted}>
112
+ {t(
113
+ waiver.checkoutMode === 'required'
114
+ ? 'waivers.intro.required'
115
+ : 'waivers.intro.optional'
116
+ )}
117
+ </Text>
118
+ <ScrollView style={[s.textBox, { borderColor: theme.border }]} nestedScrollEnabled>
119
+ <Text style={s.body}>{htmlToPlainText(waiver.bodyHtml)}</Text>
120
+ </ScrollView>
121
+
122
+ {tickets.map((ticket) => {
123
+ const signed = signatures[ticket.index]
124
+ // Another event, waiver text or ticket is another form, with its own draft.
125
+ const formKey = waiverTicketFormKey(checkout.eventId, waiver, ticket)
126
+ // A signature counts only for the ticket's holder now.
127
+ const forHolder =
128
+ !signed ||
129
+ signatureMatchesHolder(
130
+ signed,
131
+ checkout.holders[ticket.index],
132
+ holderBackfilledFrom(checkout, ticket.index)
133
+ )
134
+ const label = ticket.holderName
135
+ ? t('waivers.ticketFor', {
136
+ number: ticket.ticketNumber,
137
+ name: ticket.holderName,
138
+ })
139
+ : t('waivers.ticket', { number: ticket.ticketNumber })
140
+ return (
141
+ <View key={formKey} style={[s.ticket, { borderTopColor: theme.border }]}>
142
+ <Text style={s.strong}>{label}</Text>
143
+ {signed ? (
144
+ <View style={s.row}>
145
+ <Text style={s.body}>
146
+ {t(
147
+ !forHolder
148
+ ? 'waivers.signedForSomeoneElse'
149
+ : signed.signerRole === 'guardian'
150
+ ? 'waivers.signedByGuardian'
151
+ : 'waivers.signedBy',
152
+ { name: signed.signerName }
153
+ )}
154
+ </Text>
155
+ <Button
156
+ variant="ghost"
157
+ disabled={locked}
158
+ onPress={() => {
159
+ checkout.setWaiverSignature(ticket.index, null)
160
+ setOpen((o) => ({ ...o, [formKey]: true }))
161
+ }}
162
+ >
163
+ {t('waivers.signAgain')}
164
+ </Button>
165
+ </View>
166
+ ) : open[formKey] ? (
167
+ <TicketForm
168
+ ticket={ticket}
169
+ consentText={waiver.consentText}
170
+ t={t}
171
+ methods={waiverSignatureMethods(waiver)}
172
+ busy={signing.isBusy(ticket.index)}
173
+ locked={locked}
174
+ error={signing.errors[ticket.index]}
175
+ onSign={(draft, currentDraft) =>
176
+ signing.sign(ticket.index, draft, currentDraft)
177
+ }
178
+ />
179
+ ) : (
180
+ <Button
181
+ variant="ghost"
182
+ disabled={locked}
183
+ onPress={() => setOpen((o) => ({ ...o, [formKey]: true }))}
184
+ >
185
+ {t('waivers.signThis')}
186
+ </Button>
187
+ )}
188
+ </View>
189
+ )
190
+ })}
191
+ </Card>
192
+ )
193
+ }
194
+
195
+ const TicketForm: FC<{
196
+ ticket: WaiverTicket
197
+ consentText: string | null
198
+ t: Translate
199
+ /** The methods the backend accepts, its default first. */
200
+ methods: WaiverSignatureMethod[]
201
+ /** A signing for this ticket runs (perhaps started before the buyer came back). */
202
+ busy: boolean
203
+ /** The order is being placed: signing waits until it ends. */
204
+ locked?: boolean
205
+ error?: string
206
+ /** `currentDraft` reads the form's draft when the upload ends. */
207
+ onSign: (
208
+ draft: WaiverSignatureDraft,
209
+ currentDraft: () => WaiverSignatureDraft
210
+ ) => Promise<string[]>
211
+ }> = ({ ticket, consentText, t, methods, busy, locked = false, error, onSign }) => {
212
+ const theme = useTheme()
213
+ const s = useWaiverStyles(theme)
214
+ const [heldDraft, setDraft] = useState<WaiverSignatureDraft>(() =>
215
+ emptyWaiverDraft(
216
+ { firstName: ticket.holderFirstName, lastName: ticket.holderLastName },
217
+ { signatureMethods: methods }
218
+ )
219
+ )
220
+ // Always a method this waiver accepts, so the form can always be signed.
221
+ const draft = draftWithAllowedMethod(heldDraft, methods)
222
+ const [problems, setProblems] = useState<string[]>([])
223
+ // True while this form's own press is being saved; otherwise a busy ticket
224
+ // is still saving a change from before.
225
+ const [pressed, setPressed] = useState(false)
226
+ // While a signature is saved or the order is placed, the draft cannot change.
227
+ const frozen = busy || locked
228
+ const draftRef = useRef(draft)
229
+ draftRef.current = draft
230
+ const patch = (next: Partial<WaiverSignatureDraft>) => {
231
+ if (!frozen) setDraft((d) => ({ ...d, ...next }))
232
+ }
233
+ const guardian = draft.signerRole === 'guardian'
234
+
235
+ return (
236
+ <View style={s.form}>
237
+ <Text style={s.strong}>{t('waivers.role.label')}</Text>
238
+ {(['holder', 'guardian'] as const).map((role) => (
239
+ <Pressable
240
+ key={role}
241
+ accessibilityRole="radio"
242
+ accessibilityState={{ selected: draft.signerRole === role, disabled: frozen }}
243
+ disabled={frozen}
244
+ onPress={() =>
245
+ patch(
246
+ role === 'guardian'
247
+ ? {
248
+ signerRole: role,
249
+ minorName: draft.minorName ?? ticket.holderName ?? '',
250
+ }
251
+ : { signerRole: role }
252
+ )
253
+ }
254
+ style={s.choice}
255
+ >
256
+ <View
257
+ style={[
258
+ s.radio,
259
+ { borderColor: theme.accent },
260
+ draft.signerRole === role && { backgroundColor: theme.accent },
261
+ ]}
262
+ />
263
+ <Text style={s.body}>{t(`waivers.role.${role}`)}</Text>
264
+ </Pressable>
265
+ ))}
266
+
267
+ <Field label={t('waivers.firstName')}>
268
+ <Input
269
+ editable={!frozen}
270
+ value={draft.firstName}
271
+ autoCapitalize="words"
272
+ onChangeText={(firstName) => patch({ firstName })}
273
+ />
274
+ </Field>
275
+ <Field label={t('waivers.lastName')}>
276
+ <Input
277
+ editable={!frozen}
278
+ value={draft.lastName}
279
+ autoCapitalize="words"
280
+ onChangeText={(lastName) => patch({ lastName })}
281
+ />
282
+ </Field>
283
+ {guardian ? (
284
+ <>
285
+ <Field label={t('waivers.minorName')}>
286
+ <Input
287
+ editable={!frozen}
288
+ value={draft.minorName ?? ''}
289
+ autoCapitalize="words"
290
+ onChangeText={(minorName) => patch({ minorName })}
291
+ />
292
+ </Field>
293
+ <Field label={t('waivers.relationship')}>
294
+ <Input
295
+ editable={!frozen}
296
+ value={draft.guardianRelationship ?? ''}
297
+ onChangeText={(guardianRelationship) => patch({ guardianRelationship })}
298
+ />
299
+ </Field>
300
+ <Field label={t('waivers.minorDob')}>
301
+ <Input
302
+ editable={!frozen}
303
+ value={draft.minorDob ?? ''}
304
+ placeholder="YYYY-MM-DD"
305
+ keyboardType="number-pad"
306
+ autoCapitalize="none"
307
+ onChangeText={(minorDob) =>
308
+ patch({ minorDob: formatDateDigits(minorDob) || undefined })
309
+ }
310
+ />
311
+ </Field>
312
+ </>
313
+ ) : null}
314
+
315
+ {methods.length > 1
316
+ ? methods.map((method) => (
317
+ <Pressable
318
+ key={method}
319
+ accessibilityRole="radio"
320
+ accessibilityState={{ selected: draft.method === method }}
321
+ disabled={frozen}
322
+ onPress={() => setDraft((d) => withSignatureMethod(d, method))}
323
+ style={s.choice}
324
+ >
325
+ <View
326
+ style={[
327
+ s.radio,
328
+ { borderColor: theme.accent },
329
+ draft.method === method && { backgroundColor: theme.accent },
330
+ ]}
331
+ />
332
+ <Text style={s.body}>{t(`waivers.method.${method}`)}</Text>
333
+ </Pressable>
334
+ ))
335
+ : null}
336
+ {draft.method === 'typed' ? (
337
+ <Field label={t('waivers.typedName')}>
338
+ <Input
339
+ editable={!frozen}
340
+ value={draft.typedName ?? ''}
341
+ autoCapitalize="words"
342
+ onChangeText={(typedName) => patch({ typedName })}
343
+ />
344
+ </Field>
345
+ ) : (
346
+ <SignaturePad
347
+ label={t('waivers.draw')}
348
+ clearLabel={t('waivers.clear')}
349
+ disabled={frozen}
350
+ onChange={(imageDataUrl, inkLength) => patch({ imageDataUrl, inkLength })}
351
+ />
352
+ )}
353
+ <Checkbox
354
+ checked={draft.agreeElectronic}
355
+ onChange={(agreeElectronic) => patch({ agreeElectronic })}
356
+ label={consentText || t('waivers.agree')}
357
+ />
358
+
359
+ {problems.map((key) => (
360
+ <ErrorText key={key}>{t(key)}</ErrorText>
361
+ ))}
362
+ {error ? <ErrorText>{error}</ErrorText> : null}
363
+ {busy && !pressed ? (
364
+ <Text style={s.muted} accessibilityLiveRegion="polite">
365
+ {t('common.stillSaving')}
366
+ </Text>
367
+ ) : null}
368
+
369
+ <Button
370
+ loading={busy}
371
+ disabled={busy || locked}
372
+ onPress={() => {
373
+ setPressed(true)
374
+ void onSign(draft, () => draftRef.current)
375
+ .then(setProblems)
376
+ .finally(() => setPressed(false))
377
+ }}
378
+ >
379
+ {busy ? t('waivers.signing') : t('waivers.sign')}
380
+ </Button>
381
+ </View>
382
+ )
383
+ }
384
+
385
+ const useWaiverStyles = (theme: ResolvedTheme) =>
386
+ useMemo(
387
+ () =>
388
+ StyleSheet.create({
389
+ muted: {
390
+ color: theme.muted,
391
+ fontSize: theme.fontSize - 1,
392
+ fontFamily: theme.fontFamily,
393
+ },
394
+ body: {
395
+ color: theme.text,
396
+ fontSize: theme.fontSize - 1,
397
+ fontFamily: theme.fontFamily,
398
+ flexShrink: 1,
399
+ },
400
+ strong: {
401
+ color: theme.text,
402
+ fontSize: theme.fontSize,
403
+ fontWeight: '600',
404
+ fontFamily: theme.fontFamily,
405
+ },
406
+ textBox: {
407
+ maxHeight: 220,
408
+ borderWidth: StyleSheet.hairlineWidth,
409
+ borderRadius: 8,
410
+ padding: 12,
411
+ },
412
+ ticket: { gap: 8, paddingTop: 12, borderTopWidth: StyleSheet.hairlineWidth },
413
+ row: {
414
+ flexDirection: 'row',
415
+ alignItems: 'center',
416
+ justifyContent: 'space-between',
417
+ gap: 12,
418
+ },
419
+ form: { gap: 10 },
420
+ choice: {
421
+ flexDirection: 'row',
422
+ alignItems: 'center',
423
+ gap: 8,
424
+ paddingVertical: 4,
425
+ },
426
+ radio: { width: 18, height: 18, borderRadius: 9, borderWidth: 2 },
427
+ }),
428
+ [theme]
429
+ )
@@ -0,0 +1,169 @@
1
+ import React, { useState, type FC } from 'react'
2
+ import { Text, View } from 'react-native'
3
+ import {
4
+ hooks,
5
+ applyVerifiedPhone,
6
+ billingDraftFrom,
7
+ type BillingDraft,
8
+ type VerifiedCheckoutPhone,
9
+ } from 'tf-checkout-shared'
10
+ import { Button, ErrorText, MutedText } from '../../ui/primitives'
11
+ import { Checkbox, Field, Input } from '../../ui/Field'
12
+ import { TwoFactorForm } from '../../auth/TwoFactorForm'
13
+ export interface PhoneIdentityProps {
14
+ eventId?: string
15
+ currentPhone?: string
16
+ whatsappAvailable: boolean
17
+ knownEmailSelected?: boolean
18
+ onUseKnownEmail: (value: boolean) => void
19
+ onReset: () => void
20
+ onVerified: (verified: VerifiedCheckoutPhone) => void
21
+ }
22
+ export const PhoneIdentity: FC<PhoneIdentityProps> = ({
23
+ knownEmailSelected,
24
+ onUseKnownEmail,
25
+ onReset,
26
+ ...options
27
+ }) => {
28
+ const identity = hooks.useCheckoutPhoneIdentity(options)
29
+ const [code, setCode] = useState('')
30
+ const [emailInstead, setEmailInstead] = useState(false)
31
+ if (emailInstead) return null
32
+ if (identity.challenge)
33
+ return (
34
+ <TwoFactorForm
35
+ loading={identity.busy}
36
+ error={identity.error}
37
+ successMessage=""
38
+ smsAvailable={identity.challenge.smsAvailable}
39
+ smsRetryAfter={identity.retryAfter}
40
+ onSubmit={identity.verifyTwoFactor}
41
+ onSendSms={identity.sendTwoFactorSms}
42
+ onCancel={identity.cancel}
43
+ />
44
+ )
45
+ return (
46
+ <View style={{ gap: 12, paddingVertical: 12 }}>
47
+ <Text>Verify your phone to complete your details faster</Text>
48
+ {identity.error ? <ErrorText>{identity.error}</ErrorText> : null}
49
+ {identity.verified ? (
50
+ <>
51
+ <MutedText>Your phone is verified.</MutedText>
52
+ {identity.result?.prefill.has_known_email === true &&
53
+ typeof identity.result.prefill.email_masked === 'string' ? (
54
+ <Checkbox
55
+ checked={Boolean(knownEmailSelected)}
56
+ onChange={onUseKnownEmail}
57
+ label={`Send my tickets to ${identity.result.prefill.email_masked}`}
58
+ />
59
+ ) : null}
60
+ </>
61
+ ) : (
62
+ <>
63
+ <Field label="Mobile number, including country code">
64
+ <Input
65
+ accessibilityLabel="Mobile number"
66
+ value={identity.phone}
67
+ onChangeText={identity.setPhone}
68
+ keyboardType="phone-pad"
69
+ autoComplete="tel"
70
+ editable={!identity.busy && !identity.sent}
71
+ />
72
+ </Field>
73
+ {identity.sent ? (
74
+ <>
75
+ <Field label="Verification code">
76
+ <Input
77
+ value={code}
78
+ onChangeText={(value) => setCode(value.replace(/\D/g, '').slice(0, 6))}
79
+ keyboardType="number-pad"
80
+ textContentType="oneTimeCode"
81
+ autoComplete="one-time-code"
82
+ maxLength={6}
83
+ accessibilityLabel="Verification code"
84
+ editable={!identity.busy}
85
+ />
86
+ </Field>
87
+ <Button
88
+ loading={identity.busy}
89
+ disabled={!/^\d{6}$/.test(code)}
90
+ onPress={() => void identity.verify(code)}
91
+ >
92
+ Verify phone
93
+ </Button>
94
+ </>
95
+ ) : null}
96
+ <Button
97
+ disabled={identity.busy || identity.retryAfter > 0 || !identity.phone.trim()}
98
+ onPress={() => void identity.request('sms')}
99
+ >
100
+ {identity.retryAfter > 0
101
+ ? `Send another code in ${identity.retryAfter}s`
102
+ : 'Send code by SMS'}
103
+ </Button>
104
+ {options.whatsappAvailable ? (
105
+ <Button
106
+ variant="ghost"
107
+ disabled={
108
+ identity.busy || identity.retryAfter > 0 || !identity.phone.trim()
109
+ }
110
+ onPress={() => void identity.request('whatsapp')}
111
+ >
112
+ Send code by WhatsApp
113
+ </Button>
114
+ ) : null}
115
+ </>
116
+ )}
117
+ {(identity.sent || identity.verified) && (
118
+ <Button
119
+ variant="ghost"
120
+ disabled={identity.busy}
121
+ onPress={() => {
122
+ setCode('')
123
+ onReset()
124
+ void identity.cancel()
125
+ }}
126
+ >
127
+ Change phone number
128
+ </Button>
129
+ )}
130
+ <Button
131
+ variant="ghost"
132
+ disabled={identity.busy}
133
+ onPress={() => {
134
+ void identity.cancel().then(() => setEmailInstead(true))
135
+ }}
136
+ >
137
+ Use email instead
138
+ </Button>
139
+ </View>
140
+ )
141
+ }
142
+
143
+ /** Billing owns its draft; the phone step only supplies verified, whitelisted prefill. */
144
+ export const BillingPhoneIdentity: FC<{
145
+ billing: BillingDraft
146
+ setBilling: React.Dispatch<React.SetStateAction<BillingDraft>>
147
+ eventId?: string
148
+ whatsappAvailable: boolean
149
+ }> = ({ billing, setBilling, eventId, whatsappAvailable }) => (
150
+ <PhoneIdentity
151
+ eventId={eventId}
152
+ currentPhone={billing.phone}
153
+ whatsappAvailable={whatsappAvailable}
154
+ knownEmailSelected={billing.phoneFirstUseKnownEmail}
155
+ onUseKnownEmail={(value) =>
156
+ setBilling((current) => ({ ...current, phoneFirstUseKnownEmail: value }))
157
+ }
158
+ onReset={() =>
159
+ setBilling((current) => ({
160
+ ...current,
161
+ phoneVerificationToken: undefined,
162
+ phoneFirstUseKnownEmail: false,
163
+ }))
164
+ }
165
+ onVerified={(verified) =>
166
+ setBilling((current) => billingDraftFrom(applyVerifiedPhone(current, verified)))
167
+ }
168
+ />
169
+ )