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
package/src/index.tsx CHANGED
@@ -16,7 +16,7 @@ export * from 'tf-checkout-shared'
16
16
  export { useCheckout, type UseCheckoutOptions } from './useCheckout'
17
17
  export { configureCheckout, mmkvStorageAdapter } from './configure'
18
18
  export { createMmkvStorage } from './storage'
19
- export { CardInput, type CardInputProps } from './CardInput'
19
+ export { CardInput, claimCardField, type CardInputProps } from './CardInput'
20
20
 
21
21
  // Component library: themeable provider + primitives + sections + auth.
22
22
  export * from './ui'
@@ -0,0 +1,57 @@
1
+ import React from 'react'
2
+ import { StyleSheet, Text, View } from 'react-native'
3
+ import { hooks, htmlToPlainText, accommodationBookingDeadline } from 'tf-checkout-shared'
4
+ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
5
+ import { Field, Input } from '../ui/Field'
6
+ import { QuantitySelect } from '../ui/QuantitySelect'
7
+ import { Button, Card, ErrorText, Loader, MutedText, SectionHeader } from '../ui/primitives'
8
+
9
+ /** Native controls over the shared stay draft, quote and reservation lifecycle. */
10
+ export const Accommodation = ({ eventId }: { eventId: string }) => {
11
+ const checkout = useCheckoutContext()
12
+ const flow = hooks.useAccommodationSelection(eventId, checkout)
13
+ const theme = useTheme()
14
+ const text = { color: theme.text, fontFamily: theme.fontFamily }
15
+ if (flow.query.isLoading) return <Loader />
16
+ if (!flow.rooms.length && !flow.error) return null
17
+ return (
18
+ <Card>
19
+ <SectionHeader title="Accommodation" subtitle="Add a place to stay with your tickets." />
20
+ {!!flow.error && <ErrorText>{flow.error}</ErrorText>}
21
+ {flow.query.isError && <Button variant="ghost" onPress={() => void flow.query.refetch()}>Try again</Button>}
22
+ {flow.rooms.map((room) => {
23
+ const id = String(room.room_type_id)
24
+ const stay = flow.stay(room)
25
+ const quote = flow.quotes[id]
26
+ return (
27
+ <View key={id} style={styles.room}>
28
+ <Text style={[text, styles.title]}>{room.property_name} · {room.name}</Text>
29
+ {!!room.description && <MutedText>{htmlToPlainText(room.description)}</MutedText>}
30
+ {accommodationBookingDeadline(room) && <MutedText>{accommodationBookingDeadline(room)}</MutedText>}
31
+ <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
+ <Field label="Rooms">
33
+ <QuantitySelect value={stay.rooms} max={room.max_rooms_per_line} disabled={flow.busy || room.sold_out} label={`Rooms for ${room.name}`} onChange={(rooms) => flow.update(room, { rooms })} />
34
+ </Field>
35
+ {stay.rooms > 0 && <>
36
+ <Field label="Check-in (YYYY-MM-DD)"><Input accessibilityLabel={`Check-in for ${room.name}`} value={stay.check_in} maxLength={10} autoCapitalize="none" editable={!flow.busy} onChangeText={(check_in) => flow.update(room, { check_in })} /></Field>
37
+ <Field label="Check-out (YYYY-MM-DD)"><Input accessibilityLabel={`Check-out for ${room.name}`} value={stay.check_out} maxLength={10} autoCapitalize="none" editable={!flow.busy} onChangeText={(check_out) => flow.update(room, { check_out })} /></Field>
38
+ <Field label={`Guests per room (up to ${room.max_occupancy})`}><Input accessibilityLabel={`Guests per room for ${room.name}`} value={String(stay.num_guests)} keyboardType="number-pad" editable={!flow.busy} onChangeText={(value) => flow.update(room, { num_guests: Number(value) })} /></Field>
39
+ <Field label="Guest names (optional, one per line)"><Input multiline accessibilityLabel={`Guest names for ${room.name}`} value={(stay.occupants ?? []).map((person) => person.name).join('\n')} editable={!flow.busy} onChangeText={(value) => flow.update(room, { occupants: value.split('\n').map((name) => ({ name })) })} /></Field>
40
+ <MutedText>{room.cancellation_policy
41
+ ? `Free cancellation ${room.cancellation_policy.free_cancel_days} or more days before check-in. ${room.cancellation_policy.partial_refund_pct}% refund ${room.cancellation_policy.partial_refund_days} or more days before check-in; no refund after that.`
42
+ : 'Fully refundable under the property cancellation policy.'}</MutedText>
43
+ {!!quote && !flow.dirty && <>
44
+ <Text style={text}>{quote.total_formatted ?? quote.total_price_formatted}</Text>
45
+ {quote.charges?.map((charge, index) => <MutedText key={index}>{charge.name}: {charge.line_total_formatted}{charge.included ? ' (included)' : ''}</MutedText>)}
46
+ </>}
47
+ </>}
48
+ {!!flow.rejections[id] && <ErrorText>{flow.rejections[id].message}</ErrorText>}
49
+ </View>
50
+ )
51
+ })}
52
+ {flow.rooms.length > 0 && <Button loading={checkout.isBusy} disabled={flow.busy} onPress={() => void flow.save()}>{flow.dirty ? 'Save accommodation' : 'Refresh accommodation'}</Button>}
53
+ {flow.dirty && <Button variant="ghost" disabled={flow.busy} onPress={flow.cancel}>Discard changes</Button>}
54
+ </Card>
55
+ )
56
+ }
57
+ const styles = StyleSheet.create({ room: { gap: 12, paddingVertical: 12 }, title: { fontSize: 16, fontWeight: '600' } })
@@ -0,0 +1,347 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Linking,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ View,
11
+ type StyleProp,
12
+ type ViewStyle,
13
+ } from 'react-native'
14
+ import { hooks, type AccountDeletionFlow } from 'tf-checkout-shared'
15
+ import { useTheme } from '../ui/CheckoutProvider'
16
+ import { resolveTheme, type CheckoutTheme } from '../ui/theme'
17
+ import { useAccountT } from '../ui/useAccountT'
18
+
19
+ export interface AccountDeletionProps {
20
+ showHeader?: boolean
21
+ showExplanation?: boolean
22
+ onSignIn?: () => void
23
+ onDone?: () => void
24
+ onOpenTeam?: (url: string) => void | Promise<void>
25
+ locale?: string
26
+ translate?: (english: string, values?: Record<string, string | number>) => string
27
+ theme?: CheckoutTheme
28
+ style?: StyleProp<ViewStyle>
29
+ }
30
+
31
+ export const AccountDeletion = (props: AccountDeletionProps) => {
32
+ const deletion = hooks.useAccountDeletion()
33
+ return <AccountDeletionView {...props} deletion={deletion} />
34
+ }
35
+
36
+ /** Native presentation only: the common flow owns proof, retries and deletion. */
37
+ export const AccountDeletionView = ({
38
+ deletion: flow,
39
+ showHeader = true,
40
+ showExplanation = true,
41
+ onSignIn,
42
+ onDone,
43
+ onOpenTeam,
44
+ locale,
45
+ translate,
46
+ theme: override,
47
+ style,
48
+ }: AccountDeletionProps & { deletion: AccountDeletionFlow }) => {
49
+ const defaultT = useAccountT(locale)
50
+ const t = translate ?? defaultT
51
+ const inherited = useTheme()
52
+ const theme = useMemo(
53
+ () => (override ? resolveTheme({ ...inherited, ...override }) : inherited),
54
+ [inherited, override]
55
+ )
56
+ const assertOwner = hooks.useSessionAction()
57
+ const text = { color: theme.text, fontFamily: theme.fontFamily }
58
+ const muted = { color: theme.muted, fontFamily: theme.fontFamily }
59
+ const action = (
60
+ label: string,
61
+ execute: () => void,
62
+ danger = false,
63
+ disabled = flow.busy
64
+ ) => (
65
+ <Pressable
66
+ accessibilityRole="button"
67
+ accessibilityState={{ disabled, busy: flow.busy }}
68
+ disabled={disabled}
69
+ onPress={() => {
70
+ try {
71
+ assertOwner()
72
+ execute()
73
+ } catch {
74
+ /* A retained control belongs to its original account. */
75
+ }
76
+ }}
77
+ style={[
78
+ s.button,
79
+ { borderColor: danger ? theme.error : theme.accent },
80
+ disabled && s.disabled,
81
+ ]}
82
+ >
83
+ <Text style={[text, { color: danger ? theme.error : theme.accent }]}>
84
+ {t(label)}
85
+ </Text>
86
+ </Pressable>
87
+ )
88
+ const openTeam = async (url: string) => {
89
+ try {
90
+ assertOwner()
91
+ await (onOpenTeam ? onOpenTeam(url) : Linking.openURL(url))
92
+ } catch {
93
+ try {
94
+ assertOwner()
95
+ Alert.alert(
96
+ t('Team settings'),
97
+ t('Unable to open team settings. Please try again.')
98
+ )
99
+ } catch {
100
+ /* A retired link cannot display feedback for another account. */
101
+ }
102
+ }
103
+ }
104
+ if (flow.deleted)
105
+ return (
106
+ <View style={[s.container, style]}>
107
+ <Text style={[s.title, text]}>{t('Account deleted')}</Text>
108
+ <Text accessibilityLiveRegion="polite" style={[s.body, text]}>
109
+ {t('Your Ticket Fairy account has been closed. You are signed out.')}
110
+ </Text>
111
+ <Text style={[s.body, muted]}>
112
+ {t('Financial and transaction records are retained where required.')}
113
+ </Text>
114
+ {onDone && action('Continue browsing', onDone, false, false)}
115
+ </View>
116
+ )
117
+ if (flow.confirmingAccount)
118
+ return (
119
+ <ActivityIndicator
120
+ accessibilityLabel={t('Loading account deletion options…')}
121
+ color={theme.accent}
122
+ />
123
+ )
124
+ if (!flow.signedIn)
125
+ return (
126
+ <View style={[s.container, style]}>
127
+ {flow.error && (
128
+ <>
129
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
130
+ {t(flow.error)}
131
+ </Text>
132
+ {action('Try again', () => void flow.retry())}
133
+ </>
134
+ )}
135
+ <Text style={[s.body, text]}>
136
+ {t('Sign in to review and delete your Ticket Fairy account.')}
137
+ </Text>
138
+ {onSignIn && action('Sign in', onSignIn, false, false)}
139
+ </View>
140
+ )
141
+ if (flow.query.isPending)
142
+ return (
143
+ <ActivityIndicator
144
+ accessibilityLabel={t('Loading account deletion options…')}
145
+ color={theme.accent}
146
+ />
147
+ )
148
+ if (!flow.status)
149
+ return (
150
+ <View style={[s.container, style]}>
151
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
152
+ {t(flow.error || 'Account deletion options could not load.')}
153
+ </Text>
154
+ {action('Try again', () => void flow.retry(), false, false)}
155
+ </View>
156
+ )
157
+ const status = flow.status
158
+ const password = status.verification === 'password'
159
+ const phone = status.verification === 'phone_code'
160
+ return (
161
+ <View style={[s.container, style]}>
162
+ {showHeader && <Text style={[s.title, text]}>{t('Delete account')}</Text>}
163
+ {showExplanation && (
164
+ <>
165
+ <Text style={[s.body, text]}>
166
+ {t(
167
+ 'This closes your Ticket Fairy account across the app, website and organiser dashboard.'
168
+ )}
169
+ </Text>
170
+ <Text style={[s.heading, text]}>{t('What is removed')}</Text>
171
+ <Text style={[s.body, muted]}>
172
+ {t(
173
+ 'Your personal profile, sign-in access, friends, saved preferences and the content you posted are removed or anonymised.'
174
+ )}
175
+ </Text>
176
+ <Text style={[s.heading, text]}>{t('What is kept')}</Text>
177
+ <Text style={[s.body, muted]}>
178
+ {t(
179
+ 'Financial and transaction records are retained where required. Deleting your account does not refund or cancel orders, and does not delete other ticket holders’ details.'
180
+ )}
181
+ </Text>
182
+ </>
183
+ )}
184
+ {status.appleLinked && (
185
+ <Text style={[s.body, muted]}>
186
+ {t(
187
+ 'If Ticket Fairy still appears in your Apple Account after deletion, remove its access there too.'
188
+ )}
189
+ </Text>
190
+ )}
191
+ {status.blockers.length > 0 && (
192
+ <View style={[s.panel, { borderColor: theme.border }]}>
193
+ <Text style={[s.heading, text]}>{t('Before you delete your account')}</Text>
194
+ {status.blockers.map((blocker) => (
195
+ <View key={blocker.code} style={s.container}>
196
+ <Text style={[s.body, text]}>{t(blocker.message)}</Text>
197
+ {blocker.brands?.map((brand) =>
198
+ brand.teamUrl ? (
199
+ <Pressable
200
+ key={brand.id}
201
+ accessibilityRole="link"
202
+ disabled={flow.busy}
203
+ onPress={() => {
204
+ if (brand.teamUrl) void openTeam(brand.teamUrl)
205
+ }}
206
+ style={s.link}
207
+ >
208
+ <Text style={{ color: theme.accent, fontFamily: theme.fontFamily }}>
209
+ {t('Open {{name}} team settings', { name: brand.name })}
210
+ </Text>
211
+ </Pressable>
212
+ ) : null
213
+ )}
214
+ </View>
215
+ ))}
216
+ {action('Check again', () => void flow.retry())}
217
+ </View>
218
+ )}
219
+ {!flow.isOpen && status.canDelete && action('Delete account', flow.open, true)}
220
+ {flow.isOpen && (
221
+ <View
222
+ accessibilityLabel={t('Confirm account deletion')}
223
+ style={[s.panel, { borderColor: theme.border }]}
224
+ >
225
+ <Text style={[s.heading, text]}>{t('Delete your account permanently?')}</Text>
226
+ <Text style={[s.body, text]}>
227
+ {t('This cannot be undone. Confirm that you want to close this account.')}
228
+ </Text>
229
+ {!password && (
230
+ <>
231
+ <Text style={[s.body, muted]}>
232
+ {phone
233
+ ? t('We will text a deletion code to your verified sign-in phone.')
234
+ : t('We will email a deletion code to {{email}}.', {
235
+ email: status.email,
236
+ })}
237
+ </Text>
238
+ {action(
239
+ flow.codeSent
240
+ ? 'Send a new code'
241
+ : phone
242
+ ? 'Text deletion code'
243
+ : 'Email deletion code',
244
+ () => void flow.sendCode(locale),
245
+ false,
246
+ flow.busy || flow.retryAfter > 0
247
+ )}
248
+ {flow.codeSent && (
249
+ <Text accessibilityLiveRegion="polite" style={[s.body, muted]}>
250
+ {flow.codeSent.channel === 'sms'
251
+ ? t(
252
+ 'A code was sent to {{phone}}. It expires in {{minutes}} minutes.',
253
+ {
254
+ phone: flow.codeSent.phoneMasked ?? '',
255
+ minutes: flow.codeSent.expiresInMinutes,
256
+ }
257
+ )
258
+ : t(
259
+ 'A code was sent to {{email}}. It expires in {{minutes}} minutes.',
260
+ {
261
+ email: flow.codeSent.email,
262
+ minutes: flow.codeSent.expiresInMinutes,
263
+ }
264
+ )}
265
+ </Text>
266
+ )}
267
+ </>
268
+ )}
269
+ {flow.retryAfter > 0 && (
270
+ <Text accessibilityLiveRegion="polite" style={[s.body, muted]}>
271
+ {t('Try again in {{seconds}} seconds.', { seconds: flow.retryAfter })}
272
+ </Text>
273
+ )}
274
+ <Text style={[s.heading, text]}>
275
+ {t(password ? 'Current password' : 'Deletion code')}
276
+ </Text>
277
+ <TextInput
278
+ accessibilityLabel={t(password ? 'Current password' : 'Deletion code')}
279
+ value={flow.proof}
280
+ onChangeText={flow.setProof}
281
+ secureTextEntry={password}
282
+ textContentType={password ? 'password' : 'oneTimeCode'}
283
+ autoComplete={password ? 'current-password' : 'one-time-code'}
284
+ keyboardType={password ? 'default' : 'number-pad'}
285
+ maxLength={password ? undefined : 6}
286
+ autoCapitalize="none"
287
+ autoCorrect={false}
288
+ editable={!flow.busy}
289
+ style={[
290
+ s.input,
291
+ text,
292
+ { borderColor: theme.border, backgroundColor: theme.background },
293
+ ]}
294
+ />
295
+ {flow.error && (
296
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
297
+ {t(flow.error)}
298
+ </Text>
299
+ )}
300
+ {flow.busy && (
301
+ <ActivityIndicator
302
+ color={theme.accent}
303
+ accessibilityLabel={t('Please wait…')}
304
+ />
305
+ )}
306
+ {action('Keep account', flow.cancel)}
307
+ {action(
308
+ 'Permanently delete account',
309
+ () => void flow.confirm(),
310
+ true,
311
+ !flow.canConfirm
312
+ )}
313
+ </View>
314
+ )}
315
+ {!flow.isOpen && flow.error && (
316
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
317
+ {t(flow.error)}
318
+ </Text>
319
+ )}
320
+ </View>
321
+ )
322
+ }
323
+
324
+ const s = StyleSheet.create({
325
+ container: { gap: 12 },
326
+ title: { fontSize: 22, fontWeight: '600', lineHeight: 28 },
327
+ heading: { fontSize: 16, fontWeight: '600', lineHeight: 23 },
328
+ body: { fontSize: 15, lineHeight: 22 },
329
+ panel: {
330
+ borderWidth: StyleSheet.hairlineWidth,
331
+ borderRadius: 16,
332
+ padding: 16,
333
+ gap: 12,
334
+ },
335
+ button: {
336
+ minHeight: 48,
337
+ borderWidth: 1,
338
+ borderRadius: 12,
339
+ paddingVertical: 12,
340
+ paddingHorizontal: 16,
341
+ alignItems: 'center',
342
+ justifyContent: 'center',
343
+ },
344
+ input: { minHeight: 48, borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
345
+ link: { minHeight: 44, justifyContent: 'center' },
346
+ disabled: { opacity: 0.5 },
347
+ })
@@ -0,0 +1,122 @@
1
+ import React from 'react'
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native'
3
+ import { hooks, type AccountEmailEnrichmentFlow } from 'tf-checkout-shared'
4
+ import { Button, ErrorText, MutedText, SectionHeader } from '../ui/primitives'
5
+ import { Field, Input } from '../ui/Field'
6
+ import { useAccountT } from '../ui/useAccountT'
7
+
8
+ export interface AccountEmailEnrichmentProps {
9
+ flow: AccountEmailEnrichmentFlow
10
+ locale?: string
11
+ showHeader?: boolean
12
+ onDone?: () => void
13
+ style?: StyleProp<ViewStyle>
14
+ }
15
+ /** The common owner retains the challenge, same-buyer proof and actual verified profile. */
16
+ export function AccountEmailEnrichment({
17
+ flow,
18
+ locale,
19
+ showHeader = true,
20
+ onDone,
21
+ style,
22
+ }: AccountEmailEnrichmentProps) {
23
+ const t = useAccountT(locale)
24
+ const assertOwner = hooks.useSessionAction()
25
+ const state = flow.state
26
+ return (
27
+ <View style={[{ gap: 16 }, style]}>
28
+ {showHeader ? <SectionHeader title={t('Add an email address')} /> : null}
29
+ {state.error ? <ErrorText>{t(state.error)}</ErrorText> : null}
30
+ {state.phase === 'verified' ? (
31
+ <>
32
+ <MutedText>{t('Your email has been verified.')}</MutedText>
33
+ <MutedText>{state.email}</MutedText>
34
+ {onDone ? (
35
+ <Button
36
+ onPress={() => {
37
+ try {
38
+ assertOwner()
39
+ onDone()
40
+ } catch {
41
+ /* A retained receipt cannot navigate another buyer. */
42
+ }
43
+ }}
44
+ >
45
+ {t('Done')}
46
+ </Button>
47
+ ) : null}
48
+ </>
49
+ ) : (
50
+ <>
51
+ <MutedText>
52
+ {t(
53
+ 'Email is optional. Add one you control for receipts and account recovery.'
54
+ )}
55
+ </MutedText>
56
+ <Field label={t('Email address')} required>
57
+ <Input
58
+ value={state.email}
59
+ accessibilityLabel={t('Email address')}
60
+ onChangeText={flow.setEmail}
61
+ keyboardType="email-address"
62
+ textContentType="emailAddress"
63
+ autoComplete="email"
64
+ autoCapitalize="none"
65
+ editable={!state.busy && state.phase === 'email'}
66
+ onSubmitEditing={() => {
67
+ if (state.phase === 'email') void flow.requestCode()
68
+ }}
69
+ />
70
+ </Field>
71
+ {state.phase === 'code' ? (
72
+ <>
73
+ <MutedText>
74
+ {t('Enter the code sent to {{email}}.', { email: state.email })}
75
+ </MutedText>
76
+ <Field label={t('Verification code')} required>
77
+ <Input
78
+ value={state.code}
79
+ accessibilityLabel={t('Verification code')}
80
+ onChangeText={flow.setCode}
81
+ keyboardType="number-pad"
82
+ textContentType="oneTimeCode"
83
+ autoComplete="one-time-code"
84
+ maxLength={6}
85
+ editable={!state.busy}
86
+ onSubmitEditing={() => void flow.verifyCode()}
87
+ />
88
+ </Field>
89
+ <Button
90
+ disabled={state.busy || !/^\d{6}$/.test(state.code)}
91
+ loading={state.busy}
92
+ onPress={() => void flow.verifyCode()}
93
+ >
94
+ {t('Verify email')}
95
+ </Button>
96
+ <Button
97
+ variant="ghost"
98
+ disabled={state.busy || state.retryAfter > 0}
99
+ onPress={() => void flow.requestCode()}
100
+ >
101
+ {state.retryAfter > 0
102
+ ? t('Resend in {{seconds}}s', { seconds: state.retryAfter })
103
+ : t('Resend code')}
104
+ </Button>
105
+ <Button variant="ghost" disabled={state.busy} onPress={flow.reset}>
106
+ {t('Change email')}
107
+ </Button>
108
+ </>
109
+ ) : (
110
+ <Button
111
+ disabled={state.busy || !state.email.trim() || state.retryAfter > 0}
112
+ loading={state.busy}
113
+ onPress={() => void flow.requestCode()}
114
+ >
115
+ {t('Send email code')}
116
+ </Button>
117
+ )}
118
+ </>
119
+ )}
120
+ </View>
121
+ )
122
+ }
@@ -0,0 +1,139 @@
1
+ import React from 'react'
2
+ import { Image, View } from 'react-native'
3
+ import {
4
+ accountAnswerText,
5
+ accountInformationImageUrl,
6
+ getConfig,
7
+ hooks,
8
+ type AccountInformationTarget,
9
+ } from 'tf-checkout-shared'
10
+ import { CustomFieldInput } from '../ui/CustomFields'
11
+ import { FrozenFields } from '../ui/Frozen'
12
+ import {
13
+ BodyText,
14
+ Button,
15
+ Card,
16
+ ErrorText,
17
+ Loader,
18
+ MutedText,
19
+ SectionHeader,
20
+ } from '../ui/primitives'
21
+ import { useAccountT } from '../ui/useAccountT'
22
+
23
+ export interface AccountInformationSectionProps {
24
+ orderId: string
25
+ title?: string
26
+ target?: AccountInformationTarget
27
+ locale?: string
28
+ active?: boolean
29
+ onSaved?: () => void
30
+ }
31
+ /** The common receipt flow owns validation, retirement and save confirmation. */
32
+ export const AccountInformationSection = ({
33
+ orderId,
34
+ title,
35
+ target,
36
+ locale,
37
+ active = true,
38
+ onSaved,
39
+ }: AccountInformationSectionProps) => {
40
+ const flow = hooks.useAccountInformation(orderId, target, active)
41
+ const assertOwner = hooks.useSessionAction()
42
+ const t = useAccountT(locale)
43
+ if (!active || (!flow.loading && !flow.error && flow.block && !flow.fields.length))
44
+ return null
45
+ return (
46
+ <Card>
47
+ <SectionHeader title={title ?? t('Your information')} />
48
+ {flow.loading ? <Loader /> : null}
49
+ {flow.error ? <ErrorText>{t(flow.error)}</ErrorText> : null}
50
+ {!flow.loading && !flow.block ? (
51
+ <MutedText>{t('Your information is not available for this ticket.')}</MutedText>
52
+ ) : null}
53
+ <FrozenFields frozen={flow.busy}>
54
+ <View style={{ gap: 16 }}>
55
+ {flow.fields.map((field) => {
56
+ if (flow.editing && field.editable)
57
+ return (
58
+ <CustomFieldInput
59
+ key={field.id}
60
+ field={field}
61
+ value={flow.values[field.name]}
62
+ onChange={(value) => flow.change(field.name, value)}
63
+ />
64
+ )
65
+ const image =
66
+ field.type === 'image'
67
+ ? accountInformationImageUrl(field.defaultValue, getConfig().host)
68
+ : null
69
+ return (
70
+ <View key={field.id} style={{ gap: 4 }}>
71
+ <BodyText>{field.label}</BodyText>
72
+ {image ? (
73
+ <Image
74
+ source={{ uri: image }}
75
+ accessibilityLabel={field.label}
76
+ style={{ width: 160, height: 120, resizeMode: 'contain' }}
77
+ />
78
+ ) : (
79
+ <MutedText>
80
+ {field.type === 'image' && field.defaultValue
81
+ ? t('Image already provided.')
82
+ : accountAnswerText(
83
+ { ...field, value: field.defaultValue },
84
+ { yes: t('Yes'), no: t('No') }
85
+ ) || t('Not provided')}
86
+ </MutedText>
87
+ )}
88
+ </View>
89
+ )
90
+ })}
91
+ {flow.editing ? (
92
+ <>
93
+ <Button
94
+ loading={flow.busy}
95
+ onPress={() => {
96
+ void flow.save().then((saved) => {
97
+ if (saved) {
98
+ try {
99
+ assertOwner()
100
+ onSaved?.()
101
+ } catch {
102
+ /* Retired receipt. */
103
+ }
104
+ }
105
+ })
106
+ }}
107
+ >
108
+ {t('Save changes')}
109
+ </Button>
110
+ <Button variant="ghost" onPress={flow.cancel}>
111
+ {t('Cancel')}
112
+ </Button>
113
+ {flow.error ? (
114
+ <Button
115
+ variant="ghost"
116
+ onPress={() => {
117
+ flow.cancel()
118
+ void flow.reload()
119
+ }}
120
+ >
121
+ {t('Discard edits and reload')}
122
+ </Button>
123
+ ) : null}
124
+ </>
125
+ ) : flow.fields.some((field) => field.editable) ? (
126
+ <Button variant="ghost" onPress={flow.start}>
127
+ {t('Edit your information')}
128
+ </Button>
129
+ ) : null}
130
+ </View>
131
+ </FrozenFields>
132
+ {!flow.editing ? (
133
+ <Button variant="ghost" onPress={() => void flow.reload()}>
134
+ {t('Refresh information')}
135
+ </Button>
136
+ ) : null}
137
+ </Card>
138
+ )
139
+ }