tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.5
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 +7374 -288
- package/dist/index.d.ts +7374 -288
- package/dist/index.js +23316 -5980
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23461 -6058
- 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 +633 -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/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 +342 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +20 -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
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,108 @@
|
|
|
1
|
+
import React, { type FC } from 'react'
|
|
2
|
+
import { Alert, Image, Share, View } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
createTranslator,
|
|
5
|
+
hooks,
|
|
6
|
+
resolveLocale,
|
|
7
|
+
type AccountRewardsSectionProps,
|
|
8
|
+
} from 'tf-checkout-shared'
|
|
9
|
+
import {
|
|
10
|
+
BodyText,
|
|
11
|
+
Button,
|
|
12
|
+
Card,
|
|
13
|
+
ErrorText,
|
|
14
|
+
Loader,
|
|
15
|
+
SectionHeader,
|
|
16
|
+
} from '../ui/primitives'
|
|
17
|
+
|
|
18
|
+
/** The account aggregate has no order identity and offers no reward mutations. */
|
|
19
|
+
export const AccountRewardsSection: FC<AccountRewardsSectionProps> = ({
|
|
20
|
+
locale,
|
|
21
|
+
onBrowseEvents,
|
|
22
|
+
onViewTickets,
|
|
23
|
+
}) => {
|
|
24
|
+
const t = createTranslator({ locale: resolveLocale(locale) })
|
|
25
|
+
const { session, query, view } = hooks.useAccountRewardsSummary(t)
|
|
26
|
+
const assertOwner = hooks.useSessionAction()
|
|
27
|
+
const share = async (imageUrl: string) => {
|
|
28
|
+
try {
|
|
29
|
+
assertOwner()
|
|
30
|
+
await Share.share({ url: imageUrl, message: imageUrl })
|
|
31
|
+
} catch {
|
|
32
|
+
try {
|
|
33
|
+
assertOwner()
|
|
34
|
+
Alert.alert(t('rewards.account.shareError'))
|
|
35
|
+
} catch {
|
|
36
|
+
// A retired buyer must not display an alert in the next buyer's account.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
<View style={{ gap: 16 }}>
|
|
42
|
+
<SectionHeader
|
|
43
|
+
title={t('rewards.account.title')}
|
|
44
|
+
subtitle={t('rewards.account.intro')}
|
|
45
|
+
/>
|
|
46
|
+
{!session.isAuthenticated ? (
|
|
47
|
+
<BodyText>{t('rewards.account.signIn')}</BodyText>
|
|
48
|
+
) : (
|
|
49
|
+
<>
|
|
50
|
+
{query.isLoading && <Loader />}
|
|
51
|
+
{query.error && <ErrorText>{t('rewards.account.error')}</ErrorText>}
|
|
52
|
+
<Button
|
|
53
|
+
variant="ghost"
|
|
54
|
+
loading={query.isFetching}
|
|
55
|
+
onPress={() => void query.refetch()}
|
|
56
|
+
>
|
|
57
|
+
{t('rewards.account.refresh')}
|
|
58
|
+
</Button>
|
|
59
|
+
{view?.empty && <BodyText>{t('rewards.account.empty')}</BodyText>}
|
|
60
|
+
{view &&
|
|
61
|
+
(['ambassador', 'packs', 'prizes'] as const).map((kind) =>
|
|
62
|
+
view[kind].length ? (
|
|
63
|
+
<View key={kind} style={{ gap: 12 }}>
|
|
64
|
+
<SectionHeader title={t(`rewards.account.${kind}`)} />
|
|
65
|
+
{view[kind].map((row) => (
|
|
66
|
+
<Card key={row.key}>
|
|
67
|
+
<SectionHeader title={row.title} />
|
|
68
|
+
{row.lines.map((line, index) => (
|
|
69
|
+
<BodyText key={index}>{line}</BodyText>
|
|
70
|
+
))}
|
|
71
|
+
</Card>
|
|
72
|
+
))}
|
|
73
|
+
</View>
|
|
74
|
+
) : null
|
|
75
|
+
)}
|
|
76
|
+
{!!view?.shareCards.length && (
|
|
77
|
+
<View style={{ gap: 12 }}>
|
|
78
|
+
<SectionHeader title={t('rewards.account.shareCards')} />
|
|
79
|
+
{view.shareCards.map((card) => (
|
|
80
|
+
<Card key={card.key}>
|
|
81
|
+
<Image
|
|
82
|
+
source={{ uri: card.imageUrl }}
|
|
83
|
+
accessibilityLabel={t('rewards.account.shareCard')}
|
|
84
|
+
resizeMode="contain"
|
|
85
|
+
style={{ width: '100%', aspectRatio: 1.91 }}
|
|
86
|
+
/>
|
|
87
|
+
<Button variant="ghost" onPress={() => void share(card.imageUrl)}>
|
|
88
|
+
{t('rewards.account.share')}
|
|
89
|
+
</Button>
|
|
90
|
+
</Card>
|
|
91
|
+
))}
|
|
92
|
+
</View>
|
|
93
|
+
)}
|
|
94
|
+
{onViewTickets && (
|
|
95
|
+
<Button variant="ghost" onPress={onViewTickets}>
|
|
96
|
+
{t('rewards.account.viewTickets')}
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
{onBrowseEvents && (
|
|
100
|
+
<Button variant="ghost" onPress={onBrowseEvents}>
|
|
101
|
+
{t('rewards.account.browse')}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</View>
|
|
107
|
+
)
|
|
108
|
+
}
|
package/src/sections/AddOns.tsx
CHANGED
|
@@ -8,18 +8,18 @@
|
|
|
8
8
|
* Renders nothing when the event has no applicable add-ons (or they're all sold
|
|
9
9
|
* out), so it's safe to always place between `<Tickets/>` and `<BillingInfo/>`.
|
|
10
10
|
*/
|
|
11
|
-
import React, { Fragment,
|
|
11
|
+
import React, { Fragment, useMemo, type FC } from 'react'
|
|
12
12
|
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
addOnMaxQuantity,
|
|
16
16
|
errorMessage,
|
|
17
17
|
hooks,
|
|
18
|
-
|
|
19
|
-
type CustomFieldValues,
|
|
18
|
+
htmlToPlainText,
|
|
20
19
|
type EventAddon,
|
|
21
20
|
} from 'tf-checkout-shared'
|
|
22
21
|
|
|
22
|
+
import { FrozenFields } from '../ui/Frozen'
|
|
23
23
|
import { Button, Card, ErrorText, Loader, MutedText } from '../ui/primitives'
|
|
24
24
|
import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
|
|
25
25
|
// Created by a sibling agent; imported here per the port plan. Mirrors the web
|
|
@@ -28,24 +28,6 @@ import { CustomFieldGroupFields } from '../ui/CustomFields'
|
|
|
28
28
|
import { QuantitySelect } from '../ui/QuantitySelect'
|
|
29
29
|
import type { ResolvedTheme } from '../ui/theme'
|
|
30
30
|
|
|
31
|
-
// Web renders the add-on description via `dangerouslySetInnerHTML`. React Native
|
|
32
|
-
// has no HTML renderer, so we strip tags + decode a few common entities to show
|
|
33
|
-
// the description as plain text (the adaptation for native).
|
|
34
|
-
const stripHtml = (html: string): string =>
|
|
35
|
-
html
|
|
36
|
-
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
|
37
|
-
.replace(/<\/\s*(p|div|li|h[1-6])\s*>/gi, '\n')
|
|
38
|
-
.replace(/<[^>]+>/g, '')
|
|
39
|
-
.replace(/ /gi, ' ')
|
|
40
|
-
.replace(/&/gi, '&')
|
|
41
|
-
.replace(/</gi, '<')
|
|
42
|
-
.replace(/>/gi, '>')
|
|
43
|
-
.replace(/'|'/gi, "'")
|
|
44
|
-
.replace(/"/gi, '"')
|
|
45
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
46
|
-
.replace(/[ \t]+\n/g, '\n')
|
|
47
|
-
.trim()
|
|
48
|
-
|
|
49
31
|
const money = (currency: string, n: number): string =>
|
|
50
32
|
`${currency} ${Number(n).toFixed(2)}`
|
|
51
33
|
|
|
@@ -74,57 +56,27 @@ export const AddOns: FC<AddOnsProps> = ({
|
|
|
74
56
|
const checkout = useCheckoutContext()
|
|
75
57
|
const theme = useTheme()
|
|
76
58
|
const s = useAddonStyles(theme)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const [capture, setCapture] = useState<Record<string, CustomFieldValues>>(
|
|
93
|
-
checkout.addOnDataCapture ?? {}
|
|
94
|
-
)
|
|
95
|
-
const [error, setError] = useState('')
|
|
96
|
-
|
|
97
|
-
// Custom-field groups keyed by add-on id (scope 'add-on', group id = add-on id).
|
|
98
|
-
const fieldGroups = useMemo(() => {
|
|
99
|
-
const map: Record<string, (typeof checkout.customFields)[number]> = {}
|
|
100
|
-
for (const g of checkout.customFields) if (g.scope === 'add-on') map[g.id] = g
|
|
101
|
-
return map
|
|
102
|
-
}, [checkout.customFields])
|
|
103
|
-
|
|
104
|
-
// Combined mode: debounce-apply the selection to the cart (which reprices) as
|
|
105
|
-
// it changes, instead of gating behind a "confirm" button.
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
if (!combined) return
|
|
108
|
-
const selection = Object.fromEntries(Object.entries(qty).filter(([, n]) => n > 0))
|
|
109
|
-
if (JSON.stringify(selection) === JSON.stringify(checkout.addOns ?? {})) return
|
|
110
|
-
const captureForSelected = Object.fromEntries(
|
|
111
|
-
Object.entries(capture).filter(([id]) => (selection[id] ?? 0) > 0)
|
|
112
|
-
)
|
|
113
|
-
const t = setTimeout(() => {
|
|
114
|
-
checkout.setAddonDataCapture(captureForSelected)
|
|
115
|
-
checkout.setAddons(selection).catch((e) => setError(errorMessage(e)))
|
|
116
|
-
}, 500)
|
|
117
|
-
return () => clearTimeout(t)
|
|
118
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
-
}, [combined, qty, capture])
|
|
120
|
-
|
|
121
|
-
const hasRequired = (checkout.requiredAddOns ?? []).some((a) => a.required)
|
|
59
|
+
const {
|
|
60
|
+
query: addonsQuery,
|
|
61
|
+
addons,
|
|
62
|
+
qty,
|
|
63
|
+
setQty,
|
|
64
|
+
capture,
|
|
65
|
+
setCapture,
|
|
66
|
+
error,
|
|
67
|
+
fieldGroups,
|
|
68
|
+
combined,
|
|
69
|
+
ticketQuantity,
|
|
70
|
+
hasRequired,
|
|
71
|
+
save,
|
|
72
|
+
skip,
|
|
73
|
+
} = hooks.useAddOnSelection(eventId, checkout, { onContinue, onSkip })
|
|
122
74
|
const head = (
|
|
123
75
|
<View style={s.head}>
|
|
124
76
|
{eyebrow ? <Text style={s.eyebrow}>{eyebrow}</Text> : null}
|
|
125
77
|
<Text style={s.heading}>{heading}</Text>
|
|
126
78
|
{onSkip && !hasRequired ? (
|
|
127
|
-
<Pressable accessibilityRole="button" onPress={
|
|
79
|
+
<Pressable accessibilityRole="button" onPress={skip} style={s.skipBtn}>
|
|
128
80
|
<Text style={s.skip}>Skip</Text>
|
|
129
81
|
</Pressable>
|
|
130
82
|
) : null}
|
|
@@ -141,6 +93,14 @@ export const AddOns: FC<AddOnsProps> = ({
|
|
|
141
93
|
</Card>
|
|
142
94
|
)
|
|
143
95
|
}
|
|
96
|
+
if (addonsQuery.isError)
|
|
97
|
+
return (
|
|
98
|
+
<Card>
|
|
99
|
+
{head}
|
|
100
|
+
<ErrorText>{errorMessage(addonsQuery.error)}</ErrorText>
|
|
101
|
+
<Button onPress={() => void addonsQuery.refetch()}>Try again</Button>
|
|
102
|
+
</Card>
|
|
103
|
+
)
|
|
144
104
|
if (addons.length === 0) return null
|
|
145
105
|
// A catalog containing only unavailable rows is not a useful checkout step.
|
|
146
106
|
// Required sold-out add-ons are already exempted by the engine preflight.
|
|
@@ -160,95 +120,86 @@ export const AddOns: FC<AddOnsProps> = ({
|
|
|
160
120
|
}
|
|
161
121
|
|
|
162
122
|
const missing = checkout.missingRequiredAddonNames
|
|
163
|
-
const save = () => {
|
|
164
|
-
setError('')
|
|
165
|
-
const selection = Object.fromEntries(Object.entries(qty).filter(([, n]) => n > 0))
|
|
166
|
-
// Only keep captured fields for add-ons that are actually selected.
|
|
167
|
-
const captureForSelected = Object.fromEntries(
|
|
168
|
-
Object.entries(capture).filter(([id]) => (selection[id] ?? 0) > 0)
|
|
169
|
-
)
|
|
170
|
-
checkout.setAddonDataCapture(captureForSelected)
|
|
171
|
-
checkout
|
|
172
|
-
.setAddons(selection)
|
|
173
|
-
.then(() => onContinue?.())
|
|
174
|
-
.catch((e) => setError(errorMessage(e)))
|
|
175
|
-
}
|
|
176
123
|
|
|
177
124
|
const selectedCount = Object.values(qty).filter((n) => n > 0).length
|
|
178
125
|
|
|
179
126
|
return (
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
127
|
+
<FrozenFields frozen={checkout.orderFixed}>
|
|
128
|
+
<Card>
|
|
129
|
+
{head}
|
|
130
|
+
<Text style={s.subtitle}>PLEASE SELECT FROM THE OPTIONAL ADD-ONS BELOW</Text>
|
|
183
131
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
132
|
+
{groups.map((g, gi) => (
|
|
133
|
+
<Fragment key={g.heading ?? `g${gi}`}>
|
|
134
|
+
{g.heading ? <Text style={s.group}>{g.heading}</Text> : null}
|
|
135
|
+
{g.items.map((a) => {
|
|
136
|
+
const n = qty[a.id] ?? 0
|
|
137
|
+
const group = fieldGroups[a.id]
|
|
138
|
+
const desc = a.description ? htmlToPlainText(a.description) : ''
|
|
139
|
+
return (
|
|
140
|
+
<View key={a.id} style={s.item}>
|
|
141
|
+
<View style={s.row}>
|
|
142
|
+
<View style={s.info}>
|
|
143
|
+
<Text style={s.name}>
|
|
144
|
+
{a.name}
|
|
145
|
+
{a.required ? <Text style={s.req}> · required</Text> : null}
|
|
146
|
+
</Text>
|
|
147
|
+
<Text style={s.price}>
|
|
148
|
+
{money(a.currency, a.price)}
|
|
149
|
+
{a.feeIncluded ? (
|
|
150
|
+
<Text style={s.priceNote}> incl. fees</Text>
|
|
151
|
+
) : null}
|
|
152
|
+
</Text>
|
|
153
|
+
{desc ? <Text style={s.desc}>{desc}</Text> : null}
|
|
154
|
+
</View>
|
|
155
|
+
{soldOut(a) ? (
|
|
156
|
+
<Text style={s.soldOut}>Sold out</Text>
|
|
157
|
+
) : (
|
|
158
|
+
<QuantitySelect
|
|
159
|
+
value={n}
|
|
160
|
+
min={0}
|
|
161
|
+
max={max(a)}
|
|
162
|
+
onChange={(v) => setQty((q) => ({ ...q, [a.id]: v }))}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
206
165
|
</View>
|
|
207
|
-
{
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
166
|
+
{n > 0 && group ? (
|
|
167
|
+
<View style={s.fields}>
|
|
168
|
+
<CustomFieldGroupFields
|
|
169
|
+
group={group}
|
|
170
|
+
values={capture[a.id] ?? {}}
|
|
171
|
+
onChange={(name: string, val: string | string[]) =>
|
|
172
|
+
setCapture((c) => ({
|
|
173
|
+
...c,
|
|
174
|
+
[a.id]: { ...(c[a.id] ?? {}), [name]: val },
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
/>
|
|
178
|
+
</View>
|
|
179
|
+
) : null}
|
|
217
180
|
</View>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
values={capture[a.id] ?? {}}
|
|
223
|
-
onChange={(name: string, val: string | string[]) =>
|
|
224
|
-
setCapture((c) => ({
|
|
225
|
-
...c,
|
|
226
|
-
[a.id]: { ...(c[a.id] ?? {}), [name]: val },
|
|
227
|
-
}))
|
|
228
|
-
}
|
|
229
|
-
/>
|
|
230
|
-
</View>
|
|
231
|
-
) : null}
|
|
232
|
-
</View>
|
|
233
|
-
)
|
|
234
|
-
})}
|
|
235
|
-
</Fragment>
|
|
236
|
-
))}
|
|
181
|
+
)
|
|
182
|
+
})}
|
|
183
|
+
</Fragment>
|
|
184
|
+
))}
|
|
237
185
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
186
|
+
{!combined ? (
|
|
187
|
+
<Button
|
|
188
|
+
onPress={save}
|
|
189
|
+
loading={checkout.isBusy}
|
|
190
|
+
disabled={checkout.isBusy || selectedCount === 0}
|
|
191
|
+
>
|
|
192
|
+
{checkout.isBusy ? 'Updating…' : buttonLabel}
|
|
193
|
+
</Button>
|
|
194
|
+
) : null}
|
|
195
|
+
{missing.length > 0 ? (
|
|
196
|
+
<MutedText>Required add-on: {missing.join(', ')}.</MutedText>
|
|
197
|
+
) : null}
|
|
198
|
+
{error || checkout.error ? (
|
|
199
|
+
<ErrorText>{error || checkout.error}</ErrorText>
|
|
200
|
+
) : null}
|
|
201
|
+
</Card>
|
|
202
|
+
</FrozenFields>
|
|
252
203
|
)
|
|
253
204
|
}
|
|
254
205
|
|