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,371 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Linking,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import { getConfig, hooks, phoneCallingCodeForInput, phoneCountryLabel, type PhoneAuthenticationFlow } from 'tf-checkout-shared'
|
|
11
|
+
import { LoginForm } from '../auth/LoginForm'
|
|
12
|
+
import { EmailCodeForm } from '../auth/EmailCodeForm'
|
|
13
|
+
import { ForgotPasswordForm } from '../auth/ForgotPasswordForm'
|
|
14
|
+
import { RegisterForm } from '../auth/RegisterForm'
|
|
15
|
+
import { TwoFactorForm } from '../auth/TwoFactorForm'
|
|
16
|
+
import { Button, ErrorText, MutedText } from '../ui/primitives'
|
|
17
|
+
import { Checkbox, Field, Input } from '../ui/Field'
|
|
18
|
+
import { Select, type SelectOption } from '../ui/Select'
|
|
19
|
+
import { FrozenFields } from '../ui/Frozen'
|
|
20
|
+
import { useTheme } from '../ui/CheckoutProvider'
|
|
21
|
+
import { useAccountT } from '../ui/useAccountT'
|
|
22
|
+
|
|
23
|
+
export interface PhoneAuthenticationProps {
|
|
24
|
+
flow: PhoneAuthenticationFlow
|
|
25
|
+
locale?: string
|
|
26
|
+
showHeader?: boolean
|
|
27
|
+
allowCreation?: boolean
|
|
28
|
+
allowPasswordReset?: boolean
|
|
29
|
+
countryOptions?: SelectOption<string>[]
|
|
30
|
+
onOpenTerms?: () => void
|
|
31
|
+
onOpenPrivacy?: () => void
|
|
32
|
+
style?: StyleProp<ViewStyle>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Native presentation over one common owner; no OTP, admission or linking policy here. */
|
|
36
|
+
export function PhoneAuthentication({
|
|
37
|
+
flow,
|
|
38
|
+
locale,
|
|
39
|
+
showHeader = true,
|
|
40
|
+
allowCreation = true,
|
|
41
|
+
allowPasswordReset = true,
|
|
42
|
+
countryOptions,
|
|
43
|
+
onOpenTerms,
|
|
44
|
+
onOpenPrivacy,
|
|
45
|
+
style,
|
|
46
|
+
}: PhoneAuthenticationProps) {
|
|
47
|
+
const t = useAccountT(locale)
|
|
48
|
+
const theme = useTheme()
|
|
49
|
+
const { state, emailAuth: auth } = flow
|
|
50
|
+
const countries = hooks.useCountries({
|
|
51
|
+
enabled: !countryOptions && state.phase === 'phone',
|
|
52
|
+
})
|
|
53
|
+
const choices =
|
|
54
|
+
countryOptions ??
|
|
55
|
+
(countries.data ?? []).flatMap((country) =>
|
|
56
|
+
typeof country.code === 'string' && /^[a-z]{2}$/i.test(country.code)
|
|
57
|
+
? [{ value: country.code.toUpperCase(), label: phoneCountryLabel(country.name, country.code) }]
|
|
58
|
+
: []
|
|
59
|
+
)
|
|
60
|
+
const busy = state.busy || auth.isSubmitting
|
|
61
|
+
const callingCode = phoneCallingCodeForInput(state.phone)
|
|
62
|
+
const hasSelectedCountry = choices.some((choice) => choice.value === state.country)
|
|
63
|
+
const secondFactor = auth.view === 'two-factor' && Boolean(auth.twoFactorChallenge)
|
|
64
|
+
const heading = (label: string) =>
|
|
65
|
+
showHeader ? (
|
|
66
|
+
<Text style={[s.heading, { color: theme.text, fontFamily: theme.fontFamily }]}>
|
|
67
|
+
{t(label)}
|
|
68
|
+
</Text>
|
|
69
|
+
) : null
|
|
70
|
+
const button = (label: string, onPress: () => void, disabled = busy) => (
|
|
71
|
+
<Button variant="ghost" onPress={onPress} disabled={disabled}>
|
|
72
|
+
{t(label)}
|
|
73
|
+
</Button>
|
|
74
|
+
)
|
|
75
|
+
const emailView = () => {
|
|
76
|
+
const completion = auth.accountCompletion
|
|
77
|
+
if (auth.view === 'two-factor' && auth.twoFactorChallenge)
|
|
78
|
+
return (
|
|
79
|
+
<TwoFactorForm
|
|
80
|
+
loading={auth.isSubmitting}
|
|
81
|
+
error={auth.error}
|
|
82
|
+
successMessage={auth.successMessage}
|
|
83
|
+
smsAvailable={auth.twoFactorChallenge.smsAvailable}
|
|
84
|
+
smsRetryAfter={auth.smsRetryAfter}
|
|
85
|
+
onSubmit={auth.submitTwoFactor}
|
|
86
|
+
onSendSms={auth.sendTwoFactorSms}
|
|
87
|
+
onCancel={auth.cancelTwoFactor}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
if (auth.view === 'email-code')
|
|
91
|
+
return (
|
|
92
|
+
<EmailCodeForm
|
|
93
|
+
initialEmail={auth.suggestedEmail}
|
|
94
|
+
sent={auth.emailCodeSent}
|
|
95
|
+
target={auth.emailCodeTarget}
|
|
96
|
+
retryAfter={auth.emailRetryAfter}
|
|
97
|
+
loading={auth.isSubmitting}
|
|
98
|
+
error={auth.error}
|
|
99
|
+
successMessage={auth.successMessage}
|
|
100
|
+
onRequest={auth.requestEmailCode}
|
|
101
|
+
onVerify={auth.submitEmailCode}
|
|
102
|
+
onBack={() => auth.showLogin(auth.suggestedEmail)}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
if (auth.view === 'forgot-password')
|
|
106
|
+
return (
|
|
107
|
+
<ForgotPasswordForm
|
|
108
|
+
initialEmail={auth.suggestedEmail}
|
|
109
|
+
errors={auth.fieldErrors}
|
|
110
|
+
error={auth.error}
|
|
111
|
+
successMessage={auth.successMessage}
|
|
112
|
+
loading={auth.isSubmitting}
|
|
113
|
+
onSubmit={auth.submitPasswordReset}
|
|
114
|
+
onBackToLogin={auth.showLogin}
|
|
115
|
+
onFieldChange={auth.clearFeedback}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
if (auth.view === 'register' && allowCreation)
|
|
119
|
+
return (
|
|
120
|
+
<RegisterForm
|
|
121
|
+
initialEmail={auth.suggestedEmail}
|
|
122
|
+
errors={auth.fieldErrors}
|
|
123
|
+
error={auth.error}
|
|
124
|
+
loading={auth.isSubmitting}
|
|
125
|
+
requireAddress={auth.requireRegistrationAddress}
|
|
126
|
+
requireTerms={Boolean(auth.accountCompletion)}
|
|
127
|
+
completionRefresh={
|
|
128
|
+
completion
|
|
129
|
+
? {
|
|
130
|
+
retryAfter: auth.emailRetryAfter,
|
|
131
|
+
onRequest: () => auth.requestEmailCode(completion.email),
|
|
132
|
+
onVerify: auth.submitEmailCode,
|
|
133
|
+
}
|
|
134
|
+
: undefined
|
|
135
|
+
}
|
|
136
|
+
onSubmit={auth.submitRegistration}
|
|
137
|
+
onFieldChange={auth.clearFieldError}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
return (
|
|
141
|
+
<View style={s.panel}>
|
|
142
|
+
<LoginForm
|
|
143
|
+
initialEmail={auth.suggestedEmail}
|
|
144
|
+
errors={auth.fieldErrors}
|
|
145
|
+
error={auth.error}
|
|
146
|
+
loading={auth.isSubmitting}
|
|
147
|
+
allowPasswordReset={allowPasswordReset}
|
|
148
|
+
onSubmit={auth.submitLogin}
|
|
149
|
+
onForgotPassword={auth.showForgotPassword}
|
|
150
|
+
onFieldChange={auth.clearFieldError}
|
|
151
|
+
/>
|
|
152
|
+
{auth.emailCodeAvailable
|
|
153
|
+
? button('Sign in with an email code', () =>
|
|
154
|
+
auth.showEmailCode(auth.suggestedEmail)
|
|
155
|
+
)
|
|
156
|
+
: null}
|
|
157
|
+
{allowCreation
|
|
158
|
+
? button('Create an account with email', flow.chooseEmailRegistration)
|
|
159
|
+
: null}
|
|
160
|
+
{auth.browserSignInAvailable
|
|
161
|
+
? (['google', 'apple', 'facebook', 'passkey'] as const).map((provider) => (
|
|
162
|
+
<Button
|
|
163
|
+
key={provider}
|
|
164
|
+
variant="ghost"
|
|
165
|
+
disabled={busy}
|
|
166
|
+
onPress={() => void auth.submitBrowserSignIn(provider)}
|
|
167
|
+
>
|
|
168
|
+
{t(
|
|
169
|
+
provider === 'passkey'
|
|
170
|
+
? 'Sign in with a passkey'
|
|
171
|
+
: provider === 'google'
|
|
172
|
+
? 'Sign in with Google'
|
|
173
|
+
: provider === 'apple'
|
|
174
|
+
? 'Sign in with Apple'
|
|
175
|
+
: 'Sign in with Facebook'
|
|
176
|
+
)}
|
|
177
|
+
</Button>
|
|
178
|
+
))
|
|
179
|
+
: null}
|
|
180
|
+
</View>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return (
|
|
184
|
+
<View style={[s.panel, style]}>
|
|
185
|
+
{state.error ? <ErrorText>{t(state.error)}</ErrorText> : null}
|
|
186
|
+
{state.successMessage ? <MutedText>{t(state.successMessage)}</MutedText> : null}
|
|
187
|
+
{!secondFactor && state.phase === 'phone' ? (
|
|
188
|
+
<View style={s.panel}>
|
|
189
|
+
{heading('Sign in or sign up')}
|
|
190
|
+
<MutedText>
|
|
191
|
+
{t(
|
|
192
|
+
'We’ll send a one-time SMS code. You can still use email to access your existing account.'
|
|
193
|
+
)}
|
|
194
|
+
</MutedText>
|
|
195
|
+
<Field label={t('Country')}>
|
|
196
|
+
<Select
|
|
197
|
+
value={state.country}
|
|
198
|
+
options={choices}
|
|
199
|
+
disabled={busy}
|
|
200
|
+
onChange={flow.setCountry}
|
|
201
|
+
placeholder={!hasSelectedCountry && callingCode ? `+${callingCode}` : t('Country')}
|
|
202
|
+
/>
|
|
203
|
+
</Field>
|
|
204
|
+
{countries.isError && !countryOptions ? (
|
|
205
|
+
<View>
|
|
206
|
+
<ErrorText>{t('Unable to load countries. Please try again.')}</ErrorText>
|
|
207
|
+
{button('Try again', () => void countries.refetch())}
|
|
208
|
+
</View>
|
|
209
|
+
) : null}
|
|
210
|
+
<Field label={t('Phone number')} required>
|
|
211
|
+
<Input
|
|
212
|
+
value={state.phone}
|
|
213
|
+
onChangeText={flow.setPhone}
|
|
214
|
+
accessibilityLabel={t('Phone number')}
|
|
215
|
+
placeholder="+1 202 555 0123"
|
|
216
|
+
keyboardType="phone-pad"
|
|
217
|
+
textContentType="telephoneNumber"
|
|
218
|
+
autoComplete="tel"
|
|
219
|
+
editable={!busy}
|
|
220
|
+
onSubmitEditing={() => void flow.requestCode()}
|
|
221
|
+
/>
|
|
222
|
+
</Field>
|
|
223
|
+
<Button
|
|
224
|
+
disabled={busy || !state.phone.trim()}
|
|
225
|
+
loading={state.busy}
|
|
226
|
+
onPress={() => void flow.requestCode()}
|
|
227
|
+
>
|
|
228
|
+
{t('Send code')}
|
|
229
|
+
</Button>
|
|
230
|
+
{button('Continue with Email', flow.chooseEmail)}
|
|
231
|
+
</View>
|
|
232
|
+
) : null}
|
|
233
|
+
{!secondFactor && state.phase === 'code' ? (
|
|
234
|
+
<View style={s.panel}>
|
|
235
|
+
{heading('Verify your phone')}
|
|
236
|
+
<MutedText>
|
|
237
|
+
{t('Enter the six-digit code sent to {{phone}}.', { phone: state.phone })}
|
|
238
|
+
</MutedText>
|
|
239
|
+
<Field label={t('Verification code')} required>
|
|
240
|
+
<Input
|
|
241
|
+
value={state.code}
|
|
242
|
+
onChangeText={flow.setCode}
|
|
243
|
+
accessibilityLabel={t('Verification code')}
|
|
244
|
+
keyboardType="number-pad"
|
|
245
|
+
textContentType="oneTimeCode"
|
|
246
|
+
autoComplete="one-time-code"
|
|
247
|
+
maxLength={6}
|
|
248
|
+
editable={!busy}
|
|
249
|
+
onSubmitEditing={() => void flow.verifyCode()}
|
|
250
|
+
/>
|
|
251
|
+
</Field>
|
|
252
|
+
<Button
|
|
253
|
+
loading={state.busy}
|
|
254
|
+
disabled={busy || !/^\d{6}$/.test(state.code)}
|
|
255
|
+
onPress={() => void flow.verifyCode()}
|
|
256
|
+
>
|
|
257
|
+
{t('Verify code')}
|
|
258
|
+
</Button>
|
|
259
|
+
{button(
|
|
260
|
+
state.retryAfter > 0
|
|
261
|
+
? t('Resend in {{seconds}}s', { seconds: state.retryAfter })
|
|
262
|
+
: 'Resend code',
|
|
263
|
+
() => void flow.requestCode(),
|
|
264
|
+
busy || state.retryAfter > 0
|
|
265
|
+
)}
|
|
266
|
+
{button('Change phone number', flow.back)}
|
|
267
|
+
{button('Continue with Email', flow.chooseEmail)}
|
|
268
|
+
</View>
|
|
269
|
+
) : null}
|
|
270
|
+
{!secondFactor && state.phase === 'choose_account' ? (
|
|
271
|
+
<View style={s.panel}>
|
|
272
|
+
{heading('Your number is verified')}
|
|
273
|
+
<MutedText>
|
|
274
|
+
{t(
|
|
275
|
+
'Already have a Ticket Fairy account? Sign in with email to keep your tickets and account together.'
|
|
276
|
+
)}
|
|
277
|
+
</MutedText>
|
|
278
|
+
<Button disabled={busy} onPress={flow.chooseEmail}>
|
|
279
|
+
{t('Sign in to my existing account')}
|
|
280
|
+
</Button>
|
|
281
|
+
{allowCreation ? (
|
|
282
|
+
<>
|
|
283
|
+
<MutedText>
|
|
284
|
+
{t(
|
|
285
|
+
'New to Ticket Fairy? Create an account with this verified number. You can add email later.'
|
|
286
|
+
)}
|
|
287
|
+
</MutedText>
|
|
288
|
+
<FrozenFields frozen={busy}>
|
|
289
|
+
<Checkbox
|
|
290
|
+
checked={state.termsAccepted}
|
|
291
|
+
onChange={flow.setTermsAccepted}
|
|
292
|
+
label={t(
|
|
293
|
+
'By creating an account, I accept the Terms of Service and Privacy Policy.'
|
|
294
|
+
)}
|
|
295
|
+
/>
|
|
296
|
+
</FrozenFields>
|
|
297
|
+
<View style={s.links}>
|
|
298
|
+
{button('Terms of Service', () =>
|
|
299
|
+
onOpenTerms
|
|
300
|
+
? onOpenTerms()
|
|
301
|
+
: void Linking.openURL(getConfig().host + '/terms-of-service')
|
|
302
|
+
)}
|
|
303
|
+
{button('Privacy Policy', () =>
|
|
304
|
+
onOpenPrivacy
|
|
305
|
+
? onOpenPrivacy()
|
|
306
|
+
: void Linking.openURL(getConfig().host + '/privacy')
|
|
307
|
+
)}
|
|
308
|
+
</View>
|
|
309
|
+
<Button
|
|
310
|
+
disabled={busy || !state.termsAccepted}
|
|
311
|
+
loading={state.busy}
|
|
312
|
+
onPress={() => void flow.createAccount()}
|
|
313
|
+
>
|
|
314
|
+
{t('Create a new account')}
|
|
315
|
+
</Button>
|
|
316
|
+
</>
|
|
317
|
+
) : null}
|
|
318
|
+
</View>
|
|
319
|
+
) : null}
|
|
320
|
+
{secondFactor || state.phase === 'email' ? (
|
|
321
|
+
<View style={s.panel}>
|
|
322
|
+
{heading(
|
|
323
|
+
secondFactor
|
|
324
|
+
? 'Verify your sign in'
|
|
325
|
+
: auth.view === 'forgot-password'
|
|
326
|
+
? 'Password reset'
|
|
327
|
+
: auth.view === 'register'
|
|
328
|
+
? 'Create an account with email'
|
|
329
|
+
: 'Sign in to your existing account'
|
|
330
|
+
)}
|
|
331
|
+
{emailView()}
|
|
332
|
+
{!secondFactor ? button('Continue with phone', flow.back) : null}
|
|
333
|
+
</View>
|
|
334
|
+
) : null}
|
|
335
|
+
{!secondFactor && state.phase === 'confirm_link' ? (
|
|
336
|
+
<View style={s.panel}>
|
|
337
|
+
{heading('Link your phone number')}
|
|
338
|
+
<MutedText>
|
|
339
|
+
{state.linkAvailable === false
|
|
340
|
+
? t('You are signed in. Verify your phone again to link this number.')
|
|
341
|
+
: t(
|
|
342
|
+
'Link {{phone}} to {{account}} so you can sign in to the same account with your phone.',
|
|
343
|
+
{ phone: state.phone, account: state.linkAccountLabel ?? '' }
|
|
344
|
+
)}
|
|
345
|
+
</MutedText>
|
|
346
|
+
{state.linkAvailable !== false ? (
|
|
347
|
+
<Button
|
|
348
|
+
disabled={busy}
|
|
349
|
+
loading={state.busy}
|
|
350
|
+
onPress={() => void flow.confirmLink()}
|
|
351
|
+
>
|
|
352
|
+
{t('Link this number')}
|
|
353
|
+
</Button>
|
|
354
|
+
) : (
|
|
355
|
+
button('Verify your phone again', flow.back)
|
|
356
|
+
)}
|
|
357
|
+
{button('Not now', flow.skipLink)}
|
|
358
|
+
</View>
|
|
359
|
+
) : null}
|
|
360
|
+
{!secondFactor && state.phase === 'signed_in' ? (
|
|
361
|
+
<MutedText>{t('You are signed in.')}</MutedText>
|
|
362
|
+
) : null}
|
|
363
|
+
{state.phase !== 'signed_in' ? button('Cancel', flow.cancel) : null}
|
|
364
|
+
</View>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
const s = StyleSheet.create({
|
|
368
|
+
panel: { gap: 16 },
|
|
369
|
+
heading: { fontSize: 26, fontWeight: '500' },
|
|
370
|
+
links: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
|
|
371
|
+
})
|
|
@@ -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
|
+
})
|