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