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.
- package/README.md +115 -24
- package/dist/index.d.mts +8195 -282
- package/dist/index.d.ts +8195 -282
- package/dist/index.js +32346 -7001
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32586 -7128
- package/dist/index.mjs.map +1 -1
- package/package.json +23 -11
- package/scripts/build-seat-map.mjs +31 -0
- package/scripts/seat-map-entry.jsx +22 -0
- package/scripts/verify-discussion-support.cjs +131 -0
- package/src/CardInput.tsx +64 -3
- package/src/EmbeddedCheckout.tsx +140 -38
- package/src/auth/AuthModal.tsx +186 -9
- package/src/auth/EmailCodeForm.tsx +91 -0
- package/src/auth/RegisterForm.tsx +91 -5
- package/src/auth/TwoFactorForm.tsx +74 -0
- package/src/auth/index.ts +4 -0
- package/src/configure.ts +19 -14
- package/src/confirmer.ts +112 -20
- package/src/generated/seatMapWidget.ts +3 -0
- package/src/index.tsx +1 -1
- package/src/sections/Accommodation.tsx +57 -0
- package/src/sections/AccountDeletion.tsx +347 -0
- package/src/sections/AccountEmailEnrichment.tsx +122 -0
- package/src/sections/AccountInformation.tsx +139 -0
- package/src/sections/AccountPaymentPlan.tsx +105 -0
- package/src/sections/AccountRewards.tsx +108 -0
- package/src/sections/AddOns.tsx +99 -148
- package/src/sections/BillingInfo.tsx +80 -78
- package/src/sections/Bundles.tsx +467 -0
- package/src/sections/EventDiscussionView.tsx +655 -0
- package/src/sections/NotificationPreferences.tsx +244 -0
- package/src/sections/OrderSummary.tsx +29 -2
- package/src/sections/Passes.tsx +177 -0
- package/src/sections/Payment.tsx +170 -53
- package/src/sections/PhoneAuthentication.tsx +371 -0
- package/src/sections/PostReaction.tsx +122 -0
- package/src/sections/ProfileSettings.tsx +446 -0
- package/src/sections/Rewards.tsx +320 -0
- package/src/sections/SeatMap.tsx +225 -0
- package/src/sections/SeatsIoMap.tsx +110 -0
- package/src/sections/Streaming.tsx +278 -0
- package/src/sections/TicketChange.tsx +379 -0
- package/src/sections/TicketGift.tsx +206 -0
- package/src/sections/TicketRefund.tsx +104 -0
- package/src/sections/Tickets.tsx +289 -208
- package/src/sections/Trips.tsx +87 -0
- package/src/sections/WaiverSigning.tsx +429 -0
- package/src/sections/billing/PhoneIdentity.tsx +169 -0
- package/src/sections/billing/TicketHoldersCard.tsx +4 -0
- package/src/sections/discussion/CommentImages.tsx +366 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +46 -0
- package/src/sections/rewards/RewardsLadder.tsx +167 -0
- package/src/sections/rewards/RewardsTree.tsx +117 -0
- package/src/storage.ts +8 -9
- package/src/ui/CheckoutProvider.tsx +51 -15
- package/src/ui/CustomFields.tsx +48 -12
- package/src/ui/Field.tsx +21 -3
- package/src/ui/Frozen.tsx +22 -0
- package/src/ui/InstallmentPicker.tsx +15 -18
- package/src/ui/PaymentMethods.tsx +6 -3
- package/src/ui/ProviderPages.tsx +214 -0
- package/src/ui/QuantitySelect.tsx +28 -3
- package/src/ui/Select.tsx +4 -1
- package/src/ui/SignaturePad.tsx +218 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/primitives.tsx +13 -2
- package/src/ui/useAccountT.ts +16 -0
- package/src/useCheckout.ts +3 -1
package/src/auth/AuthModal.tsx
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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={
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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({
|
|
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
|
+
})
|