tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -8
- package/dist/index.d.mts +7410 -290
- package/dist/index.d.ts +7410 -290
- package/dist/index.js +23539 -5909
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23561 -5857
- package/dist/index.mjs.map +1 -1
- package/package.json +22 -10
- package/scripts/build-seat-map.mjs +31 -0
- package/scripts/seat-map-entry.jsx +22 -0
- package/src/CardInput.tsx +64 -3
- package/src/EmbeddedCheckout.tsx +129 -36
- package/src/auth/AuthModal.tsx +90 -6
- package/src/auth/EmailCodeForm.tsx +91 -0
- package/src/auth/RegisterForm.tsx +91 -5
- package/src/auth/TwoFactorForm.tsx +74 -0
- package/src/auth/index.ts +4 -0
- package/src/configure.ts +8 -1
- package/src/confirmer.ts +112 -20
- package/src/generated/seatMapWidget.ts +3 -0
- package/src/index.tsx +1 -1
- package/src/sections/Accommodation.tsx +57 -0
- package/src/sections/AccountRewards.tsx +108 -0
- package/src/sections/AddOns.tsx +99 -148
- package/src/sections/BillingInfo.tsx +63 -64
- package/src/sections/Bundles.tsx +467 -0
- package/src/sections/EventDiscussionView.tsx +637 -0
- package/src/sections/Passes.tsx +177 -0
- package/src/sections/Payment.tsx +169 -52
- package/src/sections/PostReaction.tsx +122 -0
- package/src/sections/Rewards.tsx +320 -0
- package/src/sections/SeatMap.tsx +225 -0
- package/src/sections/SeatsIoMap.tsx +110 -0
- package/src/sections/Streaming.tsx +278 -0
- package/src/sections/TicketChange.tsx +379 -0
- package/src/sections/TicketGift.tsx +206 -0
- package/src/sections/TicketRefund.tsx +104 -0
- package/src/sections/Tickets.tsx +289 -208
- package/src/sections/Trips.tsx +87 -0
- package/src/sections/WaiverSigning.tsx +429 -0
- package/src/sections/billing/PhoneIdentity.tsx +169 -0
- package/src/sections/discussion/CommentImages.tsx +366 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +26 -0
- package/src/sections/rewards/RewardsLadder.tsx +167 -0
- package/src/sections/rewards/RewardsTree.tsx +117 -0
- package/src/storage.ts +8 -9
- package/src/ui/CheckoutProvider.tsx +25 -13
- package/src/ui/Field.tsx +21 -3
- package/src/ui/Frozen.tsx +22 -0
- package/src/ui/InstallmentPicker.tsx +15 -18
- package/src/ui/PaymentMethods.tsx +6 -3
- package/src/ui/ProviderPages.tsx +134 -0
- package/src/ui/QuantitySelect.tsx +26 -2
- package/src/ui/Select.tsx +4 -1
- package/src/ui/SignaturePad.tsx +218 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/primitives.tsx +13 -2
- package/src/ui/useAccountT.ts +16 -0
- package/src/useCheckout.ts +3 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The referral tree, drawn with positioned `View`s.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately no SVG: adding `react-native-svg` would be a new native peer for
|
|
5
|
+
* every consumer app, and a dot-and-line picture does not need one. Each dot is
|
|
6
|
+
* an absolutely-positioned circle and each link a thin, rotated view — the
|
|
7
|
+
* positions themselves are the same shared radial layout the web SVG uses, so
|
|
8
|
+
* both platforms draw the identical picture.
|
|
9
|
+
*/
|
|
10
|
+
import React, { useMemo, type FC } from 'react'
|
|
11
|
+
import { StyleSheet, View } from 'react-native'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
computeRadialTreeLayout,
|
|
15
|
+
isTreeRoot,
|
|
16
|
+
radialTreeEdges,
|
|
17
|
+
treeNodeId,
|
|
18
|
+
treeRootId,
|
|
19
|
+
type RewardsReferralTree,
|
|
20
|
+
} from 'tf-checkout-shared'
|
|
21
|
+
|
|
22
|
+
import { useTheme } from '../../ui/CheckoutProvider'
|
|
23
|
+
|
|
24
|
+
export interface RewardsTreeProps {
|
|
25
|
+
tree: RewardsReferralTree
|
|
26
|
+
/** Edge length of the square the tree is drawn in. */
|
|
27
|
+
size?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Above this the dots overlap into a smudge on a phone screen. */
|
|
31
|
+
const MAX_NODES = 120
|
|
32
|
+
const DOT = 10
|
|
33
|
+
const ROOT_DOT = 16
|
|
34
|
+
|
|
35
|
+
export const RewardsTree: FC<RewardsTreeProps> = ({ tree, size = 260 }) => {
|
|
36
|
+
const theme = useTheme()
|
|
37
|
+
const nodes = useMemo(() => (tree?.nodes ?? []).slice(0, MAX_NODES), [tree])
|
|
38
|
+
|
|
39
|
+
const { layout, edges, rootId } = useMemo(() => {
|
|
40
|
+
// The buyer is not a node of their own tree — they are the parent the people
|
|
41
|
+
// they referred name. Looking for a parentless node finds nobody and draws a
|
|
42
|
+
// friend in the buyer's place.
|
|
43
|
+
const root = treeRootId(nodes)
|
|
44
|
+
const computed = computeRadialTreeLayout(nodes, root)
|
|
45
|
+
return { layout: computed, edges: radialTreeEdges(nodes, computed), rootId: root }
|
|
46
|
+
}, [nodes])
|
|
47
|
+
|
|
48
|
+
if (nodes.length === 0) return null
|
|
49
|
+
|
|
50
|
+
// Inset by the largest dot's radius so the outermost ring stays on canvas.
|
|
51
|
+
const inset = ROOT_DOT / 2
|
|
52
|
+
const span = size - ROOT_DOT
|
|
53
|
+
const at = (value: number) => inset + value * span
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View style={[styles.canvas, { width: size, height: size }]}>
|
|
57
|
+
{edges.map((edge) => {
|
|
58
|
+
const x1 = at(edge.from.x)
|
|
59
|
+
const y1 = at(edge.from.y)
|
|
60
|
+
const x2 = at(edge.to.x)
|
|
61
|
+
const y2 = at(edge.to.y)
|
|
62
|
+
const length = Math.hypot(x2 - x1, y2 - y1)
|
|
63
|
+
const angle = Math.atan2(y2 - y1, x2 - x1)
|
|
64
|
+
return (
|
|
65
|
+
<View
|
|
66
|
+
key={`${edge.fromId}-${edge.toId}`}
|
|
67
|
+
style={[
|
|
68
|
+
styles.link,
|
|
69
|
+
{
|
|
70
|
+
backgroundColor: theme.border,
|
|
71
|
+
width: length,
|
|
72
|
+
left: x1,
|
|
73
|
+
top: y1,
|
|
74
|
+
// Rotate about the start point so the line runs to the child.
|
|
75
|
+
transform: [
|
|
76
|
+
{ translateX: length / 2 },
|
|
77
|
+
{ rotate: `${angle}rad` },
|
|
78
|
+
{ translateX: -length / 2 },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
]}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
})}
|
|
85
|
+
{nodes.map((node) => {
|
|
86
|
+
const point = layout[treeNodeId(node)]
|
|
87
|
+
if (!point) return null
|
|
88
|
+
const isRoot = isTreeRoot(node, rootId)
|
|
89
|
+
const diameter = isRoot ? ROOT_DOT : DOT
|
|
90
|
+
return (
|
|
91
|
+
<View
|
|
92
|
+
key={treeNodeId(node)}
|
|
93
|
+
accessibilityLabel={node.public_name}
|
|
94
|
+
style={[
|
|
95
|
+
styles.dot,
|
|
96
|
+
{
|
|
97
|
+
width: diameter,
|
|
98
|
+
height: diameter,
|
|
99
|
+
borderRadius: diameter / 2,
|
|
100
|
+
backgroundColor: isRoot ? theme.text : theme.accent,
|
|
101
|
+
opacity: isRoot ? 1 : 0.75,
|
|
102
|
+
left: at(point.x) - diameter / 2,
|
|
103
|
+
top: at(point.y) - diameter / 2,
|
|
104
|
+
},
|
|
105
|
+
]}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</View>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const styles = StyleSheet.create({
|
|
114
|
+
canvas: { alignSelf: 'center' },
|
|
115
|
+
link: { position: 'absolute', height: 1 },
|
|
116
|
+
dot: { position: 'absolute' },
|
|
117
|
+
})
|
package/src/storage.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React Native storage adapter for the SDK's
|
|
2
|
+
* React Native storage adapter for the SDK's persisted session credentials.
|
|
3
3
|
*
|
|
4
|
-
* The shared transport
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* bearer session survives an app restart. `react-native-mmkv` is exactly that —
|
|
8
|
-
* synchronous get/set/delete backed by native MMKV — so we wrap it here.
|
|
4
|
+
* The shared transport uses a SYNCHRONOUS `StorageAdapter` (see shared
|
|
5
|
+
* `client/storage.ts`). MMKV 3 provides persisted get/set/delete operations,
|
|
6
|
+
* keeping the SDK's session credentials available across app restarts.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Default native cookie auth stores its ecommerce ticket here and sends it in
|
|
9
|
+
* request headers; the ambient OS cookie jar stays disabled. Other configured
|
|
10
|
+
* SDK token credentials use this same adapter. Hosts may inject another
|
|
11
|
+
* synchronous adapter when configuring checkout.
|
|
13
12
|
*/
|
|
14
13
|
import { MMKV } from 'react-native-mmkv'
|
|
15
14
|
|
|
@@ -24,10 +24,11 @@ import React, {
|
|
|
24
24
|
|
|
25
25
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
26
26
|
|
|
27
|
-
import type
|
|
27
|
+
import { hooks, type UseCheckout, type CheckoutOptions } from 'tf-checkout-shared'
|
|
28
28
|
|
|
29
29
|
import { useCheckout } from '../useCheckout'
|
|
30
30
|
import { useInstallmentPicker } from './InstallmentPicker'
|
|
31
|
+
import { useProviderPages } from './ProviderPages'
|
|
31
32
|
import { resolveTheme, type CheckoutTheme, type ResolvedTheme } from './theme'
|
|
32
33
|
|
|
33
34
|
const CheckoutCtx = createContext<UseCheckout | null>(null)
|
|
@@ -50,6 +51,8 @@ const getQueryClient = (): QueryClient => {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export interface CheckoutProviderProps {
|
|
54
|
+
/** Wallet/redirect settings for the provider-owned native engine. */
|
|
55
|
+
options?: CheckoutOptions
|
|
53
56
|
/**
|
|
54
57
|
* Bring your own checkout instance (from a top-level `useCheckout()`). Omit
|
|
55
58
|
* and the provider runs `useCheckout()` itself. Either way the sections read
|
|
@@ -93,11 +96,16 @@ const SelfProvidedCheckout: FC<Omit<CheckoutProviderProps, 'value' | 'queryClien
|
|
|
93
96
|
}) => {
|
|
94
97
|
// Bridge the engine's mid-payment MSI (installments) callback to a native modal.
|
|
95
98
|
const { onInstallmentOffers, picker } = useInstallmentPicker()
|
|
96
|
-
const
|
|
99
|
+
const { providerPage, page } = useProviderPages()
|
|
100
|
+
const checkout = useCheckout({
|
|
101
|
+
ports: { onInstallmentOffers, providerPage },
|
|
102
|
+
options: rest.options,
|
|
103
|
+
})
|
|
97
104
|
return (
|
|
98
105
|
<CheckoutRoot checkout={checkout} {...rest}>
|
|
99
106
|
{children}
|
|
100
107
|
{picker}
|
|
108
|
+
{page}
|
|
101
109
|
</CheckoutRoot>
|
|
102
110
|
)
|
|
103
111
|
}
|
|
@@ -106,17 +114,21 @@ export const CheckoutProvider: FC<CheckoutProviderProps> = ({
|
|
|
106
114
|
value,
|
|
107
115
|
queryClient,
|
|
108
116
|
...rest
|
|
109
|
-
}) =>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
}) => {
|
|
118
|
+
const session = hooks.useAppCheckoutSession()
|
|
119
|
+
const sessionKey = hooks.ticketHolderFor(value?.store ?? session.store).id
|
|
120
|
+
return (
|
|
121
|
+
// `value` present ⇒ don't call `useCheckout()` (no second binding); absent ⇒
|
|
122
|
+
// the self-providing branch owns it. The choice is static per mount.
|
|
123
|
+
<QueryClientProvider client={queryClient ?? getQueryClient()}>
|
|
124
|
+
{value ? (
|
|
125
|
+
<CheckoutRoot key={sessionKey} checkout={value} {...rest} />
|
|
126
|
+
) : (
|
|
127
|
+
<SelfProvidedCheckout key={sessionKey} {...rest} />
|
|
128
|
+
)}
|
|
129
|
+
</QueryClientProvider>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
120
132
|
|
|
121
133
|
/** Read the shared checkout inside any component under `<CheckoutProvider>`. */
|
|
122
134
|
export const useCheckoutContext = (): UseCheckout => {
|
package/src/ui/Field.tsx
CHANGED
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
TextInput,
|
|
12
12
|
View,
|
|
13
13
|
type KeyboardTypeOptions,
|
|
14
|
+
type TextInputProps,
|
|
14
15
|
} from 'react-native'
|
|
15
16
|
|
|
16
17
|
import { useTheme } from './CheckoutProvider'
|
|
17
18
|
import type { ResolvedTheme } from './theme'
|
|
19
|
+
import { useFrozen } from './Frozen'
|
|
18
20
|
|
|
19
21
|
export const Field: FC<{
|
|
20
22
|
label?: string
|
|
@@ -38,7 +40,17 @@ export const Field: FC<{
|
|
|
38
40
|
)
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
export interface InputProps
|
|
43
|
+
export interface InputProps
|
|
44
|
+
extends Pick<
|
|
45
|
+
TextInputProps,
|
|
46
|
+
| 'textContentType'
|
|
47
|
+
| 'autoComplete'
|
|
48
|
+
| 'autoFocus'
|
|
49
|
+
| 'maxLength'
|
|
50
|
+
| 'onSubmitEditing'
|
|
51
|
+
| 'accessibilityLabel'
|
|
52
|
+
| 'multiline'
|
|
53
|
+
> {
|
|
42
54
|
value: string
|
|
43
55
|
onChangeText: (text: string) => void
|
|
44
56
|
placeholder?: string
|
|
@@ -57,15 +69,19 @@ export const Input: FC<InputProps> = ({
|
|
|
57
69
|
keyboardType,
|
|
58
70
|
autoCapitalize = 'sentences',
|
|
59
71
|
secureTextEntry,
|
|
60
|
-
editable = true,
|
|
72
|
+
editable: editableProp = true,
|
|
61
73
|
invalid = false,
|
|
62
74
|
onBlur,
|
|
75
|
+
...nativeProps
|
|
63
76
|
}) => {
|
|
77
|
+
const frozen = useFrozen()
|
|
78
|
+
const editable = editableProp && !frozen
|
|
64
79
|
const theme = useTheme()
|
|
65
80
|
const s = useFieldStyles(theme)
|
|
66
81
|
const [focused, setFocused] = useState(false)
|
|
67
82
|
return (
|
|
68
83
|
<TextInput
|
|
84
|
+
{...nativeProps}
|
|
69
85
|
style={[
|
|
70
86
|
s.input,
|
|
71
87
|
focused && s.inputFocused,
|
|
@@ -98,10 +114,12 @@ export const Checkbox: FC<{
|
|
|
98
114
|
}> = ({ checked, onChange, label, invalid = false }) => {
|
|
99
115
|
const theme = useTheme()
|
|
100
116
|
const s = useFieldStyles(theme)
|
|
117
|
+
const frozen = useFrozen()
|
|
101
118
|
return (
|
|
102
119
|
<Pressable
|
|
103
120
|
accessibilityRole="checkbox"
|
|
104
|
-
accessibilityState={{ checked }}
|
|
121
|
+
accessibilityState={{ checked, disabled: frozen }}
|
|
122
|
+
disabled={frozen}
|
|
105
123
|
onPress={() => onChange(!checked)}
|
|
106
124
|
style={s.checkboxRow}
|
|
107
125
|
>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FrozenFields` — the native form of a disabled `<fieldset>`: every Input,
|
|
3
|
+
* Checkbox, Select, QuantitySelect and Button inside it stops taking input
|
|
4
|
+
* while `frozen`. A section wraps its form in it while the order is placed
|
|
5
|
+
* or charged (`orderFixed`), when the engine ignores a commit.
|
|
6
|
+
*/
|
|
7
|
+
import React, { createContext, useContext, type FC, type ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
const FrozenContext = createContext(false)
|
|
10
|
+
|
|
11
|
+
/** True inside a frozen `FrozenFields`. */
|
|
12
|
+
export const useFrozen = (): boolean => useContext(FrozenContext)
|
|
13
|
+
|
|
14
|
+
export const FrozenFields: FC<{ frozen?: boolean; children: ReactNode }> = ({
|
|
15
|
+
frozen = false,
|
|
16
|
+
children,
|
|
17
|
+
}) => {
|
|
18
|
+
const outer = useFrozen()
|
|
19
|
+
return (
|
|
20
|
+
<FrozenContext.Provider value={outer || frozen}>{children}</FrozenContext.Provider>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -17,33 +17,30 @@
|
|
|
17
17
|
* provider hook-up. Left as a provider follow-up so this slice stays scoped to
|
|
18
18
|
* the payment section + this self-contained hook/modal.
|
|
19
19
|
*/
|
|
20
|
-
import React, {
|
|
20
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
21
21
|
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
22
22
|
|
|
23
|
-
import
|
|
23
|
+
import {
|
|
24
|
+
createInstallmentChoices,
|
|
25
|
+
type InstallmentOffer,
|
|
26
|
+
type PendingInstallmentChoice,
|
|
27
|
+
} from 'tf-checkout-shared'
|
|
24
28
|
|
|
25
29
|
import { useTheme } from './CheckoutProvider'
|
|
26
30
|
import { Button } from './primitives'
|
|
27
31
|
import type { ResolvedTheme } from './theme'
|
|
28
32
|
|
|
29
33
|
export const useInstallmentPicker = () => {
|
|
30
|
-
const [pending, setPending] = useState<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const [pending, setPending] = useState<PendingInstallmentChoice | null>(null)
|
|
35
|
+
// Created once, so the port is stable (useCheckout reads ports once). It
|
|
36
|
+
// resolves when the buyer picks a plan (or declines) below.
|
|
37
|
+
const [choices] = useState(() => createInstallmentChoices(setPending))
|
|
38
|
+
// A picker that unmounts while open ends the choice, so the payment stops
|
|
39
|
+
// instead of waiting for an answer that cannot come.
|
|
40
|
+
useEffect(() => () => choices.cancel(), [choices])
|
|
41
|
+
const onInstallmentOffers = choices.ask
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
// buyer picks a plan (or declines) below.
|
|
37
|
-
const onInstallmentOffers = useCallback(
|
|
38
|
-
(offers: InstallmentOffer[]) =>
|
|
39
|
-
new Promise<InstallmentOffer | null>((resolve) => setPending({ offers, resolve })),
|
|
40
|
-
[]
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
const choose = (offer: InstallmentOffer | null) => {
|
|
44
|
-
pending?.resolve(offer)
|
|
45
|
-
setPending(null)
|
|
46
|
-
}
|
|
43
|
+
const choose = (offer: InstallmentOffer | null) => pending?.choose(offer)
|
|
47
44
|
|
|
48
45
|
const picker = (
|
|
49
46
|
<InstallmentModal
|
|
@@ -11,6 +11,7 @@ import React, { useMemo, type FC, type ReactNode } from 'react'
|
|
|
11
11
|
import { Image, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
12
12
|
|
|
13
13
|
import { useTheme } from './CheckoutProvider'
|
|
14
|
+
import { useFrozen } from './Frozen'
|
|
14
15
|
import { Field, Input } from './Field'
|
|
15
16
|
import type { ResolvedTheme } from './theme'
|
|
16
17
|
|
|
@@ -58,6 +59,7 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
|
|
|
58
59
|
children,
|
|
59
60
|
}) => {
|
|
60
61
|
const theme = useTheme()
|
|
62
|
+
const frozen = useFrozen()
|
|
61
63
|
const s = useStyles(theme)
|
|
62
64
|
return (
|
|
63
65
|
<View style={s.list} accessibilityRole="radiogroup">
|
|
@@ -75,8 +77,9 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
|
|
|
75
77
|
<View key={option.value} style={[s.row, active && s.rowActive]}>
|
|
76
78
|
<Pressable
|
|
77
79
|
accessibilityRole="radio"
|
|
78
|
-
accessibilityState={{ selected: active }}
|
|
79
|
-
|
|
80
|
+
accessibilityState={{ selected: active, disabled: frozen }}
|
|
81
|
+
disabled={frozen}
|
|
82
|
+
onPress={() => { if (!frozen) onSelect(option.value) }}
|
|
80
83
|
style={s.rowHead}
|
|
81
84
|
>
|
|
82
85
|
<View style={[s.radio, active && s.radioActive]}>
|
|
@@ -104,7 +107,7 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
|
|
|
104
107
|
<Input
|
|
105
108
|
value={extraValue ?? ''}
|
|
106
109
|
onChangeText={(text) =>
|
|
107
|
-
onExtraChange?.(
|
|
110
|
+
!frozen && onExtraChange?.(
|
|
108
111
|
option.value,
|
|
109
112
|
option.extra === 'phoneNumber'
|
|
110
113
|
? { phoneNumber: text }
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Linking,
|
|
5
|
+
Modal,
|
|
6
|
+
SafeAreaView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import { WebView } from 'react-native-webview'
|
|
11
|
+
import {
|
|
12
|
+
createProviderPages,
|
|
13
|
+
providerPageMatchesReturn,
|
|
14
|
+
providerPageOutcomeFromMessage,
|
|
15
|
+
type PendingProviderPage,
|
|
16
|
+
} from 'tf-checkout-shared'
|
|
17
|
+
import { Button, ErrorText } from './primitives'
|
|
18
|
+
import { useTheme } from './CheckoutProvider'
|
|
19
|
+
|
|
20
|
+
/** Native window plumbing over the same return/message contract as web. */
|
|
21
|
+
export const useProviderPages = () => {
|
|
22
|
+
const [pending, setPending] = useState<PendingProviderPage | null>(null)
|
|
23
|
+
const [pages] = useState(() => createProviderPages(setPending))
|
|
24
|
+
useEffect(() => () => pages.cancel(), [pages])
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!pending) return
|
|
27
|
+
const subscription = Linking.addEventListener('url', ({ url }) => {
|
|
28
|
+
if (providerPageMatchesReturn(url, pending.request.returnUrlPrefix))
|
|
29
|
+
pages.settle(pending.id, { event: 'success', returnUrl: url })
|
|
30
|
+
})
|
|
31
|
+
return () => subscription.remove()
|
|
32
|
+
}, [pending, pages])
|
|
33
|
+
return {
|
|
34
|
+
providerPage: pages.open,
|
|
35
|
+
page: pending ? (
|
|
36
|
+
<ProviderPage key={pending.id} pending={pending} settle={pages.settle} />
|
|
37
|
+
) : null,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ProviderPage = ({
|
|
42
|
+
pending,
|
|
43
|
+
settle,
|
|
44
|
+
}: {
|
|
45
|
+
pending: PendingProviderPage
|
|
46
|
+
settle: ReturnType<typeof createProviderPages>['settle']
|
|
47
|
+
}) => {
|
|
48
|
+
const theme = useTheme()
|
|
49
|
+
const [error, setError] = useState('')
|
|
50
|
+
const [reload, setReload] = useState(0)
|
|
51
|
+
const close = () => settle(pending.id, { event: 'dismiss' })
|
|
52
|
+
const source = pending.request.uri
|
|
53
|
+
? { uri: pending.request.uri }
|
|
54
|
+
: { html: pending.request.html ?? '', baseUrl: pending.request.baseUrl }
|
|
55
|
+
return (
|
|
56
|
+
<Modal
|
|
57
|
+
visible
|
|
58
|
+
animationType="slide"
|
|
59
|
+
presentationStyle="fullScreen"
|
|
60
|
+
onRequestClose={close}
|
|
61
|
+
>
|
|
62
|
+
<SafeAreaView style={[styles.root, { backgroundColor: theme.background }]}>
|
|
63
|
+
<View style={styles.toolbar}>
|
|
64
|
+
<Button variant="ghost" onPress={close}>
|
|
65
|
+
Close payment page
|
|
66
|
+
</Button>
|
|
67
|
+
</View>
|
|
68
|
+
{error ? (
|
|
69
|
+
<View style={styles.failure}>
|
|
70
|
+
<ErrorText>{error}</ErrorText>
|
|
71
|
+
<Button
|
|
72
|
+
onPress={() => {
|
|
73
|
+
setError('')
|
|
74
|
+
setReload((value) => value + 1)
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Try again
|
|
78
|
+
</Button>
|
|
79
|
+
</View>
|
|
80
|
+
) : (
|
|
81
|
+
<WebView
|
|
82
|
+
key={reload}
|
|
83
|
+
source={source}
|
|
84
|
+
style={styles.webview}
|
|
85
|
+
javaScriptEnabled
|
|
86
|
+
domStorageEnabled
|
|
87
|
+
incognito
|
|
88
|
+
sharedCookiesEnabled={false}
|
|
89
|
+
thirdPartyCookiesEnabled={false}
|
|
90
|
+
setSupportMultipleWindows={false}
|
|
91
|
+
originWhitelist={['*']}
|
|
92
|
+
startInLoadingState
|
|
93
|
+
renderLoading={() => (
|
|
94
|
+
<ActivityIndicator style={styles.loading} color={theme.accent} />
|
|
95
|
+
)}
|
|
96
|
+
onShouldStartLoadWithRequest={({ url }) => {
|
|
97
|
+
if (providerPageMatchesReturn(url, pending.request.returnUrlPrefix)) {
|
|
98
|
+
settle(pending.id, { event: 'success', returnUrl: url })
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
if (/^(https:|about:)/i.test(url)) return true
|
|
102
|
+
if (/^(http:|javascript:|data:|file:)/i.test(url)) {
|
|
103
|
+
setError('This payment page could not be opened securely.')
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
Linking.openURL(url).catch(() =>
|
|
107
|
+
setError(
|
|
108
|
+
'Unable to open this payment app. Please choose another payment method.'
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return false
|
|
112
|
+
}}
|
|
113
|
+
onMessage={({ nativeEvent }) => {
|
|
114
|
+
const outcome = providerPageOutcomeFromMessage(nativeEvent.data)
|
|
115
|
+
if (outcome) settle(pending.id, outcome)
|
|
116
|
+
}}
|
|
117
|
+
onError={() =>
|
|
118
|
+
setError(
|
|
119
|
+
'The payment page could not load. Check your connection and try again.'
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
</SafeAreaView>
|
|
125
|
+
</Modal>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
const styles = StyleSheet.create({
|
|
129
|
+
root: { flex: 1 },
|
|
130
|
+
toolbar: { padding: 12 },
|
|
131
|
+
webview: { flex: 1 },
|
|
132
|
+
failure: { padding: 24, gap: 16 },
|
|
133
|
+
loading: { ...StyleSheet.absoluteFillObject },
|
|
134
|
+
})
|
|
@@ -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,8 +35,11 @@ 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
|
|
@@ -50,7 +60,21 @@ export const QuantitySelect: FC<QuantitySelectProps> = ({
|
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
return (
|
|
53
|
-
<View
|
|
63
|
+
<View
|
|
64
|
+
style={s.stepper}
|
|
65
|
+
accessible={Boolean(label)}
|
|
66
|
+
accessibilityRole={label ? 'adjustable' : undefined}
|
|
67
|
+
accessibilityLabel={label}
|
|
68
|
+
accessibilityValue={label ? { now: value, min: 0, max: cap } : undefined}
|
|
69
|
+
accessibilityState={label ? { disabled } : undefined}
|
|
70
|
+
accessibilityActions={
|
|
71
|
+
label ? [{ name: 'increment' }, { name: 'decrement' }] : undefined
|
|
72
|
+
}
|
|
73
|
+
onAccessibilityAction={(e) => {
|
|
74
|
+
if (e.nativeEvent.actionName === 'increment' && !atCap) inc()
|
|
75
|
+
if (e.nativeEvent.actionName === 'decrement' && !atZero) dec()
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
54
78
|
<Pressable
|
|
55
79
|
accessibilityRole="button"
|
|
56
80
|
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)
|