tf-checkout-react-native 2.0.0-beta.16 → 2.0.0-beta.18
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 +7 -0
- package/dist/index.d.mts +78 -9
- package/dist/index.d.ts +78 -9
- package/dist/index.js +2648 -2134
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2344 -1823
- package/dist/index.mjs.map +1 -1
- package/package.json +19 -1
- package/src/paymentSheet.ts +15 -6
- package/src/sections/Accommodation.tsx +3 -2
- package/src/sections/AddOns.tsx +4 -3
- package/src/sections/Bundles.tsx +4 -3
- package/src/sections/Payment.tsx +19 -8
- package/src/sections/Tickets.tsx +26 -99
- package/src/sections/WaiverSigning.tsx +2 -2
- package/src/ui/RichText.tsx +187 -0
- package/src/ui/WaitingListForm.tsx +139 -0
- package/src/ui/index.ts +1 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react'
|
|
2
|
+
import { Alert, Linking, StyleSheet, Text, View } from 'react-native'
|
|
3
|
+
import RenderHTML, {
|
|
4
|
+
defaultSystemFonts,
|
|
5
|
+
HTMLContentModel,
|
|
6
|
+
HTMLElementModel,
|
|
7
|
+
IMGElement,
|
|
8
|
+
useIMGElementProps,
|
|
9
|
+
useSharedProps,
|
|
10
|
+
type CustomBlockRenderer,
|
|
11
|
+
type MixedStyleDeclaration,
|
|
12
|
+
type MixedStyleRecord,
|
|
13
|
+
} from 'react-native-render-html'
|
|
14
|
+
|
|
15
|
+
import { useTheme } from './CheckoutProvider'
|
|
16
|
+
|
|
17
|
+
const ignoredDomTags = [
|
|
18
|
+
'script',
|
|
19
|
+
'style',
|
|
20
|
+
'template',
|
|
21
|
+
'noscript',
|
|
22
|
+
'iframe',
|
|
23
|
+
'object',
|
|
24
|
+
'embed',
|
|
25
|
+
'form',
|
|
26
|
+
'head',
|
|
27
|
+
]
|
|
28
|
+
const allowedStyles: NonNullable<
|
|
29
|
+
React.ComponentProps<typeof RenderHTML>['allowedStyles']
|
|
30
|
+
> = ['fontWeight', 'fontStyle', 'textDecorationLine', 'textAlign', 'display']
|
|
31
|
+
const ignoreDomNode: NonNullable<
|
|
32
|
+
React.ComponentProps<typeof RenderHTML>['ignoreDomNode']
|
|
33
|
+
> = (node) => {
|
|
34
|
+
if (!('attribs' in node) || !node.attribs || typeof node.attribs !== 'object')
|
|
35
|
+
return false
|
|
36
|
+
return (
|
|
37
|
+
'hidden' in node.attribs ||
|
|
38
|
+
('style' in node.attribs &&
|
|
39
|
+
typeof node.attribs.style === 'string' &&
|
|
40
|
+
/(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important\s*)?(?:;|$)/i.test(
|
|
41
|
+
node.attribs.style
|
|
42
|
+
))
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
// Default models omit tables. Block models retain every caption/cell without
|
|
46
|
+
// a WebView or a second parser; complex spans remain readable, not pixel-identical.
|
|
47
|
+
const customHTMLElementModels = Object.fromEntries(
|
|
48
|
+
['table', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th'].map((tagName) => [
|
|
49
|
+
tagName,
|
|
50
|
+
HTMLElementModel.fromCustomModel({ tagName, contentModel: HTMLContentModel.block }),
|
|
51
|
+
])
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export interface RichTextProps {
|
|
55
|
+
html: string
|
|
56
|
+
textStyle?: MixedStyleDeclaration
|
|
57
|
+
tagsStyles?: MixedStyleRecord
|
|
58
|
+
onLinkPress?: (href: string) => void
|
|
59
|
+
selectable?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SafeImage: CustomBlockRenderer = (props) => {
|
|
63
|
+
const image = useIMGElementProps(props)
|
|
64
|
+
const { defaultTextProps } = useSharedProps()
|
|
65
|
+
if (!/^https?:\/\//i.test(image.source.uri ?? ''))
|
|
66
|
+
return image.alt ? (
|
|
67
|
+
<Text {...defaultTextProps} style={props.tnode.styles.nativeTextFlow}>
|
|
68
|
+
{image.alt}
|
|
69
|
+
</Text>
|
|
70
|
+
) : null
|
|
71
|
+
return <IMGElement {...image} />
|
|
72
|
+
}
|
|
73
|
+
const renderers = { img: SafeImage }
|
|
74
|
+
|
|
75
|
+
/** API HTML is parsed once, preserving escaped literal tags as visible text. */
|
|
76
|
+
export const RichText = ({
|
|
77
|
+
html,
|
|
78
|
+
textStyle,
|
|
79
|
+
tagsStyles: tagOverrides,
|
|
80
|
+
onLinkPress,
|
|
81
|
+
selectable = false,
|
|
82
|
+
}: RichTextProps) => {
|
|
83
|
+
const theme = useTheme()
|
|
84
|
+
const [width, setWidth] = useState(0)
|
|
85
|
+
const source = useMemo(() => ({ html }), [html])
|
|
86
|
+
const baseStyle = useMemo(
|
|
87
|
+
() => ({
|
|
88
|
+
color: theme.muted,
|
|
89
|
+
fontSize: theme.fontSize - 1,
|
|
90
|
+
lineHeight: theme.fontSize * 1.5,
|
|
91
|
+
fontFamily: theme.fontFamily,
|
|
92
|
+
...textStyle,
|
|
93
|
+
}),
|
|
94
|
+
[theme.muted, theme.fontSize, theme.fontFamily, textStyle]
|
|
95
|
+
)
|
|
96
|
+
const systemFonts = useMemo(
|
|
97
|
+
() =>
|
|
98
|
+
[
|
|
99
|
+
...new Set([...defaultSystemFonts, theme.fontFamily, textStyle?.fontFamily]),
|
|
100
|
+
].filter((font): font is string => typeof font === 'string'),
|
|
101
|
+
[theme.fontFamily, textStyle?.fontFamily]
|
|
102
|
+
)
|
|
103
|
+
const tagsStyles = useMemo(() => {
|
|
104
|
+
const defaults: Record<string, MixedStyleDeclaration> = {
|
|
105
|
+
p: { marginTop: 0, marginBottom: 8 },
|
|
106
|
+
ul: { marginTop: 0, marginBottom: 8, paddingLeft: 22 },
|
|
107
|
+
ol: { marginTop: 0, marginBottom: 8, paddingLeft: 22 },
|
|
108
|
+
a: { color: theme.accent, textDecorationLine: 'underline' },
|
|
109
|
+
table: { marginBottom: 8 },
|
|
110
|
+
caption: { fontWeight: '600', marginBottom: 4 },
|
|
111
|
+
tr: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
112
|
+
td: { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 48, padding: 4 },
|
|
113
|
+
th: {
|
|
114
|
+
flexGrow: 1,
|
|
115
|
+
flexShrink: 1,
|
|
116
|
+
flexBasis: 0,
|
|
117
|
+
minWidth: 48,
|
|
118
|
+
padding: 4,
|
|
119
|
+
fontWeight: '600',
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
// Match the standard heading sizes to the body font with enough line height;
|
|
123
|
+
// inheriting the body's fixed height clips larger headings.
|
|
124
|
+
const headingBase =
|
|
125
|
+
typeof baseStyle.fontSize === 'number' ? baseStyle.fontSize : theme.fontSize - 1
|
|
126
|
+
for (const [heading, scale] of Object.entries({
|
|
127
|
+
h1: 2,
|
|
128
|
+
h2: 1.5,
|
|
129
|
+
h3: 1.17,
|
|
130
|
+
h4: 1,
|
|
131
|
+
h5: 0.83,
|
|
132
|
+
h6: 0.67,
|
|
133
|
+
})) {
|
|
134
|
+
const overrideSize = tagOverrides?.[heading]?.fontSize
|
|
135
|
+
const size = typeof overrideSize === 'number' ? overrideSize : headingBase * scale
|
|
136
|
+
defaults[heading] = { fontSize: size, lineHeight: size * 1.35 }
|
|
137
|
+
}
|
|
138
|
+
for (const [tag, style] of Object.entries(tagOverrides ?? {}))
|
|
139
|
+
defaults[tag] = { ...defaults[tag], ...style }
|
|
140
|
+
return defaults
|
|
141
|
+
}, [theme.accent, theme.fontSize, baseStyle.fontSize, tagOverrides])
|
|
142
|
+
const renderersProps = useMemo<
|
|
143
|
+
NonNullable<React.ComponentProps<typeof RenderHTML>['renderersProps']>
|
|
144
|
+
>(
|
|
145
|
+
() => ({
|
|
146
|
+
a: {
|
|
147
|
+
onPress: (event, href) => {
|
|
148
|
+
// Opening a nested terms link must never accept its parent checkbox.
|
|
149
|
+
event.stopPropagation?.()
|
|
150
|
+
if (!/^https?:\/\//i.test(href)) return
|
|
151
|
+
if (onLinkPress) onLinkPress(href)
|
|
152
|
+
else
|
|
153
|
+
void Linking.openURL(href).catch(() =>
|
|
154
|
+
Alert.alert('Couldn’t open link', 'Please try again.')
|
|
155
|
+
)
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
[onLinkPress]
|
|
160
|
+
)
|
|
161
|
+
const defaultTextProps = useMemo(() => ({ selectable }), [selectable])
|
|
162
|
+
return (
|
|
163
|
+
<View
|
|
164
|
+
style={styles.container}
|
|
165
|
+
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
|
166
|
+
>
|
|
167
|
+
{width > 0 ? (
|
|
168
|
+
<RenderHTML
|
|
169
|
+
source={source}
|
|
170
|
+
contentWidth={width}
|
|
171
|
+
baseStyle={baseStyle}
|
|
172
|
+
systemFonts={systemFonts}
|
|
173
|
+
tagsStyles={tagsStyles}
|
|
174
|
+
allowedStyles={allowedStyles}
|
|
175
|
+
ignoredDomTags={ignoredDomTags}
|
|
176
|
+
ignoreDomNode={ignoreDomNode}
|
|
177
|
+
renderersProps={renderersProps}
|
|
178
|
+
customHTMLElementModels={customHTMLElementModels}
|
|
179
|
+
renderers={renderers}
|
|
180
|
+
defaultTextProps={defaultTextProps}
|
|
181
|
+
/>
|
|
182
|
+
) : null}
|
|
183
|
+
</View>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const styles = StyleSheet.create({ container: { alignSelf: 'stretch', minWidth: 0 } })
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Alert, Linking, StyleSheet, View } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
createTranslator,
|
|
5
|
+
hooks,
|
|
6
|
+
waitingListQuantityCap,
|
|
7
|
+
type EventWaitingList,
|
|
8
|
+
type WaitingListEntry,
|
|
9
|
+
type WaitingListPrefill,
|
|
10
|
+
} from 'tf-checkout-shared'
|
|
11
|
+
import { Field, Input } from './Field'
|
|
12
|
+
import { Button, ErrorText, MutedText, SectionHeader } from './primitives'
|
|
13
|
+
import { Select } from './Select'
|
|
14
|
+
|
|
15
|
+
export const WaitingListForm = ({
|
|
16
|
+
eventId,
|
|
17
|
+
definition,
|
|
18
|
+
onJoin,
|
|
19
|
+
prefill,
|
|
20
|
+
}: {
|
|
21
|
+
eventId: string
|
|
22
|
+
definition?: EventWaitingList
|
|
23
|
+
onJoin: (entry: WaitingListEntry) => Promise<void>
|
|
24
|
+
prefill?: WaitingListPrefill
|
|
25
|
+
}) => {
|
|
26
|
+
const tr = createTranslator()
|
|
27
|
+
const flow = hooks.useWaitingListForm(eventId, definition, onJoin, prefill)
|
|
28
|
+
if (definition?.state === 'closed')
|
|
29
|
+
return <MutedText>{tr('tickets.waitingListClosed')}</MutedText>
|
|
30
|
+
if (definition?.state === 'unsupported' && definition.eventUrl)
|
|
31
|
+
return (
|
|
32
|
+
<Button
|
|
33
|
+
variant="ghost"
|
|
34
|
+
onPress={() =>
|
|
35
|
+
void Linking.openURL(definition.eventUrl).catch(() =>
|
|
36
|
+
Alert.alert('Couldn’t open link', 'Please try again.')
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
>
|
|
40
|
+
{tr('tickets.waitingListWebsite')}
|
|
41
|
+
</Button>
|
|
42
|
+
)
|
|
43
|
+
if (definition?.state !== 'open') return null
|
|
44
|
+
if (flow.done) return <MutedText>{tr('tickets.waitingListJoined')}</MutedText>
|
|
45
|
+
const cap = waitingListQuantityCap(definition, flow.draft.ticketTypeId)
|
|
46
|
+
return (
|
|
47
|
+
<View style={styles.form}>
|
|
48
|
+
<SectionHeader title={tr('tickets.joinWaitingList')} />
|
|
49
|
+
<Field label={tr('tickets.firstName')}>
|
|
50
|
+
<Input
|
|
51
|
+
accessibilityLabel={tr('tickets.firstName')}
|
|
52
|
+
autoComplete="given-name"
|
|
53
|
+
textContentType="givenName"
|
|
54
|
+
autoCapitalize="words"
|
|
55
|
+
value={flow.draft.firstName}
|
|
56
|
+
editable={!flow.busy}
|
|
57
|
+
onChangeText={(firstName) => flow.update({ firstName })}
|
|
58
|
+
/>
|
|
59
|
+
</Field>
|
|
60
|
+
<Field label={tr('tickets.lastName')}>
|
|
61
|
+
<Input
|
|
62
|
+
accessibilityLabel={tr('tickets.lastName')}
|
|
63
|
+
autoComplete="family-name"
|
|
64
|
+
textContentType="familyName"
|
|
65
|
+
autoCapitalize="words"
|
|
66
|
+
value={flow.draft.lastName}
|
|
67
|
+
editable={!flow.busy}
|
|
68
|
+
onChangeText={(lastName) => flow.update({ lastName })}
|
|
69
|
+
/>
|
|
70
|
+
</Field>
|
|
71
|
+
<Field label={tr('tickets.email')}>
|
|
72
|
+
<Input
|
|
73
|
+
accessibilityLabel={tr('tickets.email')}
|
|
74
|
+
autoComplete="email"
|
|
75
|
+
textContentType="emailAddress"
|
|
76
|
+
value={flow.draft.email}
|
|
77
|
+
keyboardType="email-address"
|
|
78
|
+
autoCapitalize="none"
|
|
79
|
+
editable={!flow.busy}
|
|
80
|
+
onChangeText={(email) => flow.update({ email })}
|
|
81
|
+
/>
|
|
82
|
+
</Field>
|
|
83
|
+
{!definition.autoSelectTicketId && definition.ticketTypes.length > 0 ? (
|
|
84
|
+
<Field label={tr('tickets.waitingListTicket')}>
|
|
85
|
+
<Select
|
|
86
|
+
value={flow.draft.ticketTypeId}
|
|
87
|
+
disabled={flow.busy}
|
|
88
|
+
onChange={(ticketTypeId) => flow.update({ ticketTypeId, quantity: '' })}
|
|
89
|
+
options={[
|
|
90
|
+
...(definition.allowAnyTicket
|
|
91
|
+
? [{ value: '', label: tr('tickets.waitingListAny') }]
|
|
92
|
+
: []),
|
|
93
|
+
...definition.ticketTypes.map((choice) => ({
|
|
94
|
+
value: choice.id,
|
|
95
|
+
label: choice.name,
|
|
96
|
+
})),
|
|
97
|
+
]}
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
) : null}
|
|
101
|
+
{definition.showQuantity && definition.fixedQuantity === undefined ? (
|
|
102
|
+
<Field
|
|
103
|
+
label={tr('tickets.waitingListQuantity')}
|
|
104
|
+
error={
|
|
105
|
+
flow.quantityInvalid
|
|
106
|
+
? tr(
|
|
107
|
+
cap === 0
|
|
108
|
+
? 'tickets.waitingListNoQuantity'
|
|
109
|
+
: 'tickets.waitingListInvalidQuantity',
|
|
110
|
+
{ max: cap }
|
|
111
|
+
)
|
|
112
|
+
: undefined
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
<Input
|
|
116
|
+
accessibilityLabel={tr('tickets.waitingListQuantity')}
|
|
117
|
+
keyboardType="number-pad"
|
|
118
|
+
value={flow.draft.quantity}
|
|
119
|
+
placeholder={tr('tickets.waitingListQuantityHint')}
|
|
120
|
+
editable={!flow.busy}
|
|
121
|
+
invalid={flow.quantityInvalid}
|
|
122
|
+
onChangeText={(quantity) => flow.update({ quantity })}
|
|
123
|
+
/>
|
|
124
|
+
</Field>
|
|
125
|
+
) : null}
|
|
126
|
+
<Button
|
|
127
|
+
variant="ghost"
|
|
128
|
+
loading={flow.busy}
|
|
129
|
+
disabled={!flow.canSubmit}
|
|
130
|
+
onPress={() => void flow.submit()}
|
|
131
|
+
>
|
|
132
|
+
{tr('tickets.joinWaitingList')}
|
|
133
|
+
</Button>
|
|
134
|
+
{flow.error ? <ErrorText>{flow.error}</ErrorText> : null}
|
|
135
|
+
</View>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const styles = StyleSheet.create({ form: { gap: 8 } })
|
package/src/ui/index.ts
CHANGED