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.
Files changed (60) hide show
  1. package/README.md +92 -8
  2. package/dist/index.d.mts +7410 -290
  3. package/dist/index.d.ts +7410 -290
  4. package/dist/index.js +23539 -5909
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23561 -5857
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +22 -10
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/src/CardInput.tsx +64 -3
  12. package/src/EmbeddedCheckout.tsx +129 -36
  13. package/src/auth/AuthModal.tsx +90 -6
  14. package/src/auth/EmailCodeForm.tsx +91 -0
  15. package/src/auth/RegisterForm.tsx +91 -5
  16. package/src/auth/TwoFactorForm.tsx +74 -0
  17. package/src/auth/index.ts +4 -0
  18. package/src/configure.ts +8 -1
  19. package/src/confirmer.ts +112 -20
  20. package/src/generated/seatMapWidget.ts +3 -0
  21. package/src/index.tsx +1 -1
  22. package/src/sections/Accommodation.tsx +57 -0
  23. package/src/sections/AccountRewards.tsx +108 -0
  24. package/src/sections/AddOns.tsx +99 -148
  25. package/src/sections/BillingInfo.tsx +63 -64
  26. package/src/sections/Bundles.tsx +467 -0
  27. package/src/sections/EventDiscussionView.tsx +637 -0
  28. package/src/sections/Passes.tsx +177 -0
  29. package/src/sections/Payment.tsx +169 -52
  30. package/src/sections/PostReaction.tsx +122 -0
  31. package/src/sections/Rewards.tsx +320 -0
  32. package/src/sections/SeatMap.tsx +225 -0
  33. package/src/sections/SeatsIoMap.tsx +110 -0
  34. package/src/sections/Streaming.tsx +278 -0
  35. package/src/sections/TicketChange.tsx +379 -0
  36. package/src/sections/TicketGift.tsx +206 -0
  37. package/src/sections/TicketRefund.tsx +104 -0
  38. package/src/sections/Tickets.tsx +289 -208
  39. package/src/sections/Trips.tsx +87 -0
  40. package/src/sections/WaiverSigning.tsx +429 -0
  41. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  42. package/src/sections/discussion/CommentImages.tsx +366 -0
  43. package/src/sections/discussion/SwipeReply.tsx +88 -0
  44. package/src/sections/index.ts +26 -0
  45. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  46. package/src/sections/rewards/RewardsTree.tsx +117 -0
  47. package/src/storage.ts +8 -9
  48. package/src/ui/CheckoutProvider.tsx +25 -13
  49. package/src/ui/Field.tsx +21 -3
  50. package/src/ui/Frozen.tsx +22 -0
  51. package/src/ui/InstallmentPicker.tsx +15 -18
  52. package/src/ui/PaymentMethods.tsx +6 -3
  53. package/src/ui/ProviderPages.tsx +134 -0
  54. package/src/ui/QuantitySelect.tsx +26 -2
  55. package/src/ui/Select.tsx +4 -1
  56. package/src/ui/SignaturePad.tsx +218 -0
  57. package/src/ui/index.ts +1 -0
  58. package/src/ui/primitives.tsx +13 -2
  59. package/src/ui/useAccountT.ts +16 -0
  60. 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 token/session store.
2
+ * React Native storage adapter for the SDK's persisted session credentials.
3
3
  *
4
- * The shared transport reads/writes tokens through a SYNCHRONOUS `StorageAdapter`
5
- * (see tf-checkout-shared `client/storage.ts`). Web hands it a `localStorage`
6
- * adapter; the native side needs a synchronous, persisted store so the buyer's
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
- * Unlike web, native has no session cookie: the bearer token IS the session, so
11
- * `configureCheckout` defaults to this persisted adapter (never in-memory) so a
12
- * signed-in buyer stays signed in between launches.
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 { UseCheckout } from 'tf-checkout-shared'
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 checkout = useCheckout({ ports: { onInstallmentOffers } })
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
- // `value` present ⇒ don't call `useCheckout()` (no second binding); absent ⇒
111
- // the self-providing branch owns it. The choice is static per mount.
112
- <QueryClientProvider client={queryClient ?? getQueryClient()}>
113
- {value ? (
114
- <CheckoutRoot checkout={value} {...rest} />
115
- ) : (
116
- <SelfProvidedCheckout {...rest} />
117
- )}
118
- </QueryClientProvider>
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, { useCallback, useMemo, useState } from 'react'
20
+ import React, { useEffect, useMemo, useState } from 'react'
21
21
  import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
22
22
 
23
- import type { InstallmentOffer } from 'tf-checkout-shared'
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
- offers: InstallmentOffer[]
32
- resolve: (offer: InstallmentOffer | null) => void
33
- } | null>(null)
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
- // Stable across renders (useCheckout reads ports once) — resolves when the
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
- onPress={() => onSelect(option.value)}
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 style={s.stepper}>
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)