tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.5
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 +7374 -288
- package/dist/index.d.ts +7374 -288
- package/dist/index.js +23316 -5980
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23461 -6058
- 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 +633 -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/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 +342 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +20 -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,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
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import React, { type FC } from 'react'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { hooks, type TicketGiftProps } from 'tf-checkout-shared'
|
|
4
|
+
import {
|
|
5
|
+
BodyText,
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
ErrorText,
|
|
9
|
+
Loader,
|
|
10
|
+
SectionHeader,
|
|
11
|
+
} from '../ui/primitives'
|
|
12
|
+
import { Checkbox, Field, Input } from '../ui/Field'
|
|
13
|
+
import { useAccountT } from '../ui/useAccountT'
|
|
14
|
+
|
|
15
|
+
export const TicketGift: FC<TicketGiftProps> = ({
|
|
16
|
+
ticketHash,
|
|
17
|
+
onTicketsChanged,
|
|
18
|
+
onDone,
|
|
19
|
+
locale,
|
|
20
|
+
}) => {
|
|
21
|
+
const t = useAccountT(locale)
|
|
22
|
+
const flow = hooks.useTicketGift(ticketHash, onTicketsChanged)
|
|
23
|
+
const { state, query } = flow
|
|
24
|
+
const command = state.command ?? state.submitted
|
|
25
|
+
if (command)
|
|
26
|
+
return (
|
|
27
|
+
<Card>
|
|
28
|
+
<SectionHeader
|
|
29
|
+
title={t(command.kind === 'send' ? 'Review your gift' : 'Cancel gift')}
|
|
30
|
+
/>
|
|
31
|
+
{command.kind === 'send' ? (
|
|
32
|
+
<>
|
|
33
|
+
<BodyText>
|
|
34
|
+
{command.intent.first_name} {command.intent.last_name}
|
|
35
|
+
</BodyText>
|
|
36
|
+
<BodyText>{command.intent.email}</BodyText>
|
|
37
|
+
{query.data?.add_ons
|
|
38
|
+
.filter(
|
|
39
|
+
(item) =>
|
|
40
|
+
!query.data?.select_add_ons ||
|
|
41
|
+
command.intent.add_on_ids.includes(item.id)
|
|
42
|
+
)
|
|
43
|
+
.map((item) => (
|
|
44
|
+
<BodyText key={item.id}>{item.name}</BodyText>
|
|
45
|
+
))}
|
|
46
|
+
<BodyText>
|
|
47
|
+
{t(
|
|
48
|
+
'Once the gift is accepted, this ticket will belong to the recipient and you will no longer be able to use it.'
|
|
49
|
+
)}
|
|
50
|
+
</BodyText>
|
|
51
|
+
</>
|
|
52
|
+
) : (
|
|
53
|
+
<BodyText>
|
|
54
|
+
{t(
|
|
55
|
+
'Cancel this pending gift so you can keep or send your ticket to someone else?'
|
|
56
|
+
)}
|
|
57
|
+
</BodyText>
|
|
58
|
+
)}
|
|
59
|
+
{state.error && <ErrorText>{t(state.error)}</ErrorText>}
|
|
60
|
+
<Button
|
|
61
|
+
loading={state.busy}
|
|
62
|
+
disabled={state.busy}
|
|
63
|
+
onPress={() => void flow.submit()}
|
|
64
|
+
>
|
|
65
|
+
{t(
|
|
66
|
+
state.submitted
|
|
67
|
+
? 'Check gift status'
|
|
68
|
+
: command.kind === 'send'
|
|
69
|
+
? 'Send gift'
|
|
70
|
+
: 'Cancel gift'
|
|
71
|
+
)}
|
|
72
|
+
</Button>
|
|
73
|
+
{!state.submitted && (
|
|
74
|
+
<Button variant="ghost" onPress={flow.back}>
|
|
75
|
+
{t('Back')}
|
|
76
|
+
</Button>
|
|
77
|
+
)}
|
|
78
|
+
</Card>
|
|
79
|
+
)
|
|
80
|
+
if (query.isLoading) return <Loader />
|
|
81
|
+
if (query.error)
|
|
82
|
+
return (
|
|
83
|
+
<Card>
|
|
84
|
+
<ErrorText>{t(query.error.message)}</ErrorText>
|
|
85
|
+
<Button onPress={() => void query.refetch()}>{t('Try again')}</Button>
|
|
86
|
+
</Card>
|
|
87
|
+
)
|
|
88
|
+
const gift = query.data
|
|
89
|
+
if (!gift) return null
|
|
90
|
+
return (
|
|
91
|
+
<Card>
|
|
92
|
+
<SectionHeader title={t('Gift ticket')} />
|
|
93
|
+
{state.result === 'cancelled' && (
|
|
94
|
+
<BodyText>{t('Your gift was cancelled. Your ticket is still yours.')}</BodyText>
|
|
95
|
+
)}
|
|
96
|
+
{gift.status === 'sent' && (
|
|
97
|
+
<BodyText>
|
|
98
|
+
{t('Your ticket has been given to the recipient. You can no longer use it.')}
|
|
99
|
+
</BodyText>
|
|
100
|
+
)}
|
|
101
|
+
{gift.pending && (
|
|
102
|
+
<>
|
|
103
|
+
<BodyText>
|
|
104
|
+
{t(
|
|
105
|
+
gift.status === 'processing'
|
|
106
|
+
? 'Your gift is being completed.'
|
|
107
|
+
: 'Your gift is waiting for the recipient to accept it.'
|
|
108
|
+
)}
|
|
109
|
+
</BodyText>
|
|
110
|
+
<BodyText>
|
|
111
|
+
{gift.pending.first_name} {gift.pending.last_name}
|
|
112
|
+
</BodyText>
|
|
113
|
+
<BodyText>{gift.pending.email}</BodyText>
|
|
114
|
+
{gift.pending.can_cancel && (
|
|
115
|
+
<Button onPress={flow.confirmCancel}>{t('Cancel gift')}</Button>
|
|
116
|
+
)}
|
|
117
|
+
<Button variant="ghost" onPress={() => void query.refetch()}>
|
|
118
|
+
{t('Check gift status')}
|
|
119
|
+
</Button>
|
|
120
|
+
</>
|
|
121
|
+
)}
|
|
122
|
+
{gift.can_send ? (
|
|
123
|
+
<View style={{ gap: 12 }}>
|
|
124
|
+
<BodyText>
|
|
125
|
+
{t(
|
|
126
|
+
'Give this ticket to someone else. Once they receive it, you will no longer own it or be able to use it.'
|
|
127
|
+
)}
|
|
128
|
+
</BodyText>
|
|
129
|
+
<Field label={t('First name')}>
|
|
130
|
+
<Input
|
|
131
|
+
value={state.draft.first_name}
|
|
132
|
+
accessibilityLabel={t('First name')}
|
|
133
|
+
maxLength={100}
|
|
134
|
+
onChangeText={(value) => flow.edit({ first_name: value })}
|
|
135
|
+
autoCapitalize="words"
|
|
136
|
+
/>
|
|
137
|
+
</Field>
|
|
138
|
+
<Field label={t('Last name')}>
|
|
139
|
+
<Input
|
|
140
|
+
value={state.draft.last_name}
|
|
141
|
+
accessibilityLabel={t('Last name')}
|
|
142
|
+
maxLength={100}
|
|
143
|
+
onChangeText={(value) => flow.edit({ last_name: value })}
|
|
144
|
+
autoCapitalize="words"
|
|
145
|
+
/>
|
|
146
|
+
</Field>
|
|
147
|
+
<Field label={t('Email address')}>
|
|
148
|
+
<Input
|
|
149
|
+
value={state.draft.email}
|
|
150
|
+
accessibilityLabel={t('Email address')}
|
|
151
|
+
maxLength={254}
|
|
152
|
+
onChangeText={(value) => flow.edit({ email: value })}
|
|
153
|
+
keyboardType="email-address"
|
|
154
|
+
autoCapitalize="none"
|
|
155
|
+
/>
|
|
156
|
+
</Field>
|
|
157
|
+
<Field label={t('Confirm email address')}>
|
|
158
|
+
<Input
|
|
159
|
+
value={state.draft.confirm_email}
|
|
160
|
+
accessibilityLabel={t('Confirm email address')}
|
|
161
|
+
maxLength={254}
|
|
162
|
+
onChangeText={(value) => flow.edit({ confirm_email: value })}
|
|
163
|
+
keyboardType="email-address"
|
|
164
|
+
autoCapitalize="none"
|
|
165
|
+
/>
|
|
166
|
+
</Field>
|
|
167
|
+
{gift.add_ons.length > 0 && (
|
|
168
|
+
<BodyText>
|
|
169
|
+
{t(
|
|
170
|
+
gift.select_add_ons
|
|
171
|
+
? 'Choose add-ons to include'
|
|
172
|
+
: 'These add-ons are included with your gift'
|
|
173
|
+
)}
|
|
174
|
+
</BodyText>
|
|
175
|
+
)}
|
|
176
|
+
{gift.add_ons.map((item) =>
|
|
177
|
+
gift.select_add_ons ? (
|
|
178
|
+
<Checkbox
|
|
179
|
+
key={item.id}
|
|
180
|
+
label={item.name}
|
|
181
|
+
checked={state.draft.add_on_ids.includes(item.id)}
|
|
182
|
+
onChange={() => flow.toggleAddOn(item.id)}
|
|
183
|
+
/>
|
|
184
|
+
) : (
|
|
185
|
+
<BodyText key={item.id}>{item.name}</BodyText>
|
|
186
|
+
)
|
|
187
|
+
)}
|
|
188
|
+
<Checkbox
|
|
189
|
+
checked={state.draft.confirm_terms}
|
|
190
|
+
onChange={(value) => flow.edit({ confirm_terms: value })}
|
|
191
|
+
label={t('I understand that this ticket will belong to the recipient.')}
|
|
192
|
+
/>
|
|
193
|
+
{state.error && <ErrorText>{t(state.error)}</ErrorText>}
|
|
194
|
+
<Button onPress={flow.review}>{t('Review gift')}</Button>
|
|
195
|
+
</View>
|
|
196
|
+
) : gift.status === 'unavailable' ? (
|
|
197
|
+
<BodyText>{t('This ticket cannot be gifted now.')}</BodyText>
|
|
198
|
+
) : null}
|
|
199
|
+
{onDone && (
|
|
200
|
+
<Button variant="ghost" onPress={onDone}>
|
|
201
|
+
{t('View my tickets')}
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
</Card>
|
|
205
|
+
)
|
|
206
|
+
}
|