tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.6
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 +92 -8
- package/dist/index.d.mts +7410 -290
- package/dist/index.d.ts +7410 -290
- package/dist/index.js +23539 -5909
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23561 -5857
- package/dist/index.mjs.map +1 -1
- package/package.json +22 -10
- package/scripts/build-seat-map.mjs +31 -0
- package/scripts/seat-map-entry.jsx +22 -0
- package/src/CardInput.tsx +64 -3
- package/src/EmbeddedCheckout.tsx +129 -36
- package/src/auth/AuthModal.tsx +90 -6
- 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 +8 -1
- 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/AccountRewards.tsx +108 -0
- package/src/sections/AddOns.tsx +99 -148
- package/src/sections/BillingInfo.tsx +63 -64
- package/src/sections/Bundles.tsx +467 -0
- package/src/sections/EventDiscussionView.tsx +637 -0
- package/src/sections/Passes.tsx +177 -0
- package/src/sections/Payment.tsx +169 -52
- package/src/sections/PostReaction.tsx +122 -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/discussion/CommentImages.tsx +366 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +26 -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 +25 -13
- 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 +134 -0
- package/src/ui/QuantitySelect.tsx +26 -2
- package/src/ui/Select.tsx +4 -1
- package/src/ui/SignaturePad.tsx +218 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/primitives.tsx +13 -2
- package/src/ui/useAccountT.ts +16 -0
- package/src/useCheckout.ts +3 -1
|
@@ -25,6 +25,16 @@ import React, {
|
|
|
25
25
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
|
+
billingDobIso,
|
|
29
|
+
changeBillingPhone,
|
|
30
|
+
changeBillingField,
|
|
31
|
+
billingDraftFrom,
|
|
32
|
+
billingFilledHolderIndexes,
|
|
33
|
+
holderDraftsForSlots,
|
|
34
|
+
holderDraftsFrom,
|
|
35
|
+
holderDraftsReloaded,
|
|
36
|
+
holderDraftsMirroringBuyer,
|
|
37
|
+
patchedHolderDraft,
|
|
28
38
|
hooks,
|
|
29
39
|
mapProfileToBilling,
|
|
30
40
|
missingCheckoutDetails,
|
|
@@ -43,9 +53,11 @@ import {
|
|
|
43
53
|
} from '../ui/CheckoutProvider'
|
|
44
54
|
import { CustomFieldInput } from '../ui/CustomFields'
|
|
45
55
|
import { Checkbox, Field, Input } from '../ui/Field'
|
|
56
|
+
import { FrozenFields } from '../ui/Frozen'
|
|
46
57
|
import { Button, Card, ErrorText, SectionHeader } from '../ui/primitives'
|
|
47
58
|
import type { ResolvedTheme } from '../ui/theme'
|
|
48
59
|
import { BillingAddressFields } from './billing/BillingAddressFields'
|
|
60
|
+
import { BillingPhoneIdentity } from './billing/PhoneIdentity'
|
|
49
61
|
import { BuyerField } from './billing/BuyerField'
|
|
50
62
|
import { TicketHoldersCard } from './billing/TicketHoldersCard'
|
|
51
63
|
import type { BillingDraft, HolderDraft } from './billing/types'
|
|
@@ -110,52 +122,42 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
110
122
|
const validation = useCheckoutValidation()
|
|
111
123
|
const theme = useTheme()
|
|
112
124
|
const s = useStyles(theme)
|
|
113
|
-
const profile = hooks.
|
|
125
|
+
const profile = hooks.useCheckoutBuyerProfile(eventId)
|
|
114
126
|
const event = hooks.useEvent(eventId)
|
|
115
127
|
// No onContinue ⇒ this is one combined form (inside <EmbeddedCheckout/>): mirror the
|
|
116
128
|
// fields into the engine live and drop the "continue" button, so the buyer
|
|
117
129
|
// fills billing + pays with ONE button — the way the old checkout flowed.
|
|
118
130
|
const combined = !onContinue
|
|
119
131
|
|
|
120
|
-
const [billing, setBilling] = useState<BillingDraft>(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
email: checkout.billing?.email ?? '',
|
|
124
|
-
confirmEmail: checkout.billing?.confirmEmail ?? '',
|
|
125
|
-
phone: checkout.billing?.phone,
|
|
126
|
-
streetAddress: checkout.billing?.streetAddress,
|
|
127
|
-
city: checkout.billing?.city,
|
|
128
|
-
zip: checkout.billing?.zip,
|
|
129
|
-
countryId: checkout.billing?.countryId,
|
|
130
|
-
stateId: checkout.billing?.stateId,
|
|
131
|
-
countryCode: checkout.billing?.countryCode,
|
|
132
|
-
stateCode: checkout.billing?.stateCode,
|
|
133
|
-
nationality: checkout.billing?.nationality,
|
|
134
|
-
gender: checkout.billing?.gender,
|
|
135
|
-
instagramHandle: checkout.billing?.instagramHandle,
|
|
136
|
-
})
|
|
132
|
+
const [billing, setBilling] = useState<BillingDraft>(() =>
|
|
133
|
+
billingDraftFrom(checkout.billing)
|
|
134
|
+
)
|
|
137
135
|
const [dataCapture, setDataCapture] = useState<CustomFieldValues>(
|
|
138
136
|
checkout.billing?.dataCapture ?? {}
|
|
139
137
|
)
|
|
140
138
|
const [holders, setHolders] = useState<HolderDraft[]>(() =>
|
|
141
|
-
checkout.holders.
|
|
142
|
-
firstName: holder.firstName,
|
|
143
|
-
lastName: holder.lastName,
|
|
144
|
-
email: holder.email,
|
|
145
|
-
phone: holder.phone ?? '',
|
|
146
|
-
ticketTypeId: holder.ticketTypeId,
|
|
147
|
-
ticketDataCapture: holder.ticketDataCapture ?? {},
|
|
148
|
-
}))
|
|
139
|
+
holderDraftsFrom(checkout.holders, checkout.backfilledHolderIndexes)
|
|
149
140
|
)
|
|
150
141
|
const [consent, setConsent] = useState({ ttfOptIn: false, brandOptIn: false })
|
|
151
|
-
const [dob, setDob] = useState(() =>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
142
|
+
const [dob, setDob] = useState(() => billingDobIso(checkout.billing)) // ISO yyyy-mm-dd
|
|
143
|
+
// A placement that ended without a charge: show what a retry submits.
|
|
144
|
+
hooks.useOnOrderReleased(checkout.orderFixed, () => {
|
|
145
|
+
setBilling(billingDraftFrom(checkout.billing))
|
|
146
|
+
setDataCapture(checkout.billing?.dataCapture ?? {})
|
|
147
|
+
setDob(billingDobIso(checkout.billing))
|
|
148
|
+
setConsent(checkout.consent)
|
|
149
|
+
setHolders((prev) =>
|
|
150
|
+
holderDraftsReloaded(prev, checkout.holders, checkout.backfilledHolderIndexes)
|
|
151
|
+
)
|
|
152
|
+
})
|
|
158
153
|
const restoredHolders = useRef(checkout.holders)
|
|
154
|
+
// Another event: its holders and custom answers start empty; the buyer's
|
|
155
|
+
// own details stay.
|
|
156
|
+
hooks.useOnEventChange(checkout.eventId, () => {
|
|
157
|
+
restoredHolders.current = []
|
|
158
|
+
setHolders([])
|
|
159
|
+
setDataCapture({})
|
|
160
|
+
})
|
|
159
161
|
const profileSeeded = useRef(false)
|
|
160
162
|
const countryDefaultApplied = useRef(Boolean(checkout.billing?.countryId))
|
|
161
163
|
|
|
@@ -233,38 +235,21 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
233
235
|
|
|
234
236
|
// Resize holders to the slot count, preserving typed values; tag ticket type.
|
|
235
237
|
useEffect(() => {
|
|
236
|
-
setHolders((prev) =>
|
|
237
|
-
slots.map((slot, i) => ({
|
|
238
|
-
firstName: prev[i]?.firstName ?? restoredHolders.current[i]?.firstName ?? '',
|
|
239
|
-
lastName: prev[i]?.lastName ?? restoredHolders.current[i]?.lastName ?? '',
|
|
240
|
-
email: prev[i]?.email ?? restoredHolders.current[i]?.email ?? '',
|
|
241
|
-
phone: prev[i]?.phone ?? restoredHolders.current[i]?.phone ?? '',
|
|
242
|
-
ticketDataCapture:
|
|
243
|
-
prev[i]?.ticketDataCapture ??
|
|
244
|
-
restoredHolders.current[i]?.ticketDataCapture ??
|
|
245
|
-
{},
|
|
246
|
-
ticketTypeId: slot.ticketTypeId,
|
|
247
|
-
ticketName: slot.ticketName,
|
|
248
|
-
}))
|
|
249
|
-
)
|
|
238
|
+
setHolders((prev) => holderDraftsForSlots(prev, slots, restoredHolders.current))
|
|
250
239
|
}, [slots])
|
|
251
240
|
|
|
252
241
|
// Match the storefront: ticket 1 silently starts as the buyer. The legacy
|
|
253
242
|
// checkout has no extra "use buyer details" control.
|
|
254
243
|
useEffect(() => {
|
|
255
244
|
if (!showHolders) return
|
|
245
|
+
// Ticket 1 stops copying the buyer once the buyer edits it.
|
|
256
246
|
setHolders((prev) =>
|
|
257
|
-
prev
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
email: billing.email,
|
|
264
|
-
phone: billing.phone ?? '',
|
|
265
|
-
}
|
|
266
|
-
: holder
|
|
267
|
-
)
|
|
247
|
+
holderDraftsMirroringBuyer(prev, {
|
|
248
|
+
firstName: billing.firstName,
|
|
249
|
+
lastName: billing.lastName,
|
|
250
|
+
email: billing.email,
|
|
251
|
+
phone: billing.phone,
|
|
252
|
+
})
|
|
268
253
|
)
|
|
269
254
|
}, [showHolders, billing.firstName, billing.lastName, billing.email, billing.phone])
|
|
270
255
|
|
|
@@ -357,10 +342,10 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
357
342
|
}, [billing.stateId, billing.stateCode, states])
|
|
358
343
|
|
|
359
344
|
const setB = (k: keyof BillingDraft) => (value: string) =>
|
|
360
|
-
setBilling((b) => (
|
|
345
|
+
setBilling((b) => changeBillingField(b, k, value))
|
|
361
346
|
|
|
362
347
|
const patchHolder = (i: number, patch: Partial<HolderDraft>) =>
|
|
363
|
-
setHolders((hs) => hs.map((h, j) => (j === i ?
|
|
348
|
+
setHolders((hs) => hs.map((h, j) => (j === i ? patchedHolderDraft(h, patch) : h)))
|
|
364
349
|
|
|
365
350
|
const [dobYear, dobMonth, dobDay] = dob ? dob.split('-').map(Number) : []
|
|
366
351
|
const committedBilling: CheckoutBilling = {
|
|
@@ -413,7 +398,11 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
413
398
|
|
|
414
399
|
const commit = () => {
|
|
415
400
|
commitBilling(committedBilling)
|
|
416
|
-
|
|
401
|
+
// Holders that take the buyer's details are marked, so a guardian's
|
|
402
|
+
// signature for a child counts on them (billingFilledHolderIndexes).
|
|
403
|
+
commitHolders(committedHolders, {
|
|
404
|
+
backfilled: billingFilledHolderIndexes(holders, { holderNamesRequired }),
|
|
405
|
+
})
|
|
417
406
|
if (ttfOptInLabel || brandOptInLabel) commitConsent(consent)
|
|
418
407
|
}
|
|
419
408
|
|
|
@@ -478,7 +467,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
478
467
|
const privacyInvalid = (showPrivacyError || showValidation) && !consent.ttfOptIn
|
|
479
468
|
|
|
480
469
|
return (
|
|
481
|
-
|
|
470
|
+
<FrozenFields frozen={checkout.orderFixed}>
|
|
482
471
|
<Card>
|
|
483
472
|
<SectionHeader title={heading} />
|
|
484
473
|
|
|
@@ -491,6 +480,14 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
491
480
|
</View>
|
|
492
481
|
) : null}
|
|
493
482
|
|
|
483
|
+
{gates.phoneFirstCheckout && !checkout.isLoggedIn ? (
|
|
484
|
+
<BillingPhoneIdentity
|
|
485
|
+
billing={billing}
|
|
486
|
+
setBilling={setBilling}
|
|
487
|
+
eventId={eventId ?? checkout.eventId ?? undefined}
|
|
488
|
+
whatsappAvailable={Boolean(gates.phoneFirstWhatsapp)}
|
|
489
|
+
/>
|
|
490
|
+
) : null}
|
|
494
491
|
<View style={s.row}>
|
|
495
492
|
<View style={s.col}>
|
|
496
493
|
<Field error={fieldError('First name', 'First name is required.')}>
|
|
@@ -598,7 +595,9 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
598
595
|
<Input
|
|
599
596
|
placeholder="Phone number"
|
|
600
597
|
value={billing.phone ?? ''}
|
|
601
|
-
onChangeText={(v) =>
|
|
598
|
+
onChangeText={(v) =>
|
|
599
|
+
setBilling((current) => changeBillingPhone(current, v))
|
|
600
|
+
}
|
|
602
601
|
keyboardType="phone-pad"
|
|
603
602
|
invalid={Boolean(fieldError('Valid phone number'))}
|
|
604
603
|
/>
|
|
@@ -730,7 +729,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
730
729
|
}
|
|
731
730
|
/>
|
|
732
731
|
) : null}
|
|
733
|
-
|
|
732
|
+
</FrozenFields>
|
|
734
733
|
)
|
|
735
734
|
}
|
|
736
735
|
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Bundles/>` — the RN analogue of the web section: the event's bundles,
|
|
3
|
+
* bought on their own order. Choose bundles, enter your details (and, when
|
|
4
|
+
* offered, a name on each ticket), pay by card, see the confirmation.
|
|
5
|
+
* Everything the flow decides is in the shared core (`hooks.useBundleFlow`);
|
|
6
|
+
* this file only renders it.
|
|
7
|
+
*
|
|
8
|
+
* At most one native card field exists at a time: the bundle card field is
|
|
9
|
+
* mounted only while this section is visible.
|
|
10
|
+
*
|
|
11
|
+
* Renders nothing when the event sells no bundles. Translated through the
|
|
12
|
+
* shared translator; `locale`, `messages` or `translate` override it.
|
|
13
|
+
*/
|
|
14
|
+
import React, { useMemo, type FC } from 'react'
|
|
15
|
+
import { StyleSheet, Text, View } from 'react-native'
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
bundleAvailabilityCopy,
|
|
19
|
+
bundleContentsLine,
|
|
20
|
+
htmlToPlainText,
|
|
21
|
+
bundleQuantityRange,
|
|
22
|
+
createTranslator,
|
|
23
|
+
hooks,
|
|
24
|
+
type LocaleCode,
|
|
25
|
+
type Messages,
|
|
26
|
+
type PlaceOrderResult,
|
|
27
|
+
type Translate,
|
|
28
|
+
} from 'tf-checkout-shared'
|
|
29
|
+
|
|
30
|
+
import { CardInput, claimCardField } from '../CardInput'
|
|
31
|
+
import { rnConfirmer } from '../instance'
|
|
32
|
+
import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
|
|
33
|
+
import { Field, Input } from '../ui/Field'
|
|
34
|
+
import { Button, Card, ErrorText, Loader } from '../ui/primitives'
|
|
35
|
+
import { QuantitySelect } from '../ui/QuantitySelect'
|
|
36
|
+
import type { ResolvedTheme } from '../ui/theme'
|
|
37
|
+
|
|
38
|
+
const money = (currency: string, amount: number) => `${currency} ${amount.toFixed(2)}`
|
|
39
|
+
|
|
40
|
+
export interface BundlesProps {
|
|
41
|
+
eventId: string
|
|
42
|
+
/** Fired once when a bundle purchase is complete, with its order hash. */
|
|
43
|
+
onPurchased?: (orderHash: string) => void
|
|
44
|
+
/**
|
|
45
|
+
* Fired once when a bundle purchase is complete, in the same shape as a
|
|
46
|
+
* ticket order, so the host routes both the same way.
|
|
47
|
+
*/
|
|
48
|
+
onComplete?: (order: PlaceOrderResult) => void
|
|
49
|
+
/** `redirect`: skip the inline receipt while the host navigates. */
|
|
50
|
+
confirmationMode?: 'inline' | 'redirect'
|
|
51
|
+
/** False while the section is hidden: no card field is mounted then. */
|
|
52
|
+
visible?: boolean
|
|
53
|
+
/** Section heading. Defaults to the translated "Bundles". */
|
|
54
|
+
heading?: string
|
|
55
|
+
locale?: LocaleCode
|
|
56
|
+
messages?: Partial<Messages>
|
|
57
|
+
translate?: Translate
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const Bundles: FC<BundlesProps> = ({
|
|
61
|
+
eventId,
|
|
62
|
+
onPurchased,
|
|
63
|
+
onComplete,
|
|
64
|
+
confirmationMode = 'inline',
|
|
65
|
+
visible = true,
|
|
66
|
+
heading,
|
|
67
|
+
locale,
|
|
68
|
+
messages,
|
|
69
|
+
translate,
|
|
70
|
+
}) => {
|
|
71
|
+
const checkout = useCheckoutContext()
|
|
72
|
+
const theme = useTheme()
|
|
73
|
+
const s = useBundleStyles(theme)
|
|
74
|
+
const t = translate ?? createTranslator({ locale, messages })
|
|
75
|
+
const flow = hooks.useBundleFlow(rnConfirmer, eventId, {
|
|
76
|
+
claimCard: claimCardField,
|
|
77
|
+
defaults: {
|
|
78
|
+
firstName: checkout.billing?.firstName ?? '',
|
|
79
|
+
lastName: checkout.billing?.lastName ?? '',
|
|
80
|
+
email: checkout.billing?.email ?? '',
|
|
81
|
+
},
|
|
82
|
+
store: checkout.store,
|
|
83
|
+
visible,
|
|
84
|
+
confirmationMode,
|
|
85
|
+
onComplete,
|
|
86
|
+
onPurchased,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (flow.view === 'loading') {
|
|
90
|
+
return (
|
|
91
|
+
<Card>
|
|
92
|
+
<View style={s.loader}>
|
|
93
|
+
<Loader />
|
|
94
|
+
</View>
|
|
95
|
+
</Card>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
if (flow.view === 'catalogError') {
|
|
99
|
+
return (
|
|
100
|
+
<Card>
|
|
101
|
+
<ErrorText>{t('bundles.error.catalog')}</ErrorText>
|
|
102
|
+
<Button
|
|
103
|
+
variant="ghost"
|
|
104
|
+
loading={flow.catalogRetrying}
|
|
105
|
+
disabled={flow.catalogRetrying}
|
|
106
|
+
onPress={flow.retryCatalog}
|
|
107
|
+
>
|
|
108
|
+
{t('bundles.retryCard')}
|
|
109
|
+
</Button>
|
|
110
|
+
</Card>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
// No bundles to sell and no purchase to show.
|
|
114
|
+
if (flow.view === 'hidden') return null
|
|
115
|
+
|
|
116
|
+
const title = <Text style={s.heading}>{heading ?? t('bundles.heading')}</Text>
|
|
117
|
+
|
|
118
|
+
if (flow.view === 'redirecting') {
|
|
119
|
+
return (
|
|
120
|
+
<Card>
|
|
121
|
+
{title}
|
|
122
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
123
|
+
{t('bundles.redirecting')}
|
|
124
|
+
</Text>
|
|
125
|
+
<Loader />
|
|
126
|
+
</Card>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
if (flow.view === 'receipt' && flow.result) {
|
|
130
|
+
const { result } = flow
|
|
131
|
+
return (
|
|
132
|
+
<Card>
|
|
133
|
+
{title}
|
|
134
|
+
<Text style={s.strong}>{t('bundles.done.title')}</Text>
|
|
135
|
+
<Text style={s.muted}>
|
|
136
|
+
{result.pending
|
|
137
|
+
? t('bundles.done.pending')
|
|
138
|
+
: result.email
|
|
139
|
+
? t('bundles.done.email', { email: result.email })
|
|
140
|
+
: t('bundles.done.emailUnknown')}
|
|
141
|
+
</Text>
|
|
142
|
+
<Text style={s.muted}>
|
|
143
|
+
{t('bundles.done.order', { orderHash: result.orderHash })}
|
|
144
|
+
</Text>
|
|
145
|
+
{flow.gatewayWait ? (
|
|
146
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
147
|
+
{t(flow.gatewayWait)}
|
|
148
|
+
</Text>
|
|
149
|
+
) : null}
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
disabled={flow.gatewayBlocked}
|
|
153
|
+
onPress={() => void flow.again()}
|
|
154
|
+
>
|
|
155
|
+
{t('bundles.done.again')}
|
|
156
|
+
</Button>
|
|
157
|
+
</Card>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const anySelected = Object.values(flow.qty).some((n) => n > 0)
|
|
162
|
+
|
|
163
|
+
if (flow.view === 'details') {
|
|
164
|
+
const amount = money(flow.payAmount.currency || flow.currency, flow.payAmount.amount)
|
|
165
|
+
return (
|
|
166
|
+
<Card>
|
|
167
|
+
{title}
|
|
168
|
+
{flow.notice ? (
|
|
169
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
170
|
+
{t(flow.notice.key, flow.notice.values)}
|
|
171
|
+
</Text>
|
|
172
|
+
) : null}
|
|
173
|
+
<Text style={s.strong}>{t('bundles.buyerHeading')}</Text>
|
|
174
|
+
<Field label={t('bundles.firstName')}>
|
|
175
|
+
<Input
|
|
176
|
+
value={flow.buyer.firstName}
|
|
177
|
+
autoCapitalize="words"
|
|
178
|
+
editable={!flow.busy}
|
|
179
|
+
onChangeText={(firstName) => flow.patchBuyer({ firstName })}
|
|
180
|
+
/>
|
|
181
|
+
</Field>
|
|
182
|
+
<Field label={t('bundles.lastName')}>
|
|
183
|
+
<Input
|
|
184
|
+
value={flow.buyer.lastName}
|
|
185
|
+
autoCapitalize="words"
|
|
186
|
+
editable={!flow.busy}
|
|
187
|
+
onChangeText={(lastName) => flow.patchBuyer({ lastName })}
|
|
188
|
+
/>
|
|
189
|
+
</Field>
|
|
190
|
+
<Field label={t('bundles.email')}>
|
|
191
|
+
<Input
|
|
192
|
+
value={flow.buyer.email}
|
|
193
|
+
keyboardType="email-address"
|
|
194
|
+
autoCapitalize="none"
|
|
195
|
+
editable={!flow.busy}
|
|
196
|
+
onChangeText={(email) => flow.patchBuyer({ email })}
|
|
197
|
+
/>
|
|
198
|
+
</Field>
|
|
199
|
+
|
|
200
|
+
{flow.slots.length > 0 ? (
|
|
201
|
+
<>
|
|
202
|
+
<Text style={s.strong}>{t('bundles.holdersHeading')}</Text>
|
|
203
|
+
{!flow.namesRequired ? (
|
|
204
|
+
<Text style={s.muted}>{t('bundles.holdersOptional')}</Text>
|
|
205
|
+
) : null}
|
|
206
|
+
{flow.slots.map((slot) => {
|
|
207
|
+
const value = flow.holderValue(slot)
|
|
208
|
+
const label = t('bundles.holderLabel', {
|
|
209
|
+
ticket: slot.ticketName,
|
|
210
|
+
number: String(slot.ticketIndex + 1),
|
|
211
|
+
})
|
|
212
|
+
return (
|
|
213
|
+
<View key={`${slot.bundleId}-${slot.ticketIndex}`}>
|
|
214
|
+
<Field label={`${label} · ${t('bundles.holderFirstName')}`}>
|
|
215
|
+
<Input
|
|
216
|
+
value={value?.firstName ?? ''}
|
|
217
|
+
autoCapitalize="words"
|
|
218
|
+
editable={!flow.busy}
|
|
219
|
+
onChangeText={(firstName) => flow.setHolder(slot, { firstName })}
|
|
220
|
+
/>
|
|
221
|
+
</Field>
|
|
222
|
+
<Field label={`${label} · ${t('bundles.holderLastName')}`}>
|
|
223
|
+
<Input
|
|
224
|
+
value={value?.lastName ?? ''}
|
|
225
|
+
autoCapitalize="words"
|
|
226
|
+
editable={!flow.busy}
|
|
227
|
+
onChangeText={(lastName) => flow.setHolder(slot, { lastName })}
|
|
228
|
+
/>
|
|
229
|
+
</Field>
|
|
230
|
+
</View>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</>
|
|
234
|
+
) : null}
|
|
235
|
+
{flow.problems.map((key) => (
|
|
236
|
+
<ErrorText key={key}>{t(key)}</ErrorText>
|
|
237
|
+
))}
|
|
238
|
+
|
|
239
|
+
{flow.awaitingCard ? (
|
|
240
|
+
<View style={s.card}>
|
|
241
|
+
<Text style={s.strong}>{t('bundles.cardHeading')}</Text>
|
|
242
|
+
{flow.cardElsewhere ? (
|
|
243
|
+
<>
|
|
244
|
+
<Text style={s.muted}>{t('bundles.payingElsewhere')}</Text>
|
|
245
|
+
<Button variant="ghost" onPress={flow.payHere}>
|
|
246
|
+
{t('bundles.payHere')}
|
|
247
|
+
</Button>
|
|
248
|
+
</>
|
|
249
|
+
) : flow.cardFailed ? (
|
|
250
|
+
<Button variant="ghost" onPress={flow.retryCard}>
|
|
251
|
+
{t('bundles.retryCard')}
|
|
252
|
+
</Button>
|
|
253
|
+
) : flow.cardReady && visible ? (
|
|
254
|
+
<CardInput owner={flow.cardOwner} visible={visible} />
|
|
255
|
+
) : (
|
|
256
|
+
<Loader />
|
|
257
|
+
)}
|
|
258
|
+
</View>
|
|
259
|
+
) : null}
|
|
260
|
+
|
|
261
|
+
{flow.stillSaving ? (
|
|
262
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
263
|
+
{t('common.stillSaving')}
|
|
264
|
+
</Text>
|
|
265
|
+
) : null}
|
|
266
|
+
{flow.checkingAttempt ? (
|
|
267
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
268
|
+
{t('bundles.checkingAttempt')}
|
|
269
|
+
</Text>
|
|
270
|
+
) : null}
|
|
271
|
+
{flow.waitingForTickets ? (
|
|
272
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
273
|
+
{t('bundles.waitTickets')}
|
|
274
|
+
</Text>
|
|
275
|
+
) : null}
|
|
276
|
+
{flow.waitingForBundle ? (
|
|
277
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
278
|
+
{t(flow.gatewayWait ?? 'bundles.waitOtherBundle')}
|
|
279
|
+
</Text>
|
|
280
|
+
) : null}
|
|
281
|
+
<Button
|
|
282
|
+
onPress={() => void flow.submit()}
|
|
283
|
+
loading={flow.busy}
|
|
284
|
+
disabled={flow.submitDisabled}
|
|
285
|
+
>
|
|
286
|
+
{flow.paying
|
|
287
|
+
? t('bundles.paying')
|
|
288
|
+
: flow.awaitingCard
|
|
289
|
+
? t('bundles.pay', { amount })
|
|
290
|
+
: t('bundles.continue')}
|
|
291
|
+
</Button>
|
|
292
|
+
<Button
|
|
293
|
+
variant="ghost"
|
|
294
|
+
disabled={flow.busy || flow.gatewayBlocked}
|
|
295
|
+
onPress={() => void flow.back()}
|
|
296
|
+
>
|
|
297
|
+
{t('bundles.back')}
|
|
298
|
+
</Button>
|
|
299
|
+
{flow.error ? <ErrorText>{t(flow.error)}</ErrorText> : null}
|
|
300
|
+
{flow.orderInProgress ? (
|
|
301
|
+
<Button variant="ghost" onPress={flow.continueOrder}>
|
|
302
|
+
{t('bundles.continueOrder')}
|
|
303
|
+
</Button>
|
|
304
|
+
) : null}
|
|
305
|
+
{flow.canStartOver ? (
|
|
306
|
+
<Button
|
|
307
|
+
variant="ghost"
|
|
308
|
+
disabled={flow.gatewayBlocked}
|
|
309
|
+
onPress={() => void flow.startOver()}
|
|
310
|
+
>
|
|
311
|
+
{t('bundles.startOver')}
|
|
312
|
+
</Button>
|
|
313
|
+
) : null}
|
|
314
|
+
</Card>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const ticketsInCart = checkout.selections.length > 0
|
|
319
|
+
return (
|
|
320
|
+
<Card>
|
|
321
|
+
{title}
|
|
322
|
+
<Text style={s.muted}>{t('bundles.subtitle')}</Text>
|
|
323
|
+
{flow.notice ? (
|
|
324
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
325
|
+
{t(flow.notice.key, flow.notice.values)}
|
|
326
|
+
</Text>
|
|
327
|
+
) : null}
|
|
328
|
+
{flow.view === 'heldOrder' ? (
|
|
329
|
+
<>
|
|
330
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
331
|
+
{t('bundles.heldOrder')}
|
|
332
|
+
</Text>
|
|
333
|
+
{flow.error ? <ErrorText>{t(flow.error)}</ErrorText> : null}
|
|
334
|
+
{flow.checkingAttempt ? (
|
|
335
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
336
|
+
{t('bundles.checkingAttempt')}
|
|
337
|
+
</Text>
|
|
338
|
+
) : null}
|
|
339
|
+
{flow.gatewayWait ? (
|
|
340
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
341
|
+
{t(flow.gatewayWait)}
|
|
342
|
+
</Text>
|
|
343
|
+
) : null}
|
|
344
|
+
<Button
|
|
345
|
+
disabled={flow.busy || flow.checkingAttempt}
|
|
346
|
+
onPress={flow.continueOrder}
|
|
347
|
+
>
|
|
348
|
+
{t('bundles.continueOrder')}
|
|
349
|
+
</Button>
|
|
350
|
+
<Button
|
|
351
|
+
variant="ghost"
|
|
352
|
+
disabled={flow.busy || flow.checkingAttempt || flow.gatewayBlocked}
|
|
353
|
+
onPress={() => void flow.startOver()}
|
|
354
|
+
>
|
|
355
|
+
{t('bundles.startOver')}
|
|
356
|
+
</Button>
|
|
357
|
+
</>
|
|
358
|
+
) : null}
|
|
359
|
+
|
|
360
|
+
{flow.bundles.map((bundle) => {
|
|
361
|
+
const range = bundleQuantityRange(bundle)
|
|
362
|
+
const stock = bundleAvailabilityCopy(bundle)
|
|
363
|
+
const contents = bundleContentsLine(bundle)
|
|
364
|
+
const desc = htmlToPlainText(bundle.description ?? '')
|
|
365
|
+
return (
|
|
366
|
+
<View key={bundle.id} style={[s.item, { borderTopColor: theme.border }]}>
|
|
367
|
+
<View style={s.info}>
|
|
368
|
+
<Text style={s.name}>{bundle.name}</Text>
|
|
369
|
+
<Text style={s.price}>{money(bundle.currency, bundle.price)}</Text>
|
|
370
|
+
{contents ? (
|
|
371
|
+
<Text style={s.muted}>{t('bundles.includes', { contents })}</Text>
|
|
372
|
+
) : null}
|
|
373
|
+
{bundle.bookingFee > 0 && (
|
|
374
|
+
<Text style={s.muted}>
|
|
375
|
+
{t('bundles.bookingFee', {
|
|
376
|
+
amount: money(bundle.currency, bundle.bookingFee),
|
|
377
|
+
})}
|
|
378
|
+
</Text>
|
|
379
|
+
)}
|
|
380
|
+
{desc ? <Text style={s.muted}>{desc}</Text> : null}
|
|
381
|
+
{stock && stock.key !== 'bundles.soldOut' ? (
|
|
382
|
+
<Text style={[s.muted, s.stock]}>{t(stock.key, stock.values)}</Text>
|
|
383
|
+
) : null}
|
|
384
|
+
</View>
|
|
385
|
+
{range.max === 0 ? (
|
|
386
|
+
<Text style={s.soldOut}>{t('bundles.soldOut')}</Text>
|
|
387
|
+
) : (
|
|
388
|
+
<QuantitySelect
|
|
389
|
+
label={t('bundles.quantity', { name: bundle.name })}
|
|
390
|
+
value={flow.qty[bundle.id] ?? 0}
|
|
391
|
+
min={range.min}
|
|
392
|
+
max={range.max}
|
|
393
|
+
onChange={(v) => flow.setQuantity(bundle.id, v)}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
</View>
|
|
397
|
+
)
|
|
398
|
+
})}
|
|
399
|
+
|
|
400
|
+
{anySelected ? (
|
|
401
|
+
<Text style={s.strong}>
|
|
402
|
+
{t('bundles.total', { amount: money(flow.currency, flow.total) })}
|
|
403
|
+
</Text>
|
|
404
|
+
) : null}
|
|
405
|
+
{anySelected && ticketsInCart ? (
|
|
406
|
+
<Text style={s.muted}>{t('bundles.boughtSeparately')}</Text>
|
|
407
|
+
) : null}
|
|
408
|
+
<Button onPress={flow.continueToDetails} disabled={!anySelected}>
|
|
409
|
+
{t('bundles.continue')}
|
|
410
|
+
</Button>
|
|
411
|
+
</Card>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const useBundleStyles = (theme: ResolvedTheme) =>
|
|
416
|
+
useMemo(
|
|
417
|
+
() =>
|
|
418
|
+
StyleSheet.create({
|
|
419
|
+
loader: { paddingVertical: 20, alignItems: 'center' },
|
|
420
|
+
heading: {
|
|
421
|
+
color: theme.text,
|
|
422
|
+
fontSize: theme.fontSize + 2,
|
|
423
|
+
fontWeight: '700',
|
|
424
|
+
fontFamily: theme.fontFamily,
|
|
425
|
+
},
|
|
426
|
+
strong: {
|
|
427
|
+
color: theme.text,
|
|
428
|
+
fontSize: theme.fontSize,
|
|
429
|
+
fontWeight: '700',
|
|
430
|
+
fontFamily: theme.fontFamily,
|
|
431
|
+
},
|
|
432
|
+
muted: {
|
|
433
|
+
color: theme.muted,
|
|
434
|
+
fontSize: theme.fontSize - 1,
|
|
435
|
+
fontFamily: theme.fontFamily,
|
|
436
|
+
},
|
|
437
|
+
stock: { fontWeight: '600' },
|
|
438
|
+
card: { gap: 8 },
|
|
439
|
+
item: {
|
|
440
|
+
flexDirection: 'row',
|
|
441
|
+
alignItems: 'flex-start',
|
|
442
|
+
gap: 12,
|
|
443
|
+
paddingTop: 12,
|
|
444
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
445
|
+
},
|
|
446
|
+
info: { flex: 1, gap: 2 },
|
|
447
|
+
name: {
|
|
448
|
+
color: theme.text,
|
|
449
|
+
fontSize: theme.fontSize,
|
|
450
|
+
fontWeight: '600',
|
|
451
|
+
fontFamily: theme.fontFamily,
|
|
452
|
+
},
|
|
453
|
+
price: {
|
|
454
|
+
color: theme.text,
|
|
455
|
+
fontSize: theme.fontSize - 1,
|
|
456
|
+
fontVariant: ['tabular-nums'],
|
|
457
|
+
fontFamily: theme.fontFamily,
|
|
458
|
+
},
|
|
459
|
+
soldOut: {
|
|
460
|
+
color: theme.muted,
|
|
461
|
+
fontSize: theme.fontSize - 1,
|
|
462
|
+
fontWeight: '600',
|
|
463
|
+
fontFamily: theme.fontFamily,
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
[theme]
|
|
467
|
+
)
|