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/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
|
+
}
|