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,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
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useMemo, type FC } from 'react'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { WebView } from 'react-native-webview'
|
|
4
|
+
import {
|
|
5
|
+
hooks,
|
|
6
|
+
seatsIoChartDocument,
|
|
7
|
+
seatsIoLineNotes,
|
|
8
|
+
seatsIoRendererNavigation,
|
|
9
|
+
type SeatsIoMapData,
|
|
10
|
+
} from 'tf-checkout-shared'
|
|
11
|
+
import { useCheckoutContext } from '../ui/CheckoutProvider'
|
|
12
|
+
import { FrozenFields } from '../ui/Frozen'
|
|
13
|
+
import { Select } from '../ui/Select'
|
|
14
|
+
import {
|
|
15
|
+
Button,
|
|
16
|
+
Card,
|
|
17
|
+
ErrorText,
|
|
18
|
+
Loader,
|
|
19
|
+
MutedText,
|
|
20
|
+
SectionHeader,
|
|
21
|
+
} from '../ui/primitives'
|
|
22
|
+
|
|
23
|
+
export const SeatsIoMap: FC<{
|
|
24
|
+
eventId: string
|
|
25
|
+
initial: SeatsIoMapData
|
|
26
|
+
onContinue?: () => void
|
|
27
|
+
}> = ({ eventId, initial, onContinue }) => {
|
|
28
|
+
const checkout = useCheckoutContext()
|
|
29
|
+
const flow = hooks.useSeatsIoSelection(eventId, checkout, initial, onContinue)
|
|
30
|
+
const bundleStarting = hooks.useBundleStarting(checkout.store)
|
|
31
|
+
const html = useMemo(
|
|
32
|
+
() => (flow.data.chart ? seatsIoChartDocument(flow.data.chart, flow.nonce) : ''),
|
|
33
|
+
[flow.data.chart, flow.nonce]
|
|
34
|
+
)
|
|
35
|
+
return (
|
|
36
|
+
<FrozenFields frozen={checkout.orderFixed}>
|
|
37
|
+
<Card>
|
|
38
|
+
<SectionHeader title="Select your seats" />
|
|
39
|
+
{flow.data.maps.length > 1 || !flow.data.chart ? (
|
|
40
|
+
<Select
|
|
41
|
+
value={flow.data.chart?.mapId ?? ''}
|
|
42
|
+
placeholder="Choose a date or seating chart"
|
|
43
|
+
disabled={flow.loading || flow.adding || checkout.isBusy || bundleStarting}
|
|
44
|
+
options={flow.data.maps.map((map) => ({
|
|
45
|
+
value: map.id,
|
|
46
|
+
label: `${map.name}${map.eventStartsAt ? ` · ${map.eventStartsAt}` : ''}`,
|
|
47
|
+
}))}
|
|
48
|
+
onChange={(value) => void flow.selectMap(value)}
|
|
49
|
+
/>
|
|
50
|
+
) : null}
|
|
51
|
+
{flow.data.nextCursor && (
|
|
52
|
+
<Button
|
|
53
|
+
variant="ghost"
|
|
54
|
+
disabled={flow.loading}
|
|
55
|
+
onPress={() =>
|
|
56
|
+
void flow.selectMap(flow.data.chart?.mapId ?? '', flow.data.nextCursor!)
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
Show more dates
|
|
60
|
+
</Button>
|
|
61
|
+
)}
|
|
62
|
+
{flow.loading && <Loader />}
|
|
63
|
+
{flow.data.chart && (
|
|
64
|
+
<View style={{ height: 440 }}>
|
|
65
|
+
<WebView
|
|
66
|
+
key={flow.nonce}
|
|
67
|
+
source={{ html }}
|
|
68
|
+
javaScriptEnabled
|
|
69
|
+
incognito
|
|
70
|
+
originWhitelist={['about:*', 'https://*.seatsio.net']}
|
|
71
|
+
scrollEnabled={false}
|
|
72
|
+
onShouldStartLoadWithRequest={({ url }) => seatsIoRendererNavigation(url)}
|
|
73
|
+
onMessage={({ nativeEvent }) => flow.onMessage(nativeEvent.data)}
|
|
74
|
+
onError={() =>
|
|
75
|
+
flow.onMessage(JSON.stringify({ type: 'error', nonce: flow.nonce }))
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
</View>
|
|
79
|
+
)}
|
|
80
|
+
{flow.preview.map((line) => (
|
|
81
|
+
<MutedText key={line.ticketTypeId}>
|
|
82
|
+
{line.labels.length} × {line.name} · {flow.data.chart?.currency} {line.price}{' '}
|
|
83
|
+
each · {line.labels.join(', ')} {seatsIoLineNotes(line).join(' ')}
|
|
84
|
+
</MutedText>
|
|
85
|
+
))}
|
|
86
|
+
{flow.secondsLeft != null && (
|
|
87
|
+
<MutedText>
|
|
88
|
+
Seats held for {Math.floor(flow.secondsLeft / 60)}:
|
|
89
|
+
{String(flow.secondsLeft % 60).padStart(2, '0')}
|
|
90
|
+
</MutedText>
|
|
91
|
+
)}
|
|
92
|
+
{flow.error && (
|
|
93
|
+
<>
|
|
94
|
+
<ErrorText>{flow.error}</ErrorText>
|
|
95
|
+
<Button variant="ghost" onPress={flow.restart}>
|
|
96
|
+
Start again
|
|
97
|
+
</Button>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
<Button
|
|
101
|
+
onPress={flow.getTickets}
|
|
102
|
+
loading={flow.adding}
|
|
103
|
+
disabled={!flow.canContinue || bundleStarting}
|
|
104
|
+
>
|
|
105
|
+
Get tickets
|
|
106
|
+
</Button>
|
|
107
|
+
</Card>
|
|
108
|
+
</FrozenFields>
|
|
109
|
+
)
|
|
110
|
+
}
|