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.
@@ -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
@@ -18,3 +18,4 @@ export * from './Timer'
18
18
  export * from './TimerBanner'
19
19
  export * from './PaymentMethods'
20
20
  export * from './InstallmentPicker'
21
+ export * from './RichText'