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
|
@@ -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
|
+
})
|