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
@@ -14,18 +14,26 @@
14
14
  *
15
15
  * Render inside `<CheckoutProvider>` so it inherits the theme + query client.
16
16
  */
17
- import React, { useCallback, type FC } from 'react'
17
+ import React, { useCallback, useEffect, useRef, type FC } from 'react'
18
18
  import { Image, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
19
19
 
20
- import { hooks, type TfProfile } from 'tf-checkout-shared'
20
+ import { getConfig, hooks, type TfProfile } from 'tf-checkout-shared'
21
21
 
22
22
  import { MutedText } from '../ui/primitives'
23
23
  import { useTheme } from '../ui/CheckoutProvider'
24
24
  import { ForgotPasswordForm } from './ForgotPasswordForm'
25
25
  import { LoginForm } from './LoginForm'
26
26
  import { RegisterForm } from './RegisterForm'
27
+ import { TwoFactorForm } from './TwoFactorForm'
28
+ import { EmailCodeForm } from './EmailCodeForm'
29
+ import { PhoneAuthentication } from '../sections/PhoneAuthentication'
30
+ import { useAccountT } from '../ui/useAccountT'
27
31
 
28
32
  export interface AuthModalProps {
33
+ checkoutEventId?: string
34
+ onCheckoutAuthenticated?: (
35
+ profile: import('tf-checkout-shared').CheckoutBuyerProfile
36
+ ) => void
29
37
  open: boolean
30
38
  onClose: () => void
31
39
  allowRegistration?: boolean
@@ -60,7 +68,7 @@ const Tab: FC<{ label: string; active: boolean; onPress: () => void }> = ({
60
68
  )
61
69
  }
62
70
 
63
- export const AuthModal: FC<AuthModalProps> = ({
71
+ const CredentialAuthModal: FC<AuthModalProps> = ({
64
72
  open,
65
73
  onClose,
66
74
  allowRegistration = true,
@@ -68,10 +76,13 @@ export const AuthModal: FC<AuthModalProps> = ({
68
76
  requireRegistrationAddress = true,
69
77
  logoUrl,
70
78
  onAuthenticated,
79
+ checkoutEventId,
80
+ onCheckoutAuthenticated,
71
81
  initialEmail,
72
82
  message,
73
83
  }) => {
74
84
  const theme = useTheme()
85
+ const t = useAccountT()
75
86
  const closeAfterAuth = useCallback(
76
87
  (profile: TfProfile) => {
77
88
  onAuthenticated?.(profile)
@@ -80,16 +91,27 @@ export const AuthModal: FC<AuthModalProps> = ({
80
91
  [onAuthenticated, onClose]
81
92
  )
82
93
  const auth = hooks.useAuthFlow({
94
+ checkoutEventId,
95
+ onCheckoutAuthenticated: (profile) => {
96
+ onCheckoutAuthenticated?.(profile)
97
+ onClose()
98
+ },
83
99
  initialEmail,
84
100
  requireRegistrationAddress,
85
101
  onAuthenticated: closeAfterAuth,
86
102
  })
87
103
 
88
104
  const isPasswordReset = auth.view === 'forgot-password'
89
- const showTabs = allowRegistration && !isPasswordReset
105
+ const showTabs =
106
+ allowRegistration &&
107
+ !isPasswordReset &&
108
+ auth.view !== 'two-factor' &&
109
+ auth.view !== 'email-code'
90
110
  // Don't let a backdrop / back-button dismiss interrupt an in-flight submit.
91
- const requestClose = () => {
92
- if (!auth.isSubmitting) onClose()
111
+ const requestClose = async () => {
112
+ if (auth.isSubmitting) return
113
+ if (auth.twoFactorChallenge) await auth.cancelTwoFactor()
114
+ onClose()
93
115
  }
94
116
 
95
117
  return (
@@ -102,13 +124,17 @@ export const AuthModal: FC<AuthModalProps> = ({
102
124
  >
103
125
  <View style={styles.header}>
104
126
  <Text style={[styles.title, { color: theme.text }]}>
105
- {isPasswordReset ? 'Password reset' : 'Ticket Fairy account'}
127
+ {isPasswordReset
128
+ ? 'Password reset'
129
+ : auth.view === 'two-factor'
130
+ ? 'Verify your sign in'
131
+ : 'Ticket Fairy account'}
106
132
  </Text>
107
133
  <Pressable
108
134
  accessibilityRole="button"
109
135
  accessibilityLabel="Close"
110
136
  disabled={auth.isSubmitting}
111
- onPress={onClose}
137
+ onPress={() => void requestClose()}
112
138
  hitSlop={12}
113
139
  >
114
140
  <Text style={[styles.close, { color: theme.muted }]}>✕</Text>
@@ -145,6 +171,33 @@ export const AuthModal: FC<AuthModalProps> = ({
145
171
 
146
172
  {message && !isPasswordReset ? <MutedText>{message}</MutedText> : null}
147
173
 
174
+ {auth.view === 'email-code' ? (
175
+ <EmailCodeForm
176
+ initialEmail={auth.suggestedEmail}
177
+ sent={auth.emailCodeSent}
178
+ target={auth.emailCodeTarget}
179
+ retryAfter={auth.emailRetryAfter}
180
+ loading={auth.isSubmitting}
181
+ error={auth.error}
182
+ successMessage={auth.successMessage}
183
+ onRequest={auth.requestEmailCode}
184
+ onVerify={auth.submitEmailCode}
185
+ onBack={() => auth.showLogin(auth.suggestedEmail)}
186
+ />
187
+ ) : null}
188
+ {auth.view === 'two-factor' && auth.twoFactorChallenge ? (
189
+ <TwoFactorForm
190
+ loading={auth.isSubmitting}
191
+ error={auth.error}
192
+ successMessage={auth.successMessage}
193
+ smsAvailable={auth.twoFactorChallenge.smsAvailable}
194
+ smsRetryAfter={auth.smsRetryAfter}
195
+ onSubmit={auth.submitTwoFactor}
196
+ onSendSms={auth.sendTwoFactorSms}
197
+ onCancel={auth.cancelTwoFactor}
198
+ />
199
+ ) : null}
200
+
148
201
  {auth.view === 'login' ? (
149
202
  <LoginForm
150
203
  initialEmail={auth.suggestedEmail}
@@ -158,13 +211,52 @@ export const AuthModal: FC<AuthModalProps> = ({
158
211
  />
159
212
  ) : null}
160
213
 
214
+ {auth.view === 'login' && auth.browserSignInAvailable ? (
215
+ <View style={{ gap: 12 }}>
216
+ {auth.checkoutPhoneSignInAvailable ? (
217
+ <Pressable accessibilityRole="button" disabled={auth.isSubmitting} onPress={() => void auth.submitBrowserSignIn('phone')}>
218
+ <Text style={{ color: theme.accent }}>{t('auth.browser.phone')}</Text>
219
+ </Pressable>
220
+ ) : null}
221
+ {(['google', 'apple', 'facebook', 'passkey'] as const).map((provider) => (
222
+ <Pressable
223
+ key={provider}
224
+ accessibilityRole="button"
225
+ disabled={auth.isSubmitting}
226
+ onPress={() => void auth.submitBrowserSignIn(provider)}
227
+ >
228
+ <Text style={{ color: theme.accent }}>
229
+ {provider === 'passkey'
230
+ ? 'Sign in with a passkey'
231
+ : `Sign in with ${provider === 'google' ? 'Google' : provider === 'apple' ? 'Apple' : 'Facebook'}`}
232
+ </Text>
233
+ </Pressable>
234
+ ))}
235
+ </View>
236
+ ) : null}
237
+ {auth.view === 'login' && auth.emailCodeAvailable ? (
238
+ <Pressable onPress={() => auth.showEmailCode(auth.suggestedEmail)}>
239
+ <Text style={{ color: theme.accent }}>Sign in with an email code</Text>
240
+ </Pressable>
241
+ ) : null}
161
242
  {auth.view === 'register' && allowRegistration ? (
162
243
  <RegisterForm
163
244
  initialEmail={auth.suggestedEmail}
164
245
  errors={auth.fieldErrors}
165
246
  error={auth.error}
166
247
  loading={auth.isSubmitting}
167
- requireAddress={requireRegistrationAddress}
248
+ requireAddress={auth.requireRegistrationAddress}
249
+ requireTerms={Boolean(auth.accountCompletion)}
250
+ completionRefresh={
251
+ auth.accountCompletion
252
+ ? {
253
+ retryAfter: auth.emailRetryAfter,
254
+ onRequest: () =>
255
+ auth.requestEmailCode(auth.accountCompletion!.email),
256
+ onVerify: auth.submitEmailCode,
257
+ }
258
+ : undefined
259
+ }
168
260
  onSubmit={auth.submitRegistration}
169
261
  onFieldChange={auth.clearFieldError}
170
262
  />
@@ -189,6 +281,91 @@ export const AuthModal: FC<AuthModalProps> = ({
189
281
  )
190
282
  }
191
283
 
284
+ const PhoneAccountModal: FC<AuthModalProps> = ({
285
+ onClose,
286
+ onAuthenticated,
287
+ initialEmail,
288
+ logoUrl,
289
+ message,
290
+ allowRegistration = true,
291
+ allowPasswordReset = true,
292
+ }) => {
293
+ const theme = useTheme()
294
+ const t = useAccountT()
295
+ const flow = hooks.usePhoneAuthentication({
296
+ initialMode: initialEmail ? 'email' : 'phone',
297
+ passwordResetTransport: 'native',
298
+ onAuthenticated: (profile) => {
299
+ onAuthenticated?.(profile)
300
+ onClose()
301
+ },
302
+ onCancelled: onClose,
303
+ })
304
+ const prepared = useRef(false)
305
+ useEffect(() => {
306
+ if (prepared.current) return
307
+ prepared.current = true
308
+ if (initialEmail) flow.emailAuth.showLogin(initialEmail)
309
+ }, [initialEmail, flow.emailAuth])
310
+ const busy = flow.state.busy || flow.emailAuth.isSubmitting
311
+ const close = () => {
312
+ if (!busy) flow.cancel()
313
+ }
314
+ return (
315
+ <Modal visible animationType="slide" transparent onRequestClose={close}>
316
+ <Pressable style={styles.backdrop} onPress={close}>
317
+ <Pressable
318
+ style={[styles.sheet, { backgroundColor: theme.surface }]}
319
+ onPress={() => {}}
320
+ >
321
+ <View style={styles.header}>
322
+ <Text style={[styles.title, { color: theme.text }]}>
323
+ {t('Your Ticket Fairy account')}
324
+ </Text>
325
+ <Pressable
326
+ accessibilityRole="button"
327
+ accessibilityLabel={t('Close')}
328
+ disabled={busy}
329
+ onPress={close}
330
+ >
331
+ <Text style={[styles.close, { color: theme.muted }]}>✕</Text>
332
+ </Pressable>
333
+ </View>
334
+ <ScrollView
335
+ contentContainerStyle={styles.body}
336
+ keyboardShouldPersistTaps="handled"
337
+ >
338
+ {logoUrl ? (
339
+ <Image source={{ uri: logoUrl }} style={styles.logo} resizeMode="contain" />
340
+ ) : null}
341
+ {message ? <MutedText>{message}</MutedText> : null}
342
+ <PhoneAuthentication
343
+ flow={flow}
344
+ allowCreation={allowRegistration}
345
+ allowPasswordReset={allowPasswordReset}
346
+ />
347
+ </ScrollView>
348
+ </Pressable>
349
+ </Pressable>
350
+ </Modal>
351
+ )
352
+ }
353
+
354
+ /** Explicit checkout scope and specialized registration keep the existing credential owner. */
355
+ export const AuthModal: FC<AuthModalProps> = (props) => {
356
+ const config = getConfig()
357
+ const fullAccount = config.accountAuth && config.cookieAuth
358
+ const specialized =
359
+ Boolean(props.checkoutEventId) || props.requireRegistrationAddress === false
360
+ return fullAccount && !specialized ? (
361
+ props.open ? (
362
+ <PhoneAccountModal {...props} />
363
+ ) : null
364
+ ) : (
365
+ <CredentialAuthModal {...props} />
366
+ )
367
+ }
368
+
192
369
  const styles = StyleSheet.create({
193
370
  backdrop: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.4)' },
194
371
  sheet: {
@@ -0,0 +1,91 @@
1
+ import React, { useState, type FC } from 'react'
2
+ import { View } from 'react-native'
3
+ import { Button, ErrorText, MutedText } from '../ui/primitives'
4
+ import { Field, Input } from '../ui/Field'
5
+ export interface EmailCodeFormProps {
6
+ initialEmail: string
7
+ sent: boolean
8
+ target: string
9
+ retryAfter: number
10
+ loading: boolean
11
+ error: string
12
+ successMessage: string
13
+ onRequest: (email: string) => Promise<boolean>
14
+ onVerify: (code: string) => Promise<unknown>
15
+ onBack: () => void
16
+ }
17
+ export const EmailCodeForm: FC<EmailCodeFormProps> = ({
18
+ initialEmail,
19
+ sent,
20
+ target,
21
+ retryAfter,
22
+ loading,
23
+ error,
24
+ successMessage,
25
+ onRequest,
26
+ onVerify,
27
+ onBack,
28
+ }) => {
29
+ const [email, setEmail] = useState(initialEmail)
30
+ const [code, setCode] = useState('')
31
+ return (
32
+ <View style={{ gap: 14 }}>
33
+ <MutedText>
34
+ {sent
35
+ ? `Enter the sign-in code sent to ${target}.`
36
+ : 'Receive a one-time sign-in code by email.'}
37
+ </MutedText>
38
+ {error ? <ErrorText>{error}</ErrorText> : null}
39
+ {successMessage ? <MutedText>{successMessage}</MutedText> : null}
40
+ <Field label="Email" required>
41
+ <Input
42
+ value={email}
43
+ onChangeText={setEmail}
44
+ keyboardType="email-address"
45
+ autoCapitalize="none"
46
+ editable={!sent && !loading}
47
+ accessibilityLabel="Email"
48
+ />
49
+ </Field>
50
+ {sent ? (
51
+ <>
52
+ <Field label="Sign-in code" required>
53
+ <Input
54
+ value={code}
55
+ onChangeText={(value) => setCode(value.replace(/\D/g, '').slice(0, 6))}
56
+ accessibilityLabel="Sign-in code"
57
+ keyboardType="number-pad"
58
+ textContentType="oneTimeCode"
59
+ autoComplete="one-time-code"
60
+ maxLength={6}
61
+ autoFocus
62
+ editable={!loading}
63
+ />
64
+ </Field>
65
+ <Button
66
+ loading={loading}
67
+ disabled={!/^\d{6}$/.test(code)}
68
+ onPress={() => void onVerify(code)}
69
+ >
70
+ Verify code
71
+ </Button>
72
+ </>
73
+ ) : null}
74
+ <Button
75
+ loading={!sent && loading}
76
+ variant={sent ? 'ghost' : 'primary'}
77
+ disabled={loading || retryAfter > 0 || !email.trim()}
78
+ onPress={() => void onRequest(email)}
79
+ >
80
+ {retryAfter > 0
81
+ ? `Send another code in ${retryAfter}s`
82
+ : sent
83
+ ? 'Resend code'
84
+ : 'Send sign-in code'}
85
+ </Button>
86
+ <Button variant="ghost" disabled={loading} onPress={onBack}>
87
+ Use password instead
88
+ </Button>
89
+ </View>
90
+ )
91
+ }
@@ -7,29 +7,38 @@
7
7
  * `phone-pad` input (the US default still seeds it with "+1").
8
8
  */
9
9
  import React, { useEffect, useMemo, useState, type FC } from 'react'
10
- import { StyleSheet, View } from 'react-native'
10
+ import { Linking, Pressable, StyleSheet, View } from 'react-native'
11
11
 
12
12
  import {
13
13
  hooks,
14
+ createTranslator,
15
+ type LocaleCode,
14
16
  type AuthFieldErrors,
15
17
  type AuthFieldName,
16
18
  type AuthRegistrationInput,
17
19
  } from 'tf-checkout-shared'
18
20
 
19
- import { Button, ErrorText } from '../ui/primitives'
21
+ import { Button, ErrorText, MutedText } from '../ui/primitives'
20
22
  import { Field, Input } from '../ui/Field'
21
23
  import { Select, type SelectOption } from '../ui/Select'
22
24
  import { PasswordInput } from './PasswordInput'
23
25
 
24
26
  export interface RegisterFormProps {
25
27
  initialEmail?: string
28
+ locale?: LocaleCode
26
29
  errors?: AuthFieldErrors
27
30
  error?: string
28
31
  loading?: boolean
29
32
  requireAddress?: boolean
33
+ requireTerms?: boolean
34
+ completionRefresh?: {
35
+ retryAfter: number
36
+ onRequest: () => Promise<boolean>
37
+ onVerify: (code: string) => Promise<unknown>
38
+ }
30
39
  onSubmit: (
31
40
  input: AuthRegistrationInput,
32
- options: { requireState: boolean }
41
+ options: { requireState: boolean; termsAccepted?: boolean }
33
42
  ) => void | Promise<unknown>
34
43
  onFieldChange?: (field: AuthFieldName) => void
35
44
  }
@@ -50,16 +59,23 @@ const emptyRegistration = (email: string): AuthRegistrationInput => ({
50
59
 
51
60
  export const RegisterForm: FC<RegisterFormProps> = ({
52
61
  initialEmail = '',
62
+ locale,
53
63
  errors = {},
54
64
  error,
55
65
  loading = false,
56
66
  requireAddress = true,
67
+ requireTerms = false,
68
+ completionRefresh,
57
69
  onSubmit,
58
70
  onFieldChange,
59
71
  }) => {
72
+ const t = useMemo(() => createTranslator({ locale }), [locale])
60
73
  const [values, setValues] = useState<AuthRegistrationInput>(() =>
61
74
  emptyRegistration(initialEmail)
62
75
  )
76
+ const [termsAccepted, setTermsAccepted] = useState(false)
77
+ const [refreshingProof, setRefreshingProof] = useState(false)
78
+ const [refreshCode, setRefreshCode] = useState('')
63
79
  const countriesQuery = hooks.useCountries()
64
80
  const countries = useMemo(() => countriesQuery.data ?? [], [countriesQuery.data])
65
81
  const statesQuery = hooks.useStates(values.countryId, {
@@ -103,7 +119,8 @@ export const RegisterForm: FC<RegisterFormProps> = ({
103
119
  [states]
104
120
  )
105
121
 
106
- const submit = () => void onSubmit(values, { requireState: states.length > 0 })
122
+ const submit = () =>
123
+ void onSubmit(values, { requireState: states.length > 0, termsAccepted })
107
124
 
108
125
  return (
109
126
  <View style={styles.form}>
@@ -137,6 +154,7 @@ export const RegisterForm: FC<RegisterFormProps> = ({
137
154
  <Field label="Email" required error={errors.email}>
138
155
  <Input
139
156
  value={values.email}
157
+ editable={!requireTerms}
140
158
  onChangeText={(text) => setField('email', text)}
141
159
  placeholder="Email"
142
160
  keyboardType="email-address"
@@ -233,7 +251,75 @@ export const RegisterForm: FC<RegisterFormProps> = ({
233
251
  />
234
252
  </Field>
235
253
 
236
- <Button onPress={submit} loading={loading}>
254
+ {completionRefresh ? (
255
+ <View style={{ gap: 12 }}>
256
+ <Button
257
+ disabled={loading || completionRefresh.retryAfter > 0}
258
+ onPress={() => {
259
+ setRefreshingProof(true)
260
+ void completionRefresh.onRequest()
261
+ }}
262
+ >
263
+ {t('auth.completionVerifyAgain')}
264
+ {completionRefresh.retryAfter > 0
265
+ ? ` (${completionRefresh.retryAfter}s)`
266
+ : ''}
267
+ </Button>
268
+ {refreshingProof ? (
269
+ <>
270
+ <Field label={t('auth.completionNewCode')}>
271
+ <Input
272
+ value={refreshCode}
273
+ keyboardType="number-pad"
274
+ textContentType="oneTimeCode"
275
+ maxLength={6}
276
+ onChangeText={(value) =>
277
+ setRefreshCode(value.replace(/\D/g, '').slice(0, 6))
278
+ }
279
+ />
280
+ </Field>
281
+ <Button
282
+ disabled={loading || !/^\d{6}$/.test(refreshCode)}
283
+ onPress={() => void completionRefresh.onVerify(refreshCode)}
284
+ >
285
+ {t('auth.completionVerifyCode')}
286
+ </Button>
287
+ </>
288
+ ) : null}
289
+ </View>
290
+ ) : null}
291
+ {requireTerms ? (
292
+ <View style={{ gap: 8 }}>
293
+ <Pressable
294
+ accessibilityRole="checkbox"
295
+ accessibilityState={{ checked: termsAccepted }}
296
+ onPress={() => setTermsAccepted((current) => !current)}
297
+ >
298
+ <MutedText>
299
+ {termsAccepted ? '☑' : '☐'} {t('auth.completionAgree')}
300
+ </MutedText>
301
+ </Pressable>
302
+ <Pressable
303
+ accessibilityRole="link"
304
+ onPress={() =>
305
+ void Linking.openURL('https://www.ticketfairy.com/terms-of-service')
306
+ }
307
+ >
308
+ <MutedText>{t('auth.completionTerms')}</MutedText>
309
+ </Pressable>
310
+ <Pressable
311
+ accessibilityRole="link"
312
+ onPress={() => void Linking.openURL('https://www.ticketfairy.com/privacy')}
313
+ >
314
+ <MutedText>{t('auth.completionPrivacy')}</MutedText>
315
+ </Pressable>
316
+ </View>
317
+ ) : null}
318
+ <Button
319
+ onPress={submit}
320
+ loading={loading}
321
+ disabled={requireTerms && !termsAccepted}
322
+ >
237
323
  Create Account
238
324
  </Button>
239
325
  </View>
@@ -0,0 +1,74 @@
1
+ import React, { useState, type FC } from 'react'
2
+ import { View } from 'react-native'
3
+ import { Button, ErrorText, MutedText } from '../ui/primitives'
4
+ import { Field, Input } from '../ui/Field'
5
+
6
+ export interface TwoFactorFormProps {
7
+ loading: boolean
8
+ error: string
9
+ successMessage: string
10
+ smsAvailable: boolean
11
+ smsRetryAfter: number
12
+ onSubmit: (code: string) => Promise<unknown>
13
+ onSendSms: () => Promise<boolean>
14
+ onCancel: () => Promise<void>
15
+ }
16
+
17
+ /** Presentation only; the shared headless flow owns the pending identity. */
18
+ export const TwoFactorForm: FC<TwoFactorFormProps> = ({
19
+ loading,
20
+ error,
21
+ successMessage,
22
+ smsAvailable,
23
+ smsRetryAfter,
24
+ onSubmit,
25
+ onSendSms,
26
+ onCancel,
27
+ }) => {
28
+ const [code, setCode] = useState('')
29
+ return (
30
+ <View style={{ gap: 14 }}>
31
+ <MutedText>
32
+ Enter the six-digit code from your authenticator app or registered phone to
33
+ complete sign-in.
34
+ </MutedText>
35
+ {error ? <ErrorText>{error}</ErrorText> : null}
36
+ {successMessage ? <MutedText>{successMessage}</MutedText> : null}
37
+ <Field label="Verification code" required>
38
+ <Input
39
+ accessibilityLabel="Verification code"
40
+ value={code}
41
+ onChangeText={(value) => setCode(value.replace(/\D/g, '').slice(0, 6))}
42
+ keyboardType="number-pad"
43
+ textContentType="oneTimeCode"
44
+ autoComplete="one-time-code"
45
+ maxLength={6}
46
+ autoFocus
47
+ editable={!loading}
48
+ onSubmitEditing={() => void onSubmit(code)}
49
+ />
50
+ </Field>
51
+ <Button
52
+ loading={loading}
53
+ disabled={!/^\d{6}$/.test(code)}
54
+ onPress={() => void onSubmit(code)}
55
+ >
56
+ Verify code
57
+ </Button>
58
+ {smsAvailable ? (
59
+ <Button
60
+ variant="ghost"
61
+ disabled={loading || smsRetryAfter > 0}
62
+ onPress={() => void onSendSms()}
63
+ >
64
+ {smsRetryAfter > 0
65
+ ? `Send another code in ${smsRetryAfter}s`
66
+ : 'Send a code by SMS'}
67
+ </Button>
68
+ ) : null}
69
+ <Button variant="ghost" disabled={loading} onPress={() => void onCancel()}>
70
+ Use password again
71
+ </Button>
72
+ </View>
73
+ )
74
+ }
package/src/auth/index.ts CHANGED
@@ -5,3 +5,7 @@ export { RegisterForm } from './RegisterForm'
5
5
  export { ForgotPasswordForm } from './ForgotPasswordForm'
6
6
  export { ResetPasswordForm } from './ResetPasswordForm'
7
7
  export { PasswordInput } from './PasswordInput'
8
+
9
+ export { TwoFactorForm, type TwoFactorFormProps } from './TwoFactorForm'
10
+
11
+ export { EmailCodeForm, type EmailCodeFormProps } from './EmailCodeForm'
package/src/configure.ts CHANGED
@@ -1,19 +1,17 @@
1
1
  /**
2
- * One-time SDK setup for React Native. Wraps the shared `configure()` and
3
- * defaults the token store to a persisted MMKV adapter. Call once at app
4
- * start, before any `useCheckout`/`ttf` call:
2
+ * One-time React Native SDK setup. Defaults SDK-owned storage to persisted
3
+ * MMKV. Call before any checkout, API or hook:
5
4
  *
6
- * configureCheckout({ env: 'PROD', clientId, clientSecret })
5
+ * configureCheckout({ env: 'PROD', clientId: 'PUBLIC_SDK_CLIENT_ID', clientSecret: '' })
7
6
  *
8
- * `cookieAuth` defaults ON: native sign-in must ride the legacy cookie-session
9
- * flow (`POST /auth`), NOT the OAuth bearer chain. The cart lives in the
10
- * backend session, and a bearer request without a guest header is moved onto a
11
- * synthetic empty session — so a buyer who reserves tickets and then logs in
12
- * mid-checkout would lose the cart ("The cart is expired"). On the cookie flow
13
- * no bearer is ever minted, the session that holds the cart stays put, and the
14
- * X-TF-ECOMMERCE ticket is persisted to MMKV (the shared cookie shim falls
15
- * back to the storage adapter off-browser) so sign-in survives a relaunch.
16
- * Pass `cookieAuth: false` to opt back into the bearer chain.
7
+ * Public checkout sign-in preserves the event-bound guest carrier and keeps
8
+ * its buyer credential server-side; it does not return a full-account ticket
9
+ * or grant private account scope. Never bundle a confidential client secret.
10
+ * First-party full-account flows explicitly use `accountAuth: true`.
11
+ *
12
+ * The wrapper defaults `cookieAuth` to true and disables ambient native cookie
13
+ * handling. Owned credentials use the SDK's header-backed storage; the backend
14
+ * still validates a restored session.
17
15
  */
18
16
  import { configure, type TtfConfig } from 'tf-checkout-shared'
19
17
 
@@ -23,4 +21,11 @@ import { createMmkvStorage } from './storage'
23
21
  export const mmkvStorageAdapter = createMmkvStorage()
24
22
 
25
23
  export const configureCheckout = (config: TtfConfig): void =>
26
- configure({ storage: mmkvStorageAdapter, cookieAuth: true, ...config })
24
+ configure({
25
+ storage: mmkvStorageAdapter,
26
+ cookieAuth: true,
27
+ ...config,
28
+ // NSURLSession/RCTNetworking would otherwise accept Set-Cookie outside
29
+ // the SDK epoch guard. Only the SDK's header-backed jar owns native auth.
30
+ withCredentials: false,
31
+ })