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,214 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Linking,
|
|
5
|
+
Modal,
|
|
6
|
+
SafeAreaView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native'
|
|
11
|
+
import { WebView } from 'react-native-webview'
|
|
12
|
+
import {
|
|
13
|
+
createProviderPages,
|
|
14
|
+
providerPageMatchesReturn,
|
|
15
|
+
providerPageOutcomeFromMessage,
|
|
16
|
+
type PendingProviderPage,
|
|
17
|
+
} from 'tf-checkout-shared'
|
|
18
|
+
import { Button, ErrorText } from './primitives'
|
|
19
|
+
import { useTheme } from './CheckoutProvider'
|
|
20
|
+
import { resolveTheme } from './theme'
|
|
21
|
+
|
|
22
|
+
const hasUnsafeNativeUriCharacters = (uri: string): boolean => {
|
|
23
|
+
for (const character of uri) {
|
|
24
|
+
const code = character.charCodeAt(0)
|
|
25
|
+
if (code <= 0x20 || code === 0x7f || character === '\\') return true
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reject unsupported initial URI sources before WebView's native load path.
|
|
31
|
+
* Hostless sources can reach WKWebView.loadFileURL before navigation policy runs. */
|
|
32
|
+
const secureInitialUri = (uri: string): boolean => {
|
|
33
|
+
// URL parsers normalize scheme-only/slash variants that NSURL can treat as
|
|
34
|
+
// hostless file sources. Require the original string's HTTPS authority.
|
|
35
|
+
if (hasUnsafeNativeUriCharacters(uri)) return false
|
|
36
|
+
if (!/^https:\/\/[^/?#\s\\]+(?:[/?#][^\s\\]*)?$/i.test(uri)) return false
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(uri)
|
|
39
|
+
return (
|
|
40
|
+
url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password
|
|
41
|
+
)
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Preserve provider app links without handing their hostless URL to WKWebView. */
|
|
48
|
+
const externalInitialUri = (uri: string): boolean => {
|
|
49
|
+
if (hasUnsafeNativeUriCharacters(uri)) return false
|
|
50
|
+
const match = /^([a-z][a-z\d+.-]*):.+$/i.exec(uri)
|
|
51
|
+
return Boolean(
|
|
52
|
+
match &&
|
|
53
|
+
!['http', 'https', 'about', 'file', 'javascript', 'data', 'blob'].includes(
|
|
54
|
+
match[1].toLowerCase()
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Native window plumbing over the same return/message contract as web. */
|
|
60
|
+
export const useProviderPages = () => {
|
|
61
|
+
const [pending, setPending] = useState<PendingProviderPage | null>(null)
|
|
62
|
+
const [pages] = useState(() => createProviderPages(setPending))
|
|
63
|
+
useEffect(() => () => pages.cancel(), [pages])
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!pending) return
|
|
66
|
+
const subscription = Linking.addEventListener('url', ({ url }) => {
|
|
67
|
+
if (providerPageMatchesReturn(url, pending.request.returnUrlPrefix))
|
|
68
|
+
pages.settle(pending.id, { event: 'success', returnUrl: url })
|
|
69
|
+
})
|
|
70
|
+
return () => subscription.remove()
|
|
71
|
+
}, [pending, pages])
|
|
72
|
+
return {
|
|
73
|
+
providerPage: pages.open,
|
|
74
|
+
page: pending ? (
|
|
75
|
+
<ProviderPage key={pending.id} pending={pending} settle={pages.settle} />
|
|
76
|
+
) : null,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ProviderPage = ({
|
|
81
|
+
pending,
|
|
82
|
+
settle,
|
|
83
|
+
}: {
|
|
84
|
+
pending: PendingProviderPage
|
|
85
|
+
settle: ReturnType<typeof createProviderPages>['settle']
|
|
86
|
+
}) => {
|
|
87
|
+
const theme = useTheme()
|
|
88
|
+
const backdrop = resolveTheme({ colorScheme: theme.colorScheme }).background
|
|
89
|
+
const externalUri =
|
|
90
|
+
pending.request.uri && externalInitialUri(pending.request.uri)
|
|
91
|
+
? pending.request.uri
|
|
92
|
+
: null
|
|
93
|
+
const sourceError =
|
|
94
|
+
pending.request.uri && !secureInitialUri(pending.request.uri) && !externalUri
|
|
95
|
+
? 'This payment page could not be opened securely.'
|
|
96
|
+
: ''
|
|
97
|
+
const [error, setError] = useState('')
|
|
98
|
+
const displayedError = sourceError || error
|
|
99
|
+
const [reload, setReload] = useState(0)
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!externalUri) return
|
|
102
|
+
let active = true
|
|
103
|
+
void Linking.openURL(externalUri).catch(() => {
|
|
104
|
+
if (active)
|
|
105
|
+
setError(
|
|
106
|
+
'Unable to open this payment app. Close this page to return to checkout and check the payment status.'
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
return () => {
|
|
110
|
+
active = false
|
|
111
|
+
}
|
|
112
|
+
}, [externalUri, reload])
|
|
113
|
+
const close = () => settle(pending.id, { event: 'dismiss' })
|
|
114
|
+
const source = pending.request.uri
|
|
115
|
+
? { uri: pending.request.uri }
|
|
116
|
+
: { html: pending.request.html ?? '', baseUrl: pending.request.baseUrl }
|
|
117
|
+
return (
|
|
118
|
+
<Modal
|
|
119
|
+
visible
|
|
120
|
+
animationType="slide"
|
|
121
|
+
presentationStyle="fullScreen"
|
|
122
|
+
onRequestClose={close}
|
|
123
|
+
>
|
|
124
|
+
<View style={[styles.root, { backgroundColor: backdrop }]}>
|
|
125
|
+
<SafeAreaView style={[styles.root, { backgroundColor: theme.background }]}>
|
|
126
|
+
<View style={styles.toolbar}>
|
|
127
|
+
<Button variant="ghost" onPress={close}>
|
|
128
|
+
Close payment page
|
|
129
|
+
</Button>
|
|
130
|
+
</View>
|
|
131
|
+
{displayedError ? (
|
|
132
|
+
<View style={styles.failure}>
|
|
133
|
+
<ErrorText>{displayedError}</ErrorText>
|
|
134
|
+
{!sourceError ? (
|
|
135
|
+
<Button
|
|
136
|
+
onPress={() => {
|
|
137
|
+
setError('')
|
|
138
|
+
setReload((value) => value + 1)
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
Try again
|
|
142
|
+
</Button>
|
|
143
|
+
) : null}
|
|
144
|
+
</View>
|
|
145
|
+
) : externalUri ? (
|
|
146
|
+
<View style={styles.external}>
|
|
147
|
+
<ActivityIndicator color={theme.accent} />
|
|
148
|
+
<Text style={{ color: theme.text, textAlign: 'center' }}>
|
|
149
|
+
Complete payment in your payment app, then return here.
|
|
150
|
+
</Text>
|
|
151
|
+
</View>
|
|
152
|
+
) : (
|
|
153
|
+
<WebView
|
|
154
|
+
key={reload}
|
|
155
|
+
source={source}
|
|
156
|
+
style={styles.webview}
|
|
157
|
+
javaScriptEnabled
|
|
158
|
+
domStorageEnabled
|
|
159
|
+
incognito
|
|
160
|
+
sharedCookiesEnabled={false}
|
|
161
|
+
thirdPartyCookiesEnabled={false}
|
|
162
|
+
setSupportMultipleWindows={false}
|
|
163
|
+
originWhitelist={['*']}
|
|
164
|
+
startInLoadingState
|
|
165
|
+
renderLoading={() => (
|
|
166
|
+
<ActivityIndicator style={styles.loading} color={theme.accent} />
|
|
167
|
+
)}
|
|
168
|
+
onShouldStartLoadWithRequest={({ url }) => {
|
|
169
|
+
if (providerPageMatchesReturn(url, pending.request.returnUrlPrefix)) {
|
|
170
|
+
settle(pending.id, { event: 'success', returnUrl: url })
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
if (/^(https:|about:)/i.test(url)) return true
|
|
174
|
+
if (/^(http:|javascript:|data:|file:)/i.test(url)) {
|
|
175
|
+
setError('This payment page could not be opened securely.')
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
Linking.openURL(url).catch(() =>
|
|
179
|
+
setError(
|
|
180
|
+
'Unable to open this payment app. Close this page to return to checkout and check the payment status.'
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return false
|
|
184
|
+
}}
|
|
185
|
+
onMessage={({ nativeEvent }) => {
|
|
186
|
+
const outcome = providerPageOutcomeFromMessage(nativeEvent.data)
|
|
187
|
+
if (outcome) settle(pending.id, outcome)
|
|
188
|
+
}}
|
|
189
|
+
onError={() =>
|
|
190
|
+
setError(
|
|
191
|
+
'The payment page could not load. Check your connection and try again.'
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
</SafeAreaView>
|
|
197
|
+
</View>
|
|
198
|
+
</Modal>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
const styles = StyleSheet.create({
|
|
202
|
+
root: { flex: 1 },
|
|
203
|
+
toolbar: { padding: 12 },
|
|
204
|
+
webview: { flex: 1 },
|
|
205
|
+
external: {
|
|
206
|
+
flex: 1,
|
|
207
|
+
padding: 24,
|
|
208
|
+
gap: 16,
|
|
209
|
+
justifyContent: 'center',
|
|
210
|
+
alignItems: 'center',
|
|
211
|
+
},
|
|
212
|
+
failure: { padding: 24, gap: 16 },
|
|
213
|
+
loading: { ...StyleSheet.absoluteFillObject },
|
|
214
|
+
})
|
|
@@ -10,6 +10,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'
|
|
|
10
10
|
|
|
11
11
|
import { useTheme } from './CheckoutProvider'
|
|
12
12
|
import type { ResolvedTheme } from './theme'
|
|
13
|
+
import { useFrozen } from './Frozen'
|
|
13
14
|
|
|
14
15
|
export interface QuantitySelectProps {
|
|
15
16
|
value: number
|
|
@@ -20,6 +21,12 @@ export interface QuantitySelectProps {
|
|
|
20
21
|
/** Step between quantities (the old checkout's `multiplier`). */
|
|
21
22
|
step?: number
|
|
22
23
|
disabled?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Accessible name, for example "Quantity of Weekend bundle". With a label
|
|
26
|
+
* the stepper is one adjustable control: a screen reader names it, reads
|
|
27
|
+
* the value and swipes up or down to change it.
|
|
28
|
+
*/
|
|
29
|
+
label?: string
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export const QuantitySelect: FC<QuantitySelectProps> = ({
|
|
@@ -28,15 +35,19 @@ export const QuantitySelect: FC<QuantitySelectProps> = ({
|
|
|
28
35
|
min = 0,
|
|
29
36
|
max = 10,
|
|
30
37
|
step = 1,
|
|
31
|
-
disabled = false,
|
|
38
|
+
disabled: disabledProp = false,
|
|
39
|
+
label,
|
|
32
40
|
}) => {
|
|
41
|
+
const frozen = useFrozen()
|
|
42
|
+
const disabled = disabledProp || frozen
|
|
33
43
|
const theme = useTheme()
|
|
34
44
|
const s = useQuantityStyles(theme)
|
|
35
45
|
// 0 (buy none) is always offered; the first non-zero step honors the type's
|
|
36
46
|
// minimum, then steps up to the cap — mirroring the web dropdown's option grid
|
|
37
47
|
// (0, lo, lo + step, …) so a min-2 type goes 0 → 2 → 3, never 0 → 1.
|
|
38
48
|
const lo = Math.max(1, min)
|
|
39
|
-
const
|
|
49
|
+
const limit = Math.min(max, 50)
|
|
50
|
+
const cap = limit < lo ? 0 : lo + Math.floor((limit - lo) / step) * step
|
|
40
51
|
const atZero = value <= 0
|
|
41
52
|
const atCap = value >= cap
|
|
42
53
|
|
|
@@ -50,7 +61,21 @@ export const QuantitySelect: FC<QuantitySelectProps> = ({
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
return (
|
|
53
|
-
<View
|
|
64
|
+
<View
|
|
65
|
+
style={s.stepper}
|
|
66
|
+
accessible={Boolean(label)}
|
|
67
|
+
accessibilityRole={label ? 'adjustable' : undefined}
|
|
68
|
+
accessibilityLabel={label}
|
|
69
|
+
accessibilityValue={label ? { now: value, min: 0, max: cap } : undefined}
|
|
70
|
+
accessibilityState={label ? { disabled } : undefined}
|
|
71
|
+
accessibilityActions={
|
|
72
|
+
label ? [{ name: 'increment' }, { name: 'decrement' }] : undefined
|
|
73
|
+
}
|
|
74
|
+
onAccessibilityAction={(e) => {
|
|
75
|
+
if (e.nativeEvent.actionName === 'increment' && !atCap) inc()
|
|
76
|
+
if (e.nativeEvent.actionName === 'decrement' && !atZero) dec()
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
54
79
|
<Pressable
|
|
55
80
|
accessibilityRole="button"
|
|
56
81
|
accessibilityLabel="Remove one"
|
package/src/ui/Select.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native
|
|
|
9
9
|
|
|
10
10
|
import { useTheme } from './CheckoutProvider'
|
|
11
11
|
import type { ResolvedTheme } from './theme'
|
|
12
|
+
import { useFrozen } from './Frozen'
|
|
12
13
|
|
|
13
14
|
export interface SelectOption<T = string> {
|
|
14
15
|
label: string
|
|
@@ -21,7 +22,7 @@ export function Select<T = string>({
|
|
|
21
22
|
options,
|
|
22
23
|
placeholder = 'Select…',
|
|
23
24
|
invalid = false,
|
|
24
|
-
disabled = false,
|
|
25
|
+
disabled: disabledProp = false,
|
|
25
26
|
}: {
|
|
26
27
|
value: T | null | undefined
|
|
27
28
|
onChange: (value: T) => void
|
|
@@ -30,6 +31,8 @@ export function Select<T = string>({
|
|
|
30
31
|
invalid?: boolean
|
|
31
32
|
disabled?: boolean
|
|
32
33
|
}) {
|
|
34
|
+
const frozen = useFrozen()
|
|
35
|
+
const disabled = disabledProp || frozen
|
|
33
36
|
const theme = useTheme()
|
|
34
37
|
const s = useSelectStyles(theme)
|
|
35
38
|
const [open, setOpen] = useState(false)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<SignaturePad/>` — the native drawing area for a drawn signature. Built
|
|
3
|
+
* from React Native's own `PanResponder` and `View`s, so it adds no native
|
|
4
|
+
* module to the consumer app (`react-native-svg` is not one of the SDK's
|
|
5
|
+
* peers). Each segment of a stroke renders as a thin, rotated `View`; when a
|
|
6
|
+
* stroke ends the pad reports a PNG data URL made by the shared core and the
|
|
7
|
+
* ink drawn so far (`signatureStrokeReport`). '' and 0 once cleared. A stroke
|
|
8
|
+
* the system interrupts (the app going to the background, an OS gesture)
|
|
9
|
+
* ends the same way as a released one, so what is saved is what is shown.
|
|
10
|
+
*
|
|
11
|
+
* Drawing stays smooth on long signatures: points live in a ref, a point
|
|
12
|
+
* closer than `MIN_STEP` to the last one is not kept, the pad re-renders at
|
|
13
|
+
* most once a frame, and a finished stroke never re-renders (`StrokeView`).
|
|
14
|
+
*
|
|
15
|
+
* The pad keeps the gesture inside a `ScrollView` (it refuses to hand the
|
|
16
|
+
* responder over), so drawing does not scroll the page.
|
|
17
|
+
*/
|
|
18
|
+
import React, { memo, useEffect, useMemo, useRef, useState, type FC } from 'react'
|
|
19
|
+
import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
20
|
+
import {
|
|
21
|
+
SIGNATURE_INK,
|
|
22
|
+
SIGNATURE_PAPER,
|
|
23
|
+
signatureStrokeReport,
|
|
24
|
+
type SignatureStroke,
|
|
25
|
+
} from 'tf-checkout-shared'
|
|
26
|
+
|
|
27
|
+
import { useTheme } from './CheckoutProvider'
|
|
28
|
+
|
|
29
|
+
export interface SignaturePadProps {
|
|
30
|
+
onChange: (pngDataUrl: string, inkLength: number) => void
|
|
31
|
+
/** Accessible name of the drawing area. */
|
|
32
|
+
label: string
|
|
33
|
+
clearLabel: string
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const LINE_WIDTH = 2.5
|
|
38
|
+
const PAD_HEIGHT = 160
|
|
39
|
+
/** Pad points between kept points: finer moves add views, not detail. */
|
|
40
|
+
const MIN_STEP = 1.5
|
|
41
|
+
|
|
42
|
+
/** One stroke's segments. `points` changes only while the stroke is drawn. */
|
|
43
|
+
const StrokeView = memo<{ stroke: SignatureStroke; points: number }>(({ stroke }) =>
|
|
44
|
+
stroke.length === 1 ? (
|
|
45
|
+
<View
|
|
46
|
+
pointerEvents="none"
|
|
47
|
+
style={[
|
|
48
|
+
styles.dot,
|
|
49
|
+
{ left: stroke[0].x - LINE_WIDTH / 2, top: stroke[0].y - LINE_WIDTH / 2 },
|
|
50
|
+
]}
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<>
|
|
54
|
+
{stroke.slice(1).map((p, i) => {
|
|
55
|
+
const a = stroke[i]
|
|
56
|
+
const length = Math.hypot(p.x - a.x, p.y - a.y)
|
|
57
|
+
const angle = Math.atan2(p.y - a.y, p.x - a.x)
|
|
58
|
+
return (
|
|
59
|
+
<View
|
|
60
|
+
key={i}
|
|
61
|
+
pointerEvents="none"
|
|
62
|
+
style={[
|
|
63
|
+
styles.segment,
|
|
64
|
+
{
|
|
65
|
+
width: length + LINE_WIDTH,
|
|
66
|
+
left: (a.x + p.x) / 2 - (length + LINE_WIDTH) / 2,
|
|
67
|
+
top: (a.y + p.y) / 2 - LINE_WIDTH / 2,
|
|
68
|
+
transform: [{ rotate: `${angle}rad` }],
|
|
69
|
+
},
|
|
70
|
+
]}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
})}
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
StrokeView.displayName = 'StrokeView'
|
|
78
|
+
|
|
79
|
+
export const SignaturePad: FC<SignaturePadProps> = ({
|
|
80
|
+
onChange,
|
|
81
|
+
label,
|
|
82
|
+
clearLabel,
|
|
83
|
+
disabled,
|
|
84
|
+
}) => {
|
|
85
|
+
const theme = useTheme()
|
|
86
|
+
const strokesRef = useRef<SignatureStroke[]>([])
|
|
87
|
+
// Bumped to re-render; the strokes themselves stay in the ref.
|
|
88
|
+
const [, setFrame] = useState(0)
|
|
89
|
+
const frameRequest = useRef<number | null>(null)
|
|
90
|
+
const size = useRef({ width: 0, height: PAD_HEIGHT })
|
|
91
|
+
const report = useRef(onChange)
|
|
92
|
+
report.current = onChange
|
|
93
|
+
const disabledRef = useRef(disabled)
|
|
94
|
+
disabledRef.current = disabled
|
|
95
|
+
|
|
96
|
+
const render = () => {
|
|
97
|
+
if (frameRequest.current !== null) cancelAnimationFrame(frameRequest.current)
|
|
98
|
+
frameRequest.current = null
|
|
99
|
+
setFrame((n) => n + 1)
|
|
100
|
+
}
|
|
101
|
+
const renderNextFrame = () => {
|
|
102
|
+
if (frameRequest.current !== null) return
|
|
103
|
+
frameRequest.current = requestAnimationFrame(render)
|
|
104
|
+
}
|
|
105
|
+
useEffect(
|
|
106
|
+
() => () => {
|
|
107
|
+
if (frameRequest.current !== null) cancelAnimationFrame(frameRequest.current)
|
|
108
|
+
},
|
|
109
|
+
[]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// Paints every kept point first, so the report and the pad show the same.
|
|
113
|
+
const endStroke = () => {
|
|
114
|
+
render()
|
|
115
|
+
const ended = signatureStrokeReport(strokesRef.current, {
|
|
116
|
+
padWidth: size.current.width,
|
|
117
|
+
padHeight: size.current.height,
|
|
118
|
+
lineWidth: LINE_WIDTH,
|
|
119
|
+
})
|
|
120
|
+
if (ended) report.current(ended.pngDataUrl, ended.inkLength)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const responder = useMemo(
|
|
124
|
+
() =>
|
|
125
|
+
PanResponder.create({
|
|
126
|
+
onStartShouldSetPanResponder: () => !disabledRef.current,
|
|
127
|
+
onMoveShouldSetPanResponder: () => !disabledRef.current,
|
|
128
|
+
onPanResponderTerminationRequest: () => false,
|
|
129
|
+
onPanResponderGrant: (e) => {
|
|
130
|
+
const { locationX: x, locationY: y } = e.nativeEvent
|
|
131
|
+
strokesRef.current = [...strokesRef.current, [{ x, y }]]
|
|
132
|
+
render()
|
|
133
|
+
},
|
|
134
|
+
onPanResponderMove: (e) => {
|
|
135
|
+
const { locationX: x, locationY: y } = e.nativeEvent
|
|
136
|
+
const last = strokesRef.current[strokesRef.current.length - 1]
|
|
137
|
+
if (!last) return
|
|
138
|
+
const previous = last[last.length - 1]
|
|
139
|
+
if (Math.hypot(x - previous.x, y - previous.y) < MIN_STEP) return
|
|
140
|
+
last.push({ x, y })
|
|
141
|
+
renderNextFrame()
|
|
142
|
+
},
|
|
143
|
+
onPanResponderRelease: endStroke,
|
|
144
|
+
onPanResponderTerminate: endStroke,
|
|
145
|
+
}),
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
[]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const clear = () => {
|
|
151
|
+
strokesRef.current = []
|
|
152
|
+
render()
|
|
153
|
+
report.current('', 0)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<View style={styles.wrap}>
|
|
158
|
+
<View
|
|
159
|
+
accessible
|
|
160
|
+
accessibilityRole="image"
|
|
161
|
+
accessibilityLabel={label}
|
|
162
|
+
onLayout={(e) => {
|
|
163
|
+
size.current = {
|
|
164
|
+
width: e.nativeEvent.layout.width,
|
|
165
|
+
height: e.nativeEvent.layout.height,
|
|
166
|
+
}
|
|
167
|
+
}}
|
|
168
|
+
style={[
|
|
169
|
+
styles.pad,
|
|
170
|
+
{
|
|
171
|
+
borderColor: theme.border,
|
|
172
|
+
borderRadius: theme.radiusControl,
|
|
173
|
+
// The stored image is black on white, so the pad shows the same.
|
|
174
|
+
backgroundColor: SIGNATURE_PAPER,
|
|
175
|
+
},
|
|
176
|
+
]}
|
|
177
|
+
{...responder.panHandlers}
|
|
178
|
+
>
|
|
179
|
+
{strokesRef.current.map((stroke, si) => (
|
|
180
|
+
<StrokeView key={si} stroke={stroke} points={stroke.length} />
|
|
181
|
+
))}
|
|
182
|
+
</View>
|
|
183
|
+
<Pressable
|
|
184
|
+
accessibilityRole="button"
|
|
185
|
+
onPress={clear}
|
|
186
|
+
disabled={disabled}
|
|
187
|
+
style={styles.clear}
|
|
188
|
+
>
|
|
189
|
+
<Text style={{ color: theme.accent, fontFamily: theme.fontFamily }}>
|
|
190
|
+
{clearLabel}
|
|
191
|
+
</Text>
|
|
192
|
+
</Pressable>
|
|
193
|
+
</View>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const styles = StyleSheet.create({
|
|
198
|
+
wrap: { gap: 6 },
|
|
199
|
+
pad: {
|
|
200
|
+
height: PAD_HEIGHT,
|
|
201
|
+
borderWidth: 1,
|
|
202
|
+
overflow: 'hidden',
|
|
203
|
+
},
|
|
204
|
+
segment: {
|
|
205
|
+
position: 'absolute',
|
|
206
|
+
height: LINE_WIDTH,
|
|
207
|
+
borderRadius: LINE_WIDTH / 2,
|
|
208
|
+
backgroundColor: SIGNATURE_INK,
|
|
209
|
+
},
|
|
210
|
+
dot: {
|
|
211
|
+
position: 'absolute',
|
|
212
|
+
width: LINE_WIDTH,
|
|
213
|
+
height: LINE_WIDTH,
|
|
214
|
+
borderRadius: LINE_WIDTH / 2,
|
|
215
|
+
backgroundColor: SIGNATURE_INK,
|
|
216
|
+
},
|
|
217
|
+
clear: { alignSelf: 'flex-start', paddingVertical: 4 },
|
|
218
|
+
})
|
package/src/ui/index.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
export * from './theme'
|
|
3
3
|
export {
|
|
4
4
|
CheckoutProvider,
|
|
5
|
+
CheckoutThemeProvider,
|
|
5
6
|
useCheckoutContext,
|
|
6
7
|
useCheckoutValidation,
|
|
7
8
|
useTheme,
|
|
8
9
|
type CheckoutProviderProps,
|
|
10
|
+
type CheckoutThemeProviderProps,
|
|
9
11
|
} from './CheckoutProvider'
|
|
10
12
|
export * from './primitives'
|
|
13
|
+
export * from './Frozen'
|
|
11
14
|
export * from './Field'
|
|
12
15
|
export * from './Select'
|
|
13
16
|
export * from './QuantitySelect'
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
import { useTheme } from './CheckoutProvider'
|
|
19
19
|
import type { ResolvedTheme } from './theme'
|
|
20
|
+
import { useFrozen } from './Frozen'
|
|
20
21
|
|
|
21
22
|
/** Small spinner. */
|
|
22
23
|
export const Loader: FC<{ size?: 'small' | 'large'; color?: string }> = ({
|
|
@@ -79,7 +80,16 @@ export const MutedText: FC<{ children: ReactNode; style?: StyleProp<TextStyle> }
|
|
|
79
80
|
export const ErrorText: FC<{ children: ReactNode }> = ({ children }) => {
|
|
80
81
|
const theme = useTheme()
|
|
81
82
|
const s = useStyles(theme)
|
|
82
|
-
|
|
83
|
+
// Announced when it appears, like role="alert" on web.
|
|
84
|
+
return (
|
|
85
|
+
<Text
|
|
86
|
+
style={s.errorText}
|
|
87
|
+
accessibilityRole="alert"
|
|
88
|
+
accessibilityLiveRegion="assertive"
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</Text>
|
|
92
|
+
)
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
export interface ButtonProps {
|
|
@@ -99,7 +109,8 @@ export const Button: FC<ButtonProps> = ({
|
|
|
99
109
|
}) => {
|
|
100
110
|
const theme = useTheme()
|
|
101
111
|
const s = useStyles(theme)
|
|
102
|
-
const
|
|
112
|
+
const frozen = useFrozen()
|
|
113
|
+
const isDisabled = disabled || loading || frozen
|
|
103
114
|
const ghost = variant === 'ghost'
|
|
104
115
|
return (
|
|
105
116
|
<Pressable
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
createTranslator,
|
|
4
|
+
resolveLocale,
|
|
5
|
+
translateAccountMessage,
|
|
6
|
+
type TranslateVars,
|
|
7
|
+
} from 'tf-checkout-shared'
|
|
8
|
+
|
|
9
|
+
export const useAccountT = (locale?: string) => {
|
|
10
|
+
const translate = useMemo(
|
|
11
|
+
() => createTranslator({ locale: resolveLocale(locale) }),
|
|
12
|
+
[locale]
|
|
13
|
+
)
|
|
14
|
+
return (english: string, vars?: TranslateVars) =>
|
|
15
|
+
translateAccountMessage(translate, english, vars)
|
|
16
|
+
}
|
package/src/useCheckout.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type UseCheckout,
|
|
19
19
|
} from 'tf-checkout-shared'
|
|
20
20
|
|
|
21
|
+
import { claimDefaultCardField } from './CardInput'
|
|
21
22
|
import { rnConfirmer, rnPorts } from './instance'
|
|
22
23
|
|
|
23
24
|
export interface UseCheckoutOptions {
|
|
@@ -30,6 +31,7 @@ export interface UseCheckoutOptions {
|
|
|
30
31
|
|
|
31
32
|
export const useCheckout = (opts: UseCheckoutOptions = {}): UseCheckout =>
|
|
32
33
|
useSharedCheckout({
|
|
33
|
-
|
|
34
|
+
// A Pay without its own claim uses the default ticket field (`checkout`).
|
|
35
|
+
ports: { confirmer: rnConfirmer, ...rnPorts, claimDefaultCardField, ...opts.ports },
|
|
34
36
|
options: opts.options,
|
|
35
37
|
})
|