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.
Files changed (60) hide show
  1. package/README.md +92 -8
  2. package/dist/index.d.mts +7410 -290
  3. package/dist/index.d.ts +7410 -290
  4. package/dist/index.js +23539 -5909
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23561 -5857
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +22 -10
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/src/CardInput.tsx +64 -3
  12. package/src/EmbeddedCheckout.tsx +129 -36
  13. package/src/auth/AuthModal.tsx +90 -6
  14. package/src/auth/EmailCodeForm.tsx +91 -0
  15. package/src/auth/RegisterForm.tsx +91 -5
  16. package/src/auth/TwoFactorForm.tsx +74 -0
  17. package/src/auth/index.ts +4 -0
  18. package/src/configure.ts +8 -1
  19. package/src/confirmer.ts +112 -20
  20. package/src/generated/seatMapWidget.ts +3 -0
  21. package/src/index.tsx +1 -1
  22. package/src/sections/Accommodation.tsx +57 -0
  23. package/src/sections/AccountRewards.tsx +108 -0
  24. package/src/sections/AddOns.tsx +99 -148
  25. package/src/sections/BillingInfo.tsx +63 -64
  26. package/src/sections/Bundles.tsx +467 -0
  27. package/src/sections/EventDiscussionView.tsx +637 -0
  28. package/src/sections/Passes.tsx +177 -0
  29. package/src/sections/Payment.tsx +169 -52
  30. package/src/sections/PostReaction.tsx +122 -0
  31. package/src/sections/Rewards.tsx +320 -0
  32. package/src/sections/SeatMap.tsx +225 -0
  33. package/src/sections/SeatsIoMap.tsx +110 -0
  34. package/src/sections/Streaming.tsx +278 -0
  35. package/src/sections/TicketChange.tsx +379 -0
  36. package/src/sections/TicketGift.tsx +206 -0
  37. package/src/sections/TicketRefund.tsx +104 -0
  38. package/src/sections/Tickets.tsx +289 -208
  39. package/src/sections/Trips.tsx +87 -0
  40. package/src/sections/WaiverSigning.tsx +429 -0
  41. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  42. package/src/sections/discussion/CommentImages.tsx +366 -0
  43. package/src/sections/discussion/SwipeReply.tsx +88 -0
  44. package/src/sections/index.ts +26 -0
  45. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  46. package/src/sections/rewards/RewardsTree.tsx +117 -0
  47. package/src/storage.ts +8 -9
  48. package/src/ui/CheckoutProvider.tsx +25 -13
  49. package/src/ui/Field.tsx +21 -3
  50. package/src/ui/Frozen.tsx +22 -0
  51. package/src/ui/InstallmentPicker.tsx +15 -18
  52. package/src/ui/PaymentMethods.tsx +6 -3
  53. package/src/ui/ProviderPages.tsx +134 -0
  54. package/src/ui/QuantitySelect.tsx +26 -2
  55. package/src/ui/Select.tsx +4 -1
  56. package/src/ui/SignaturePad.tsx +218 -0
  57. package/src/ui/index.ts +1 -0
  58. package/src/ui/primitives.tsx +13 -2
  59. package/src/ui/useAccountT.ts +16 -0
  60. package/src/useCheckout.ts +3 -1
@@ -0,0 +1,122 @@
1
+ import React, { type ReactNode } from 'react'
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native'
3
+ import { hooks, type WordPostReactionFlow } from 'tf-checkout-shared'
4
+ import { useAccountT } from '../ui/useAccountT'
5
+
6
+ export interface PostReactionControlsProps {
7
+ flow: WordPostReactionFlow
8
+ onSignIn?: () => void
9
+ renderIcon?: (choice: 'up' | 'down') => ReactNode
10
+ translate?: (english: string) => string
11
+ showFeedback?: boolean
12
+ }
13
+
14
+ /** Native hosts may supply their own icon assets without introducing a native dependency. */
15
+ export const PostReactionControls = ({
16
+ flow,
17
+ onSignIn,
18
+ renderIcon,
19
+ translate,
20
+ showFeedback = true,
21
+ }: PostReactionControlsProps) => {
22
+ const accountT = useAccountT()
23
+ const t = translate ?? accountT
24
+ const unavailable = flow.signedIn && !flow.query.data
25
+ return (
26
+ <View style={styles.root}>
27
+ <View style={styles.buttons} accessibilityLabel={t('Article reaction')}>
28
+ {(['up', 'down'] as const).map((choice) => (
29
+ <Pressable
30
+ key={choice}
31
+ accessibilityRole="button"
32
+ accessibilityLabel={t(choice === 'up' ? 'Like article' : 'Dislike article')}
33
+ accessibilityState={{
34
+ selected: flow.reaction === choice,
35
+ disabled: flow.busy || unavailable,
36
+ }}
37
+ disabled={flow.busy || unavailable}
38
+ style={[
39
+ styles.button,
40
+ flow.reaction === choice && styles.selected,
41
+ (flow.busy || unavailable) && styles.disabled,
42
+ ]}
43
+ onPress={() =>
44
+ flow.signedIn ? void flow.toggleReaction(choice) : onSignIn?.()
45
+ }
46
+ >
47
+ {renderIcon ? (
48
+ renderIcon(choice)
49
+ ) : (
50
+ <Text style={styles.icon}>{choice === 'up' ? '👍' : '👎'}</Text>
51
+ )}
52
+ </Pressable>
53
+ ))}
54
+ </View>
55
+ {showFeedback && <PostReactionFeedback flow={flow} translate={translate} />}
56
+ </View>
57
+ )
58
+ }
59
+
60
+ export const PostReactionFeedback = ({
61
+ flow,
62
+ translate,
63
+ }: Pick<PostReactionControlsProps, 'flow' | 'translate'>) => {
64
+ const accountT = useAccountT()
65
+ const t = translate ?? accountT
66
+ return (
67
+ <>
68
+ {flow.busy && (
69
+ <Text style={styles.message} accessibilityLiveRegion="polite">
70
+ {t('Saving reaction…')}
71
+ </Text>
72
+ )}
73
+ {(flow.error || flow.query.error) && (
74
+ <View style={styles.error}>
75
+ <Text style={styles.message} accessibilityRole="alert">
76
+ {t(
77
+ flow.error ||
78
+ flow.query.error?.message ||
79
+ 'Your reaction could not be saved. Please try again.'
80
+ )}
81
+ </Text>
82
+ <Pressable
83
+ accessibilityRole="button"
84
+ disabled={flow.busy}
85
+ onPress={() => void flow.retry()}
86
+ >
87
+ <Text style={styles.retry}>{t('Try again')}</Text>
88
+ </Pressable>
89
+ </View>
90
+ )}
91
+ </>
92
+ )
93
+ }
94
+
95
+ export const PostReaction = ({
96
+ postId,
97
+ ...props
98
+ }: Omit<PostReactionControlsProps, 'flow'> & { postId: string | number }) => {
99
+ const flow = hooks.useWordPostReaction(postId)
100
+ return <PostReactionControls {...props} flow={flow} />
101
+ }
102
+
103
+ const styles = StyleSheet.create({
104
+ root: { gap: 8 },
105
+ buttons: { flexDirection: 'row', gap: 8 },
106
+ button: {
107
+ width: 42,
108
+ height: 42,
109
+ borderRadius: 999,
110
+ alignItems: 'center',
111
+ justifyContent: 'center',
112
+ backgroundColor: 'rgba(11,10,21,.6)',
113
+ borderWidth: 1,
114
+ borderColor: 'rgba(196,181,255,.12)',
115
+ },
116
+ selected: { backgroundColor: '#6040B8', borderColor: '#C4B5FF' },
117
+ disabled: { opacity: 0.5 },
118
+ icon: { fontSize: 18 },
119
+ message: { color: '#BBBFF4', fontSize: 13 },
120
+ error: { gap: 6, maxWidth: 260 },
121
+ retry: { color: '#C4B5FF', textDecorationLine: 'underline' },
122
+ })
@@ -0,0 +1,320 @@
1
+ /**
2
+ * `<Rewards/>` — the native post-purchase rewards block: where the buyer
3
+ * stands, who they have brought in, what they can still win, their pack, and
4
+ * the card they share. Render it as `children` of `<Confirmation/>`.
5
+ *
6
+ * Native adaptations vs web:
7
+ * - Copy comes in as props, this package's idiom. `translate` defaults to the
8
+ * shared translator, so a host that passes nothing still reads correctly.
9
+ * - The per-channel share row collapses into one `Share.share()`: the OS
10
+ * sheet already lists every installed app, which is exactly what the web
11
+ * `share_sheet_file` channel is reaching for through the browser.
12
+ * - No SVG. The tree is positioned views (see `rewards/RewardsTree`), so this
13
+ * package still adds no native peer dependency.
14
+ */
15
+ import React, { useEffect, useState, type FC } from 'react'
16
+ import { Alert, Image, Share, StyleSheet, View } from 'react-native'
17
+
18
+ // `@react-native-clipboard/clipboard` is a dependency of this package.
19
+ import Clipboard from '@react-native-clipboard/clipboard'
20
+
21
+ import {
22
+ buildShareText,
23
+ packsAvailable,
24
+ createTranslator,
25
+ planShareChannels,
26
+ shareCardView,
27
+ hooks,
28
+ type LocaleCode,
29
+ type Messages,
30
+ type RewardsPayload,
31
+ type Translate,
32
+ } from 'tf-checkout-shared'
33
+
34
+ import {
35
+ BodyText,
36
+ Button,
37
+ Card,
38
+ ErrorText,
39
+ Loader,
40
+ MutedText,
41
+ SectionHeader,
42
+ } from '../ui/primitives'
43
+ import { Input } from '../ui/Field'
44
+ import { RewardsLadder } from './rewards/RewardsLadder'
45
+ import { RewardsTree } from './rewards/RewardsTree'
46
+
47
+ export interface RewardsProps {
48
+ /** The order to load rewards for. Not needed when `data` is passed in. */
49
+ orderHash?: string
50
+ /** An already-loaded payload, if the host fetched it itself. */
51
+ data?: RewardsPayload | null
52
+ /** Locale for the built-in copy. Ignored when `translate` is passed. */
53
+ locale?: LocaleCode
54
+ /** Per-key copy overrides, merged over the locale. */
55
+ messages?: Partial<Messages>
56
+ /** Bring your own translator — the host's, if it already has one. */
57
+ translate?: Translate
58
+ }
59
+
60
+ type Flow = ReturnType<typeof hooks.useReceiptRewards>
61
+ export const Rewards: FC<RewardsProps> = (props) => {
62
+ const flow = hooks.useReceiptRewards(props)
63
+ return <RewardsView key={flow.scopeKey} {...props} flow={flow} />
64
+ }
65
+ const RewardsView: FC<RewardsProps & { flow: Flow }> = ({
66
+ locale,
67
+ messages,
68
+ translate,
69
+ flow,
70
+ }) => {
71
+ const t = translate ?? createTranslator({ locale, messages })
72
+ const { rewards, failure, busy, hash } = flow
73
+ const [packName, setPackName] = useState('')
74
+ const [packCode, setPackCode] = useState('')
75
+ const [copied, setCopied] = useState(false)
76
+ useEffect(() => {
77
+ setCopied(false)
78
+ }, [rewards?.share_link])
79
+ if (!rewards)
80
+ return flow.loading || failure ? (
81
+ <Card>
82
+ {flow.loading && <Loader />}
83
+ {failure && <ErrorText>{failure}</ErrorText>}
84
+ {failure && (
85
+ <Button onPress={() => void flow.reload()}>{t('rewards.retry')}</Button>
86
+ )}
87
+ </Card>
88
+ ) : null
89
+ if (rewards.referrals_enabled === false) return null
90
+
91
+ const impact = rewards.referral_impact
92
+ const card = shareCardView(rewards.share_card)
93
+ const pack = rewards.pack
94
+
95
+ // Every installed app is on the sheet, so the file share is what native can
96
+ // always do; the rest of the plan only tells us the brand's suffix to use.
97
+ const plan = planShareChannels(rewards.channels ?? [], {
98
+ canShareFiles: true,
99
+ isMobile: true,
100
+ })
101
+
102
+ const share = () => {
103
+ flow.assertCurrent()
104
+ const text = buildShareText(plan.primary, {
105
+ shareLink: rewards.share_link,
106
+ shareMessage: rewards.share_message,
107
+ })
108
+ // `url` is what iOS attaches; Android reads `message`, so both carry the
109
+ // link and the image only rides along where the OS supports it.
110
+ Share.share({
111
+ message: text,
112
+ url: card.imageUrl ?? rewards.share_link,
113
+ }).catch(() => undefined)
114
+ }
115
+
116
+ const copy = () => {
117
+ flow.assertCurrent()
118
+ if (!rewards.share_link) return
119
+ Clipboard.setString(rewards.share_link)
120
+ setCopied(true)
121
+ setTimeout(() => {
122
+ try {
123
+ flow.assertCurrent()
124
+ setCopied(false)
125
+ } catch {
126
+ /* Retired receipt. */
127
+ }
128
+ }, 1500)
129
+ }
130
+
131
+ return (
132
+ <Card>
133
+ <SectionHeader title={t('rewards.title')} />
134
+
135
+ {rewards.boosts_active?.map((boost) => (
136
+ <BodyText key={`${boost.name}-${boost.ends_at}`} style={styles.boost}>
137
+ {t('rewards.boost.active', {
138
+ name: boost.name,
139
+ multiplier: boost.multiplier,
140
+ ends: boost.ends_at_local || boost.ends_at,
141
+ })}
142
+ </BodyText>
143
+ ))}
144
+
145
+ {impact ? (
146
+ <View style={styles.block}>
147
+ <BodyText style={styles.standing}>
148
+ {impact.rank
149
+ ? t('rewards.impact.rank', {
150
+ rank: impact.rank,
151
+ total: impact.ranked_total,
152
+ })
153
+ : t('rewards.impact.unranked')}
154
+ </BodyText>
155
+ <MutedText>
156
+ {t('rewards.impact.direct', {
157
+ tickets: impact.direct_tickets,
158
+ buyers: impact.direct_buyers,
159
+ })}
160
+ </MutedText>
161
+ {impact.network_tickets > impact.direct_tickets ? (
162
+ <MutedText>
163
+ {t('rewards.impact.network', { tickets: impact.network_tickets })}
164
+ </MutedText>
165
+ ) : null}
166
+ {impact.tree?.nodes?.length ? (
167
+ <RewardsTree tree={impact.tree} />
168
+ ) : (
169
+ <MutedText>{t('rewards.impact.empty')}</MutedText>
170
+ )}
171
+ {impact.tree?.truncated ? (
172
+ <MutedText>{t('rewards.impact.truncated')}</MutedText>
173
+ ) : null}
174
+ </View>
175
+ ) : null}
176
+
177
+ {rewards.ambassador ? (
178
+ <View style={styles.block}>
179
+ <BodyText style={styles.standing}>
180
+ {t('rewards.ambassador.title', { brand: rewards.ambassador.brand_name })}
181
+ </BodyText>
182
+ {rewards.ambassador.rank != null ? (
183
+ <MutedText>
184
+ {t('rewards.ambassador.standing', {
185
+ rank: rewards.ambassador.rank,
186
+ total: rewards.ambassador.ranked_total,
187
+ })}
188
+ </MutedText>
189
+ ) : null}
190
+ {rewards.ambassador.tier ? (
191
+ <MutedText>
192
+ {t(`rewards.ambassador.tier.${rewards.ambassador.tier}`)}
193
+ </MutedText>
194
+ ) : null}
195
+ <MutedText>
196
+ {t('rewards.ambassador.lifetime', {
197
+ tickets: rewards.ambassador.lifetime_tickets,
198
+ })}
199
+ </MutedText>
200
+ </View>
201
+ ) : null}
202
+
203
+ {rewards.ladder?.length ? (
204
+ <View style={styles.block}>
205
+ <BodyText style={styles.standing}>{t('rewards.ladder.title')}</BodyText>
206
+ <RewardsLadder
207
+ rows={rewards.ladder}
208
+ translate={t}
209
+ onSubmitDetails={hash ? flow.submitDetails : undefined}
210
+ />
211
+ </View>
212
+ ) : null}
213
+
214
+ {hash && packsAvailable(rewards) ? (
215
+ <View style={styles.block}>
216
+ <BodyText style={styles.standing}>{t('rewards.pack.title')}</BodyText>
217
+ {pack ? (
218
+ <>
219
+ <BodyText>{pack.name}</BodyText>
220
+ <MutedText>
221
+ {t('rewards.pack.total', { tickets: pack.total_tickets })}
222
+ </MutedText>
223
+ <MutedText>{t('rewards.pack.code', { code: pack.code })}</MutedText>
224
+ <Button
225
+ variant="ghost"
226
+ loading={busy === 'leave'}
227
+ onPress={() => {
228
+ flow.assertCurrent()
229
+ Alert.alert(
230
+ t('rewards.pack.leave'),
231
+ t('rewards.pack.leaveConfirm', { name: pack.name }),
232
+ [
233
+ { text: t('rewards.pack.cancelLeave'), style: 'cancel' },
234
+ {
235
+ text: t('rewards.pack.leave'),
236
+ style: 'destructive',
237
+ onPress: () => {
238
+ void flow.leavePack().catch(() => undefined)
239
+ },
240
+ },
241
+ ]
242
+ )
243
+ }}
244
+ >
245
+ {t('rewards.pack.leave')}
246
+ </Button>
247
+ </>
248
+ ) : (
249
+ <>
250
+ <MutedText>{t('rewards.pack.none')}</MutedText>
251
+ <Input
252
+ value={packName}
253
+ onChangeText={setPackName}
254
+ placeholder={t('rewards.pack.namePlaceholder')}
255
+ />
256
+ <Button
257
+ loading={busy === 'create'}
258
+ disabled={!packName.trim()}
259
+ onPress={() =>
260
+ void flow.createPack(packName.trim()).catch(() => undefined)
261
+ }
262
+ >
263
+ {t('rewards.pack.create')}
264
+ </Button>
265
+ <Input
266
+ value={packCode}
267
+ onChangeText={setPackCode}
268
+ placeholder={t('rewards.pack.codePlaceholder')}
269
+ autoCapitalize="characters"
270
+ />
271
+ <Button
272
+ variant="ghost"
273
+ loading={busy === 'join'}
274
+ disabled={!packCode.trim()}
275
+ onPress={() => void flow.joinPack(packCode.trim()).catch(() => undefined)}
276
+ >
277
+ {t('rewards.pack.join')}
278
+ </Button>
279
+ </>
280
+ )}
281
+ </View>
282
+ ) : null}
283
+
284
+ {card.provisional ? (
285
+ <MutedText>{t('rewards.shareCard.provisional')}</MutedText>
286
+ ) : null}
287
+ {card.imageUrl ? (
288
+ <Image
289
+ source={{ uri: card.imageUrl }}
290
+ accessibilityLabel={t('rewards.shareCard.alt')}
291
+ style={{ width: '100%', aspectRatio: 1.91 }}
292
+ resizeMode="contain"
293
+ />
294
+ ) : null}
295
+
296
+ {rewards.share_link ? (
297
+ <View style={styles.block}>
298
+ <BodyText style={styles.standing}>{t('rewards.share.title')}</BodyText>
299
+ <Input
300
+ value={rewards.share_link}
301
+ onChangeText={() => undefined}
302
+ editable={false}
303
+ />
304
+ <Button variant="ghost" onPress={copy}>
305
+ {copied ? t('rewards.share.copied') : t('rewards.share.copy')}
306
+ </Button>
307
+ <Button onPress={share}>{t('rewards.channel.shareSheet')}</Button>
308
+ </View>
309
+ ) : null}
310
+
311
+ {failure ? <ErrorText>{failure}</ErrorText> : null}
312
+ </Card>
313
+ )
314
+ }
315
+
316
+ const styles = StyleSheet.create({
317
+ block: { gap: 6 },
318
+ standing: { fontWeight: '700' },
319
+ boost: { fontWeight: '600' },
320
+ })
@@ -0,0 +1,225 @@
1
+ import { SeatsIoMap } from './SeatsIoMap'
2
+ import React, { useEffect, useMemo, useRef, useState, type FC } from 'react'
3
+ import { StyleSheet, Text, View } from 'react-native'
4
+ import { WebView } from 'react-native-webview'
5
+ import {
6
+ getTierRelationsArray,
7
+ hooks,
8
+ isGuestCapable,
9
+ ticketTypesForTier,
10
+ } from 'tf-checkout-shared'
11
+ import { SEAT_MAP_CSS, SEAT_MAP_SCRIPT } from '../generated/seatMapWidget'
12
+ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
13
+ import { FrozenFields } from '../ui/Frozen'
14
+ import { Field } from '../ui/Field'
15
+ import { Select } from '../ui/Select'
16
+ import {
17
+ Button,
18
+ Card,
19
+ ErrorText,
20
+ Loader,
21
+ MutedText,
22
+ SectionHeader,
23
+ } from '../ui/primitives'
24
+
25
+ const safeJson = (value: unknown) =>
26
+ JSON.stringify(value)
27
+ .replace(/</g, '\\u003c')
28
+ .replace(/\u2028/g, '\\u2028')
29
+ .replace(/\u2029/g, '\\u2029')
30
+
31
+ export interface SeatMapProps {
32
+ eventId: string
33
+ onContinue?: () => void
34
+ }
35
+
36
+ /** The hosted chart renderer draws the map; shared core owns every reservation. */
37
+ export const SeatMap: FC<SeatMapProps> = ({ eventId, onContinue }) => {
38
+ const checkout = useCheckoutContext()
39
+ const theme = useTheme()
40
+ const flow = hooks.useSeatSelection(eventId, checkout, onContinue)
41
+ const bundleStarting = hooks.useBundleStarting(checkout.store)
42
+ const webview = useRef<WebView>(null)
43
+ const [rendererError, setRendererError] = useState(false)
44
+ const [size, setSize] = useState(320)
45
+ const properties =
46
+ flow.chart.state === 'ready'
47
+ ? {
48
+ seatData: flow.chart.data.seatData,
49
+ statuses: flow.statuses,
50
+ isSelectionOn: false,
51
+ disabled: flow.reserving || checkout.orderFixed,
52
+ loading: flow.reserving,
53
+ tierPrices: flow.chart.data.tierPrices,
54
+ ticketTypeTireRelationsArray: getTierRelationsArray(
55
+ flow.chart.data.ticketTypeTierRelations
56
+ ),
57
+ isBlockMap: flow.chart.data.seatMapType === 'block',
58
+ isTableMap: flow.chart.data.seatMapType === 'table',
59
+ predefinedSeats: flow.chart.data.predefinedSeats,
60
+ }
61
+ : null
62
+ const chart = flow.chart.state === 'ready' ? flow.chart.data : null
63
+ const html = useMemo(
64
+ () =>
65
+ chart
66
+ ? `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><style>html,body,#seat-map{margin:0;width:100%;height:100%;overflow:hidden}body{background:#fff}${SEAT_MAP_CSS}</style></head><body><div id="seat-map"></div><script>${SEAT_MAP_SCRIPT.replace(/<\/script/gi, '<\\/script')}</script></body></html>`
67
+ : '',
68
+ [chart]
69
+ )
70
+ const update = () => {
71
+ if (properties)
72
+ webview.current?.injectJavaScript(
73
+ `window.__tfSeatMapUpdate&&window.__tfSeatMapUpdate(${safeJson(properties)});true;`
74
+ )
75
+ }
76
+ useEffect(update, [properties && safeJson(properties)])
77
+ if (flow.chart.state === 'provider') return <SeatsIoMap eventId={eventId} initial={flow.chart.data} onContinue={onContinue} />
78
+ return (
79
+ <FrozenFields frozen={checkout.orderFixed}>
80
+ <Card>
81
+ <SectionHeader title="Select your seats" />
82
+ {flow.secondsLeft != null && (
83
+ <MutedText>
84
+ Seats held for {Math.floor(flow.secondsLeft / 60)}:
85
+ {String(flow.secondsLeft % 60).padStart(2, '0')}
86
+ </MutedText>
87
+ )}
88
+ {flow.chart.state === 'loading' ? (
89
+ <Loader />
90
+ ) : flow.chart.state === 'empty' ? (
91
+ <MutedText>This event has no seating chart.</MutedText>
92
+ ) : flow.chart.state === 'error' || rendererError ? (
93
+ <View>
94
+ <ErrorText>{flow.error || 'The seating chart could not load.'}</ErrorText>
95
+ <Button
96
+ onPress={() => {
97
+ setRendererError(false)
98
+ flow.retry()
99
+ }}
100
+ >
101
+ Try again
102
+ </Button>
103
+ </View>
104
+ ) : (
105
+ <View
106
+ onLayout={(event) => setSize(event.nativeEvent.layout.width)}
107
+ style={{ height: size }}
108
+ >
109
+ <WebView
110
+ ref={webview}
111
+ source={{ html }}
112
+ style={styles.map}
113
+ scrollEnabled={false}
114
+ incognito
115
+ javaScriptEnabled
116
+ originWhitelist={['about:*']}
117
+ onShouldStartLoadWithRequest={({ url }) => url === 'about:blank'}
118
+ onLoadEnd={update}
119
+ onError={() => setRendererError(true)}
120
+ onMessage={({ nativeEvent }) => {
121
+ try {
122
+ const data = JSON.parse(nativeEvent.data)
123
+ if (
124
+ data.type === 'seatClick' &&
125
+ data.seat &&
126
+ ['seatId', 'tierId'].every(
127
+ (key) =>
128
+ typeof data.seat[key] === 'string' ||
129
+ typeof data.seat[key] === 'number'
130
+ ) &&
131
+ typeof data.seat.status === 'string'
132
+ )
133
+ flow.onSeatClick({
134
+ seat: {
135
+ seatId: String(data.seat.seatId),
136
+ tierId: String(data.seat.tierId),
137
+ status: data.seat.status,
138
+ },
139
+ })
140
+ } catch {
141
+ /* A malformed chart message changes no reservation. */
142
+ }
143
+ }}
144
+ />
145
+ </View>
146
+ )}
147
+ {flow.reserved.map((seat) => {
148
+ const types = ticketTypesForTier(
149
+ chart?.ticketTypeTierRelations ?? {},
150
+ seat.tierId
151
+ )
152
+ const relation = flow.relationFor(seat.seatId)
153
+ const min = Number(relation?.ticket_type_min_number_of_guests) || 1
154
+ const max = Number(relation?.ticket_type_max_number_of_guests) || min
155
+ return (
156
+ <View key={seat.seatId} style={styles.seat}>
157
+ <Text style={{ color: theme.text, fontFamily: theme.fontFamily }}>
158
+ Seat {seat.seatId}
159
+ </Text>
160
+ <Select
161
+ value={flow.selectedTypes[seat.seatId]}
162
+ placeholder="Choose ticket type"
163
+ disabled={flow.reserving}
164
+ options={types.map((type) => ({
165
+ value: type.ticket_type_id,
166
+ label: `${type.ticket_type_name} · ${checkout.breakdown?.currencySymbol ?? ''}${type.ticket_type_price}`,
167
+ }))}
168
+ onChange={(value) =>
169
+ flow.setSelectedTypes((previous) => ({
170
+ ...previous,
171
+ [seat.seatId]: value,
172
+ }))
173
+ }
174
+ />
175
+ {isGuestCapable(relation) && (
176
+ <Field label="Guests">
177
+ <Select
178
+ value={flow.guestCounts[seat.seatId] ?? min}
179
+ options={Array.from(
180
+ { length: Math.max(0, max - min + 1) },
181
+ (_, index) => ({ value: min + index, label: String(min + index) })
182
+ )}
183
+ onChange={(value) =>
184
+ flow.setGuestCounts((previous) => ({
185
+ ...previous,
186
+ [seat.seatId]: value,
187
+ }))
188
+ }
189
+ />
190
+ </Field>
191
+ )}
192
+ <Button
193
+ variant="ghost"
194
+ disabled={flow.reserving}
195
+ onPress={() => flow.onSeatClick({ seat: { ...seat, status: 'OR' } })}
196
+ >
197
+ Remove seat
198
+ </Button>
199
+ </View>
200
+ )
201
+ })}
202
+ {flow.error && flow.chart.state !== 'error' && (
203
+ <ErrorText>{flow.error}</ErrorText>
204
+ )}
205
+ <Button
206
+ onPress={flow.getTickets}
207
+ loading={flow.adding}
208
+ disabled={
209
+ !flow.allChosen ||
210
+ flow.reserving ||
211
+ checkout.isBusy ||
212
+ checkout.orderFixed ||
213
+ bundleStarting
214
+ }
215
+ >
216
+ Get tickets
217
+ </Button>
218
+ </Card>
219
+ </FrozenFields>
220
+ )
221
+ }
222
+ const styles = StyleSheet.create({
223
+ map: { flex: 1, backgroundColor: '#FFF' },
224
+ seat: { gap: 12, paddingVertical: 12 },
225
+ })