tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.11
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 +8195 -282
- package/dist/index.d.ts +8195 -282
- package/dist/index.js +32346 -7001
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32586 -7128
- 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,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
|
+
})
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<TicketChange/>` — the RN analogue of the web section: a buyer changes or
|
|
3
|
+
* upgrades one ticket. It lists what the ticket can change to with the one
|
|
4
|
+
* price the server words, confirms, and makes the change; a paid change opens
|
|
5
|
+
* the hosted payment page. When no change is open it says why and offers
|
|
6
|
+
* "Sell it instead" or the upgrade waitlist. The flow lives in
|
|
7
|
+
* `hooks.useTicketChange`, shared with web.
|
|
8
|
+
*
|
|
9
|
+
* Pass `orderHash` (from the order email link) for a buyer with no account.
|
|
10
|
+
*/
|
|
11
|
+
import React, { useMemo, type FC } from 'react'
|
|
12
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createTranslator,
|
|
16
|
+
hooks,
|
|
17
|
+
ticketChangeCharge,
|
|
18
|
+
ticketChangeDoneCopy,
|
|
19
|
+
ticketChangeView,
|
|
20
|
+
type LocaleCode,
|
|
21
|
+
type Messages,
|
|
22
|
+
type TicketChangeOption,
|
|
23
|
+
type Translate,
|
|
24
|
+
} from 'tf-checkout-shared'
|
|
25
|
+
|
|
26
|
+
import { rnPorts } from '../instance'
|
|
27
|
+
import { useTheme } from '../ui/CheckoutProvider'
|
|
28
|
+
import { Button, Card, ErrorText, Loader, SectionHeader } from '../ui/primitives'
|
|
29
|
+
import type { ResolvedTheme } from '../ui/theme'
|
|
30
|
+
|
|
31
|
+
export interface TicketChangeProps {
|
|
32
|
+
ticketHash: string
|
|
33
|
+
/** The order's own link hash, for a buyer who is not signed in. */
|
|
34
|
+
orderHash?: string
|
|
35
|
+
/** Fired by the Back button after a change that needed no payment is made.
|
|
36
|
+
* Without it, Back returns to the start of the change flow. */
|
|
37
|
+
onChanged?: () => void
|
|
38
|
+
/** Opens server links (payment page, resale). Defaults to the system browser. */
|
|
39
|
+
openUrl?: (url: string) => void | Promise<void>
|
|
40
|
+
/** Opens the host's sign-in. When given, a credit change that needs an
|
|
41
|
+
* account shows a Sign in button beside the prompt. A sign-in through the
|
|
42
|
+
* SDK reads the options again by itself; a host with its own session
|
|
43
|
+
* returns a promise that settles once the buyer is signed in. */
|
|
44
|
+
onLogin?: () => void | Promise<unknown>
|
|
45
|
+
locale?: LocaleCode
|
|
46
|
+
messages?: Partial<Messages>
|
|
47
|
+
translate?: Translate
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const TicketChange: FC<TicketChangeProps> = ({
|
|
51
|
+
ticketHash,
|
|
52
|
+
orderHash,
|
|
53
|
+
onChanged,
|
|
54
|
+
openUrl,
|
|
55
|
+
onLogin,
|
|
56
|
+
locale,
|
|
57
|
+
messages,
|
|
58
|
+
translate,
|
|
59
|
+
}) => {
|
|
60
|
+
const theme = useTheme()
|
|
61
|
+
const s = useChangeStyles(theme)
|
|
62
|
+
const t = translate ?? createTranslator({ locale, messages })
|
|
63
|
+
const open = openUrl ?? rnPorts.openUrl
|
|
64
|
+
const flow = hooks.useTicketChange({ ticketHash, orderHash }, open)
|
|
65
|
+
const options = flow.query.data
|
|
66
|
+
// A credit change that needs an account: the prompt replaces Confirm, and
|
|
67
|
+
// the ticket stays as it is.
|
|
68
|
+
const signInLine = flow.signInRequired ? (
|
|
69
|
+
<View>
|
|
70
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
71
|
+
{t('ticketChange.signInRequired')}
|
|
72
|
+
</Text>
|
|
73
|
+
{onLogin ? (
|
|
74
|
+
<Button onPress={() => flow.signIn(onLogin)}>{t('ticketChange.signIn')}</Button>
|
|
75
|
+
) : null}
|
|
76
|
+
</View>
|
|
77
|
+
) : null
|
|
78
|
+
const label = (option: TicketChangeOption) =>
|
|
79
|
+
option.eventName
|
|
80
|
+
? t('ticketChange.otherEvent', { name: option.name, event: option.eventName })
|
|
81
|
+
: option.name
|
|
82
|
+
// A change for this ticket from before the buyer left and came back is
|
|
83
|
+
// still running: the controls show busy, never dead.
|
|
84
|
+
const savingLine =
|
|
85
|
+
flow.saving && flow.step !== 'submitting' ? (
|
|
86
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
87
|
+
{t('common.stillSaving')}
|
|
88
|
+
</Text>
|
|
89
|
+
) : null
|
|
90
|
+
// The options on screen may be old after a failed reread: say so, offer a
|
|
91
|
+
// retry, and keep every action disabled until it works.
|
|
92
|
+
const refreshLine = flow.refreshFailed ? (
|
|
93
|
+
<View>
|
|
94
|
+
<ErrorText>{t('ticketChange.refreshFailed')}</ErrorText>
|
|
95
|
+
<Button
|
|
96
|
+
variant="ghost"
|
|
97
|
+
disabled={!flow.can.retry}
|
|
98
|
+
onPress={() => void flow.retryRefresh()}
|
|
99
|
+
>
|
|
100
|
+
{t('ticketChange.retry')}
|
|
101
|
+
</Button>
|
|
102
|
+
</View>
|
|
103
|
+
) : null
|
|
104
|
+
const header = (
|
|
105
|
+
<>
|
|
106
|
+
<SectionHeader title={t('ticketChange.heading')} />
|
|
107
|
+
{savingLine}
|
|
108
|
+
{refreshLine}
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// A payment for this ticket began in a flow that is gone: no new change
|
|
113
|
+
// until the buyer continues it, starts again, or it is over.
|
|
114
|
+
if (flow.paymentInProgress) {
|
|
115
|
+
return (
|
|
116
|
+
<Card>
|
|
117
|
+
{header}
|
|
118
|
+
<Text style={s.muted} accessibilityLiveRegion="polite">
|
|
119
|
+
{t('ticketChange.paymentInProgress')}
|
|
120
|
+
</Text>
|
|
121
|
+
{flow.canContinuePayment ? (
|
|
122
|
+
<Button onPress={flow.continuePayment}>
|
|
123
|
+
{t('ticketChange.continuePayment')}
|
|
124
|
+
</Button>
|
|
125
|
+
) : null}
|
|
126
|
+
{flow.notice ? <ErrorText>{t(flow.notice)}</ErrorText> : null}
|
|
127
|
+
<Button variant="ghost" onPress={flow.startAgain}>
|
|
128
|
+
{t('ticketChange.startAgain')}
|
|
129
|
+
</Button>
|
|
130
|
+
</Card>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (flow.query.isLoading) {
|
|
135
|
+
return (
|
|
136
|
+
<Card>
|
|
137
|
+
{header}
|
|
138
|
+
<View style={s.loader}>
|
|
139
|
+
<Loader />
|
|
140
|
+
</View>
|
|
141
|
+
</Card>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
if (!options) {
|
|
145
|
+
return (
|
|
146
|
+
<Card>
|
|
147
|
+
{header}
|
|
148
|
+
<ErrorText>
|
|
149
|
+
{flow.query.error
|
|
150
|
+
? (flow.query.error as Error).message
|
|
151
|
+
: t('ticketChange.closed')}
|
|
152
|
+
</ErrorText>
|
|
153
|
+
</Card>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (flow.step === 'done') {
|
|
158
|
+
// Only a made change is reported as one: a refund to wait for, or an
|
|
159
|
+
// answer that is not confirmed, leaves the ticket as it is.
|
|
160
|
+
const done = ticketChangeDoneCopy(flow.result)
|
|
161
|
+
const priceText =
|
|
162
|
+
flow.result && 'priceText' in flow.result ? flow.result.priceText : ''
|
|
163
|
+
return (
|
|
164
|
+
<Card>
|
|
165
|
+
{header}
|
|
166
|
+
<Text style={s.strong}>
|
|
167
|
+
{'text' in done ? done.text : t(done.key, done.values)}
|
|
168
|
+
</Text>
|
|
169
|
+
{priceText ? <Text style={s.strong}>{priceText}</Text> : null}
|
|
170
|
+
<Button
|
|
171
|
+
onPress={() => (done.changed && onChanged ? onChanged() : flow.restart())}
|
|
172
|
+
>
|
|
173
|
+
{t('ticketChange.back')}
|
|
174
|
+
</Button>
|
|
175
|
+
</Card>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (flow.step === 'payment' && flow.result?.status === 'payment_required') {
|
|
180
|
+
return (
|
|
181
|
+
<Card>
|
|
182
|
+
{header}
|
|
183
|
+
<Text style={s.muted}>{t('ticketChange.payment')}</Text>
|
|
184
|
+
<Button disabled={!flow.can.pay} onPress={flow.openPayment}>
|
|
185
|
+
{t('ticketChange.paymentOpen')}
|
|
186
|
+
</Button>
|
|
187
|
+
{flow.notice ? <ErrorText>{t(flow.notice)}</ErrorText> : null}
|
|
188
|
+
{/* Back rereads the options under the ticket's lock: wait for it. */}
|
|
189
|
+
<Button
|
|
190
|
+
variant="ghost"
|
|
191
|
+
loading={flow.busy}
|
|
192
|
+
disabled={!flow.can.back}
|
|
193
|
+
onPress={flow.back}
|
|
194
|
+
>
|
|
195
|
+
{t('ticketChange.back')}
|
|
196
|
+
</Button>
|
|
197
|
+
</Card>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if ((flow.step === 'confirm' || flow.step === 'submitting') && flow.selected) {
|
|
202
|
+
const option = flow.selected
|
|
203
|
+
const submitting = flow.step === 'submitting'
|
|
204
|
+
return (
|
|
205
|
+
<Card>
|
|
206
|
+
{header}
|
|
207
|
+
<Text style={s.strong}>
|
|
208
|
+
{t('ticketChange.confirmTitle', { name: label(option) })}
|
|
209
|
+
</Text>
|
|
210
|
+
<Text style={s.price}>{option.priceText}</Text>
|
|
211
|
+
{(() => {
|
|
212
|
+
// The server's price text is the difference; a change fee comes on top.
|
|
213
|
+
const charge = ticketChangeCharge(option)
|
|
214
|
+
if (!charge || option.fee <= 0) return null
|
|
215
|
+
return (
|
|
216
|
+
<Text style={s.muted}>
|
|
217
|
+
{t('ticketChange.fee', {
|
|
218
|
+
amount: `${option.currency} ${option.fee.toFixed(2)}`,
|
|
219
|
+
})}
|
|
220
|
+
{'\n'}
|
|
221
|
+
{t('ticketChange.total', {
|
|
222
|
+
amount: `${charge.currency} ${charge.amount.toFixed(2)}`,
|
|
223
|
+
})}
|
|
224
|
+
</Text>
|
|
225
|
+
)
|
|
226
|
+
})()}
|
|
227
|
+
{flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
|
|
228
|
+
{flow.notice ? <Text style={s.muted}>{t(flow.notice)}</Text> : null}
|
|
229
|
+
{signInLine}
|
|
230
|
+
{flow.alternatives.length > 0 ? (
|
|
231
|
+
<View style={s.slots}>
|
|
232
|
+
<Text style={s.muted}>{t('ticketChange.slotFull')}</Text>
|
|
233
|
+
{flow.alternatives.map((slot) => (
|
|
234
|
+
<Button
|
|
235
|
+
key={slot.slotValue}
|
|
236
|
+
variant="ghost"
|
|
237
|
+
disabled={!flow.can.chooseAlternative}
|
|
238
|
+
onPress={() => void flow.chooseAlternative(slot)}
|
|
239
|
+
>
|
|
240
|
+
{slot.label}
|
|
241
|
+
</Button>
|
|
242
|
+
))}
|
|
243
|
+
</View>
|
|
244
|
+
) : null}
|
|
245
|
+
{/* No Confirm for a slot just refused: the buyer picks an open one. */}
|
|
246
|
+
{flow.canConfirm || submitting ? (
|
|
247
|
+
<Button
|
|
248
|
+
loading={submitting}
|
|
249
|
+
disabled={!flow.can.confirm}
|
|
250
|
+
onPress={() => void flow.confirm()}
|
|
251
|
+
>
|
|
252
|
+
{submitting
|
|
253
|
+
? t('ticketChange.submitting')
|
|
254
|
+
: option.paymentRequired
|
|
255
|
+
? t('ticketChange.confirmPay')
|
|
256
|
+
: t('ticketChange.confirm')}
|
|
257
|
+
</Button>
|
|
258
|
+
) : null}
|
|
259
|
+
<Button variant="ghost" disabled={!flow.can.back} onPress={flow.back}>
|
|
260
|
+
{t('ticketChange.back')}
|
|
261
|
+
</Button>
|
|
262
|
+
</Card>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (ticketChangeView(options) === 'choose') {
|
|
267
|
+
return (
|
|
268
|
+
<Card>
|
|
269
|
+
{header}
|
|
270
|
+
<Text style={s.muted}>{t('ticketChange.choose')}</Text>
|
|
271
|
+
{options.options.map((option) => (
|
|
272
|
+
<Pressable
|
|
273
|
+
key={option.ticketTypeId}
|
|
274
|
+
accessibilityRole="button"
|
|
275
|
+
accessibilityState={{ disabled: !flow.can.select }}
|
|
276
|
+
disabled={!flow.can.select}
|
|
277
|
+
onPress={() => flow.select(option)}
|
|
278
|
+
style={[
|
|
279
|
+
s.option,
|
|
280
|
+
{ borderColor: theme.border },
|
|
281
|
+
!flow.can.select && s.optionDisabled,
|
|
282
|
+
]}
|
|
283
|
+
>
|
|
284
|
+
<Text style={s.strong}>{label(option)}</Text>
|
|
285
|
+
<Text style={s.price}>{option.priceText}</Text>
|
|
286
|
+
</Pressable>
|
|
287
|
+
))}
|
|
288
|
+
{flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
|
|
289
|
+
{flow.notice ? <Text style={s.muted}>{t(flow.notice)}</Text> : null}
|
|
290
|
+
{signInLine}
|
|
291
|
+
</Card>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<Card>
|
|
297
|
+
{header}
|
|
298
|
+
<Text style={s.muted}>{options.message || t('ticketChange.closed')}</Text>
|
|
299
|
+
{options.sellInstead.available ? (
|
|
300
|
+
<>
|
|
301
|
+
<Text style={s.muted}>{t('ticketChange.sellInsteadNote')}</Text>
|
|
302
|
+
<Button disabled={!flow.can.sellInstead} onPress={flow.sellInstead}>
|
|
303
|
+
{t('ticketChange.sellInstead')}
|
|
304
|
+
</Button>
|
|
305
|
+
</>
|
|
306
|
+
) : null}
|
|
307
|
+
{options.waitlistOptions.map((wait) => (
|
|
308
|
+
<View key={wait.ticketTypeId} style={s.waitlist}>
|
|
309
|
+
{wait.entry ? (
|
|
310
|
+
<>
|
|
311
|
+
<Text style={s.muted}>
|
|
312
|
+
{t(
|
|
313
|
+
wait.entry.status === 'offered'
|
|
314
|
+
? 'ticketChange.waitlist.offered'
|
|
315
|
+
: 'ticketChange.waitlist.waiting',
|
|
316
|
+
{ name: wait.name }
|
|
317
|
+
)}
|
|
318
|
+
</Text>
|
|
319
|
+
<Button
|
|
320
|
+
variant="ghost"
|
|
321
|
+
loading={flow.busyWaitlist}
|
|
322
|
+
disabled={!flow.can.waitlist}
|
|
323
|
+
onPress={() => wait.entry && void flow.leaveWaitlist(wait.entry.id)}
|
|
324
|
+
>
|
|
325
|
+
{t('ticketChange.waitlist.leave')}
|
|
326
|
+
</Button>
|
|
327
|
+
</>
|
|
328
|
+
) : (
|
|
329
|
+
<Button
|
|
330
|
+
variant="ghost"
|
|
331
|
+
loading={flow.busyWaitlist}
|
|
332
|
+
disabled={!flow.can.waitlist}
|
|
333
|
+
onPress={() => void flow.joinWaitlist(wait.ticketTypeId)}
|
|
334
|
+
>
|
|
335
|
+
{t('ticketChange.waitlist.join', { name: wait.name })}
|
|
336
|
+
</Button>
|
|
337
|
+
)}
|
|
338
|
+
</View>
|
|
339
|
+
))}
|
|
340
|
+
{flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
|
|
341
|
+
{flow.notice ? <Text style={s.muted}>{t(flow.notice)}</Text> : null}
|
|
342
|
+
</Card>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const useChangeStyles = (theme: ResolvedTheme) =>
|
|
347
|
+
useMemo(
|
|
348
|
+
() =>
|
|
349
|
+
StyleSheet.create({
|
|
350
|
+
loader: { paddingVertical: 20, alignItems: 'center' },
|
|
351
|
+
muted: {
|
|
352
|
+
color: theme.muted,
|
|
353
|
+
fontSize: theme.fontSize - 1,
|
|
354
|
+
fontFamily: theme.fontFamily,
|
|
355
|
+
},
|
|
356
|
+
strong: {
|
|
357
|
+
color: theme.text,
|
|
358
|
+
fontSize: theme.fontSize,
|
|
359
|
+
fontWeight: '600',
|
|
360
|
+
fontFamily: theme.fontFamily,
|
|
361
|
+
},
|
|
362
|
+
price: {
|
|
363
|
+
color: theme.text,
|
|
364
|
+
fontSize: theme.fontSize,
|
|
365
|
+
fontWeight: '700',
|
|
366
|
+
fontFamily: theme.fontFamily,
|
|
367
|
+
},
|
|
368
|
+
option: {
|
|
369
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
370
|
+
borderRadius: 8,
|
|
371
|
+
padding: 12,
|
|
372
|
+
gap: 4,
|
|
373
|
+
},
|
|
374
|
+
optionDisabled: { opacity: 0.4 },
|
|
375
|
+
slots: { gap: 8 },
|
|
376
|
+
waitlist: { gap: 6, paddingTop: 8 },
|
|
377
|
+
}),
|
|
378
|
+
[theme]
|
|
379
|
+
)
|