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,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
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import React, { useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Alert,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
type StyleProp,
|
|
10
|
+
type ViewStyle,
|
|
11
|
+
} from 'react-native'
|
|
12
|
+
import { hooks, type AccountPreferences } from 'tf-checkout-shared'
|
|
13
|
+
import { useTheme } from '../ui/CheckoutProvider'
|
|
14
|
+
import { resolveTheme, type CheckoutTheme } from '../ui/theme'
|
|
15
|
+
import { useAccountT } from '../ui/useAccountT'
|
|
16
|
+
|
|
17
|
+
type Flow = ReturnType<typeof hooks.useAccountPreferences>
|
|
18
|
+
export type StreamingProvider = keyof AccountPreferences['music']
|
|
19
|
+
type SupportedProvider = Exclude<StreamingProvider, 'youtubeMusic'>
|
|
20
|
+
export interface StreamingProps {
|
|
21
|
+
/** Open the first-party review browser, resolving when the person returns. */
|
|
22
|
+
onOpenMusic: (url: string, provider: SupportedProvider) => void | Promise<void>
|
|
23
|
+
onSignIn?: () => void
|
|
24
|
+
locale?: string
|
|
25
|
+
translate?: (english: string, vars?: Record<string, string | number>) => string
|
|
26
|
+
renderProviderIcon?: (provider: StreamingProvider) => ReactNode
|
|
27
|
+
showHeader?: boolean
|
|
28
|
+
theme?: CheckoutTheme
|
|
29
|
+
style?: StyleProp<ViewStyle>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Standalone native presentation; account rules and state stay in the common hook. */
|
|
33
|
+
export const Streaming = (props: StreamingProps) => {
|
|
34
|
+
const preferences = hooks.useAccountPreferences()
|
|
35
|
+
return <StreamingView {...props} preferences={preferences} />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compose an existing shared flow, for example to refetch on native navigation focus. */
|
|
39
|
+
export const StreamingView = ({
|
|
40
|
+
preferences: flow,
|
|
41
|
+
onOpenMusic,
|
|
42
|
+
onSignIn,
|
|
43
|
+
locale,
|
|
44
|
+
translate,
|
|
45
|
+
renderProviderIcon,
|
|
46
|
+
showHeader = true,
|
|
47
|
+
theme: override,
|
|
48
|
+
style,
|
|
49
|
+
}: StreamingProps & { preferences: Flow }) => {
|
|
50
|
+
const defaultT = useAccountT(locale)
|
|
51
|
+
const t = translate ?? defaultT
|
|
52
|
+
const inherited = useTheme()
|
|
53
|
+
const theme = useMemo(
|
|
54
|
+
() => (override ? resolveTheme({ ...inherited, ...override }) : inherited),
|
|
55
|
+
[inherited, override]
|
|
56
|
+
)
|
|
57
|
+
const session = hooks.useAuthSession()
|
|
58
|
+
const assertOwner = hooks.useSessionAction()
|
|
59
|
+
const pendingBrowser = useRef({ scope: session.sessionScope, locked: false })
|
|
60
|
+
const [stored, setStored] = useState({
|
|
61
|
+
scope: session.sessionScope,
|
|
62
|
+
busy: false,
|
|
63
|
+
error: '',
|
|
64
|
+
})
|
|
65
|
+
const local =
|
|
66
|
+
stored.scope === session.sessionScope
|
|
67
|
+
? stored
|
|
68
|
+
: { scope: session.sessionScope, busy: false, error: '' }
|
|
69
|
+
const busy = flow.busy || local.busy
|
|
70
|
+
const text = { color: theme.text, fontFamily: theme.fontFamily }
|
|
71
|
+
const muted = { color: theme.muted, fontFamily: theme.fontFamily }
|
|
72
|
+
const connect = async (provider: SupportedProvider) => {
|
|
73
|
+
try {
|
|
74
|
+
assertOwner()
|
|
75
|
+
} catch {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
pendingBrowser.current.scope === session.sessionScope &&
|
|
80
|
+
pendingBrowser.current.locked
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
pendingBrowser.current = { scope: session.sessionScope, locked: true }
|
|
84
|
+
setStored({ scope: session.sessionScope, busy: true, error: '' })
|
|
85
|
+
try {
|
|
86
|
+
const link = await (provider === 'spotify'
|
|
87
|
+
? flow.connectSpotify()
|
|
88
|
+
: flow.connectAppleMusic())
|
|
89
|
+
assertOwner()
|
|
90
|
+
if (!link) return
|
|
91
|
+
await onOpenMusic(link.url, provider)
|
|
92
|
+
assertOwner()
|
|
93
|
+
await flow.query.refetch()
|
|
94
|
+
assertOwner()
|
|
95
|
+
} catch {
|
|
96
|
+
try {
|
|
97
|
+
assertOwner()
|
|
98
|
+
setStored({
|
|
99
|
+
scope: session.sessionScope,
|
|
100
|
+
busy: false,
|
|
101
|
+
error: 'Unable to open the connection page. Please try again.',
|
|
102
|
+
})
|
|
103
|
+
} catch {
|
|
104
|
+
/* A retired browser callback cannot publish into another buyer. */
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
try {
|
|
108
|
+
assertOwner()
|
|
109
|
+
if (pendingBrowser.current.scope === session.sessionScope)
|
|
110
|
+
pendingBrowser.current.locked = false
|
|
111
|
+
setStored((state) => ({ ...state, scope: session.sessionScope, busy: false }))
|
|
112
|
+
} catch {
|
|
113
|
+
/* The new owner keeps its own presentation state. */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const disconnect = (provider: SupportedProvider) => {
|
|
118
|
+
try {
|
|
119
|
+
assertOwner()
|
|
120
|
+
} catch {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
Alert.alert(
|
|
124
|
+
t(provider === 'spotify' ? 'Disconnect Spotify?' : 'Disconnect Apple Music?'),
|
|
125
|
+
t(
|
|
126
|
+
provider === 'spotify'
|
|
127
|
+
? 'Your imported Spotify data and approved names will be removed. Your saved interests stay.'
|
|
128
|
+
: 'Your imported artists and genres will be removed. Your saved interests will stay.'
|
|
129
|
+
),
|
|
130
|
+
[
|
|
131
|
+
{ text: t('Cancel'), style: 'cancel' },
|
|
132
|
+
{
|
|
133
|
+
text: t('Disconnect'),
|
|
134
|
+
style: 'destructive',
|
|
135
|
+
onPress: () => {
|
|
136
|
+
try {
|
|
137
|
+
assertOwner()
|
|
138
|
+
void (provider === 'spotify'
|
|
139
|
+
? flow.disconnectSpotify()
|
|
140
|
+
: flow.disconnectAppleMusic())
|
|
141
|
+
} catch {
|
|
142
|
+
/* Confirmation belongs to the buyer who opened it. */
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
const action = (
|
|
150
|
+
label: string,
|
|
151
|
+
provider: string,
|
|
152
|
+
onPress: () => void,
|
|
153
|
+
disabled = busy
|
|
154
|
+
) => (
|
|
155
|
+
<Pressable
|
|
156
|
+
accessibilityRole="button"
|
|
157
|
+
accessibilityLabel={
|
|
158
|
+
provider ? t('{{action}} {{provider}}', { action: t(label), provider }) : t(label)
|
|
159
|
+
}
|
|
160
|
+
accessibilityState={{ disabled, busy }}
|
|
161
|
+
disabled={disabled}
|
|
162
|
+
onPress={onPress}
|
|
163
|
+
style={[s.button, { borderColor: theme.accent }, disabled && s.disabled]}
|
|
164
|
+
>
|
|
165
|
+
<Text style={[text, { color: theme.accent, fontSize: theme.fontSize }]}>
|
|
166
|
+
{t(label)}
|
|
167
|
+
</Text>
|
|
168
|
+
</Pressable>
|
|
169
|
+
)
|
|
170
|
+
const music = flow.query.data?.music
|
|
171
|
+
return (
|
|
172
|
+
<View style={[s.container, style]}>
|
|
173
|
+
{showHeader && <Text style={[s.title, text]}>{t('Streaming')}</Text>}
|
|
174
|
+
<Text style={[s.intro, text]}>
|
|
175
|
+
{t('Choose which artist and genre names to use for event suggestions.')}
|
|
176
|
+
</Text>
|
|
177
|
+
{flow.query.isPending && (
|
|
178
|
+
<ActivityIndicator
|
|
179
|
+
accessibilityLabel={t('Loading streaming connections…')}
|
|
180
|
+
color={theme.accent}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
{music &&
|
|
184
|
+
(
|
|
185
|
+
[
|
|
186
|
+
{ key: 'spotify', name: 'Spotify', connection: music.spotify },
|
|
187
|
+
{ key: 'appleMusic', name: 'Apple Music', connection: music.appleMusic },
|
|
188
|
+
{
|
|
189
|
+
key: 'youtubeMusic',
|
|
190
|
+
name: 'YouTube Music',
|
|
191
|
+
connection: music.youtubeMusic,
|
|
192
|
+
},
|
|
193
|
+
] as const
|
|
194
|
+
).map(({ key, name, connection }) => (
|
|
195
|
+
<View key={key} style={[s.provider, { borderBottomColor: theme.border }]}>
|
|
196
|
+
<View style={s.identity}>
|
|
197
|
+
{renderProviderIcon?.(key)}
|
|
198
|
+
<View style={s.names}>
|
|
199
|
+
<Text style={[s.name, text]}>{name}</Text>
|
|
200
|
+
<Text style={[s.status, muted]}>
|
|
201
|
+
{connection.connected
|
|
202
|
+
? t('Connected')
|
|
203
|
+
: connection.available
|
|
204
|
+
? t('Not connected')
|
|
205
|
+
: t(connection.reason || 'Not available yet')}
|
|
206
|
+
</Text>
|
|
207
|
+
</View>
|
|
208
|
+
</View>
|
|
209
|
+
{key !== 'youtubeMusic' && (
|
|
210
|
+
<View style={s.actions}>
|
|
211
|
+
{connection.available &&
|
|
212
|
+
action(
|
|
213
|
+
connection.connected ? 'Review choices' : 'Connect',
|
|
214
|
+
name,
|
|
215
|
+
() => void connect(key)
|
|
216
|
+
)}
|
|
217
|
+
{connection.connected &&
|
|
218
|
+
action('Disconnect', name, () => disconnect(key))}
|
|
219
|
+
</View>
|
|
220
|
+
)}
|
|
221
|
+
{connection.connected && (
|
|
222
|
+
<Text style={[s.approved, muted]}>
|
|
223
|
+
{connection.artists.length || connection.genres.length
|
|
224
|
+
? [...connection.artists, ...connection.genres].join(', ')
|
|
225
|
+
: t('No artist or genre names approved yet.')}
|
|
226
|
+
</Text>
|
|
227
|
+
)}
|
|
228
|
+
</View>
|
|
229
|
+
))}
|
|
230
|
+
{(local.error || flow.error || flow.query.error) && (
|
|
231
|
+
<Text accessibilityRole="alert" style={[s.note, { color: theme.error }]}>
|
|
232
|
+
{t(local.error || flow.error || flow.query.error?.message || '')}
|
|
233
|
+
</Text>
|
|
234
|
+
)}
|
|
235
|
+
{flow.message && (
|
|
236
|
+
<Text accessibilityLiveRegion="polite" style={[s.note, muted]}>
|
|
237
|
+
{t(flow.message)}
|
|
238
|
+
</Text>
|
|
239
|
+
)}
|
|
240
|
+
{action('Refresh connections', '', () => void flow.query.refetch())}
|
|
241
|
+
{!music && onSignIn && action('Sign in', '', onSignIn)}
|
|
242
|
+
<Text style={[s.note, muted]}>
|
|
243
|
+
{t(
|
|
244
|
+
'Only the artist and genre names you approve are used for these event suggestions.'
|
|
245
|
+
)}
|
|
246
|
+
</Text>
|
|
247
|
+
</View>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
const s = StyleSheet.create({
|
|
251
|
+
container: { width: '100%' },
|
|
252
|
+
title: { fontSize: 20, fontWeight: '500', marginBottom: 16 },
|
|
253
|
+
intro: { fontSize: 20, lineHeight: 28, marginVertical: 24 },
|
|
254
|
+
provider: { borderBottomWidth: StyleSheet.hairlineWidth, paddingVertical: 20, gap: 16 },
|
|
255
|
+
identity: { flexDirection: 'row', alignItems: 'center', gap: 16 },
|
|
256
|
+
names: { flex: 1 },
|
|
257
|
+
name: { fontSize: 16, lineHeight: 24 },
|
|
258
|
+
status: { fontSize: 14, lineHeight: 20, marginTop: 4 },
|
|
259
|
+
actions: {
|
|
260
|
+
flexDirection: 'row',
|
|
261
|
+
flexWrap: 'wrap',
|
|
262
|
+
justifyContent: 'flex-end',
|
|
263
|
+
gap: 10,
|
|
264
|
+
},
|
|
265
|
+
button: {
|
|
266
|
+
alignSelf: 'flex-end',
|
|
267
|
+
borderWidth: 1,
|
|
268
|
+
borderRadius: 24,
|
|
269
|
+
paddingVertical: 10,
|
|
270
|
+
paddingHorizontal: 20,
|
|
271
|
+
minHeight: 44,
|
|
272
|
+
justifyContent: 'center',
|
|
273
|
+
marginTop: 4,
|
|
274
|
+
},
|
|
275
|
+
disabled: { opacity: 0.5 },
|
|
276
|
+
approved: { fontSize: 14, lineHeight: 20 },
|
|
277
|
+
note: { fontSize: 14, lineHeight: 20, marginTop: 24 },
|
|
278
|
+
})
|