tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.5

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 (59) hide show
  1. package/README.md +92 -8
  2. package/dist/index.d.mts +7374 -288
  3. package/dist/index.d.ts +7374 -288
  4. package/dist/index.js +23316 -5980
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23461 -6058
  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 +633 -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/TicketChange.tsx +379 -0
  35. package/src/sections/TicketGift.tsx +206 -0
  36. package/src/sections/TicketRefund.tsx +104 -0
  37. package/src/sections/Tickets.tsx +289 -208
  38. package/src/sections/Trips.tsx +87 -0
  39. package/src/sections/WaiverSigning.tsx +429 -0
  40. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  41. package/src/sections/discussion/CommentImages.tsx +342 -0
  42. package/src/sections/discussion/SwipeReply.tsx +88 -0
  43. package/src/sections/index.ts +20 -0
  44. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  45. package/src/sections/rewards/RewardsTree.tsx +117 -0
  46. package/src/storage.ts +8 -9
  47. package/src/ui/CheckoutProvider.tsx +25 -13
  48. package/src/ui/Field.tsx +21 -3
  49. package/src/ui/Frozen.tsx +22 -0
  50. package/src/ui/InstallmentPicker.tsx +15 -18
  51. package/src/ui/PaymentMethods.tsx +6 -3
  52. package/src/ui/ProviderPages.tsx +134 -0
  53. package/src/ui/QuantitySelect.tsx +26 -2
  54. package/src/ui/Select.tsx +4 -1
  55. package/src/ui/SignaturePad.tsx +218 -0
  56. package/src/ui/index.ts +1 -0
  57. package/src/ui/primitives.tsx +13 -2
  58. package/src/ui/useAccountT.ts +16 -0
  59. package/src/useCheckout.ts +3 -1
@@ -0,0 +1,342 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ Image,
5
+ Modal,
6
+ Pressable,
7
+ ScrollView,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ } from 'react-native'
12
+ import { eventCommentImageDataUri, hooks, type EventComment } from 'tf-checkout-shared'
13
+ import { useTheme } from '../../ui/CheckoutProvider'
14
+ import { useAccountT } from '../../ui/useAccountT'
15
+
16
+ const LoadedImage = ({
17
+ eventId,
18
+ comment,
19
+ index,
20
+ previewKey,
21
+ full = false,
22
+ locale,
23
+ }: {
24
+ eventId: string
25
+ comment: EventComment
26
+ index: number
27
+ previewKey?: string
28
+ full?: boolean
29
+ locale?: string
30
+ }) => {
31
+ const t = useAccountT(locale)
32
+ const image = hooks.useEventCommentImage(eventId, comment, previewKey, index)
33
+ const theme = useTheme()
34
+ if (image.isLoading)
35
+ return (
36
+ <View style={s.loading}>
37
+ <ActivityIndicator
38
+ accessibilityLabel={t('Loading image {{number}}', { number: index + 1 })}
39
+ />
40
+ </View>
41
+ )
42
+ if (!image.data)
43
+ return full ? (
44
+ <Pressable
45
+ style={s.loading}
46
+ accessibilityRole="button"
47
+ accessibilityLabel={t('Retry image {{number}}', { number: index + 1 })}
48
+ onPress={() => void image.refetch()}
49
+ >
50
+ <Text style={{ color: theme.text, fontFamily: theme.fontFamily }}>
51
+ {t('Image couldn’t load. Tap to retry.')}
52
+ </Text>
53
+ </Pressable>
54
+ ) : (
55
+ <Text style={{ color: theme.muted, fontFamily: theme.fontFamily }}>
56
+ {t('Image unavailable')}
57
+ </Text>
58
+ )
59
+ return (
60
+ <Image
61
+ source={{ uri: image.data }}
62
+ style={s.image}
63
+ resizeMode={full ? 'contain' : 'cover'}
64
+ />
65
+ )
66
+ }
67
+
68
+ /** Images are fetched through the common authenticated/preview-key transport. */
69
+ export const CommentImages = ({
70
+ eventId,
71
+ comment,
72
+ previewKey,
73
+ onPreviewChange,
74
+ locale,
75
+ }: {
76
+ eventId: string
77
+ comment: EventComment
78
+ previewKey?: string
79
+ onPreviewChange: (open: boolean) => void
80
+ locale?: string
81
+ }) => {
82
+ const t = useAccountT(locale)
83
+ const theme = useTheme()
84
+ const white = [s.white, { fontFamily: theme.fontFamily }]
85
+ const [selected, setSelected] = useState<number | null>(null)
86
+ useEffect(() => {
87
+ if (selected === null) return
88
+ onPreviewChange(true)
89
+ return () => onPreviewChange(false)
90
+ }, [selected, onPreviewChange])
91
+ const images = comment.images
92
+ if (!images.length) return null
93
+ const position =
94
+ selected === null ? -1 : images.findIndex((image) => image.index === selected)
95
+ const label = (index: number) =>
96
+ images.length === 1
97
+ ? t('View attached image')
98
+ : t('View attached image {{number}} of {{total}}', {
99
+ number: index + 1,
100
+ total: images.length,
101
+ })
102
+ const move = (delta: number) => {
103
+ const target = images[position + delta]
104
+ if (target) setSelected(target.index)
105
+ }
106
+ return (
107
+ <>
108
+ <View style={[s.grid, images.length === 1 && s.single]}>
109
+ {images.slice(0, 3).map((image, i) => (
110
+ <Pressable
111
+ key={image.index}
112
+ style={s.tile}
113
+ accessibilityRole="button"
114
+ accessibilityLabel={label(i)}
115
+ onPress={() => setSelected(image.index)}
116
+ >
117
+ <LoadedImage
118
+ eventId={eventId}
119
+ comment={comment}
120
+ index={image.index}
121
+ previewKey={previewKey}
122
+ locale={locale}
123
+ />
124
+ {i === 2 && images.length > 3 && (
125
+ <View pointerEvents="none" style={s.more}>
126
+ <Text style={[s.moreText, { fontFamily: theme.fontFamily }]}>
127
+ +{images.length - 3}
128
+ </Text>
129
+ </View>
130
+ )}
131
+ </Pressable>
132
+ ))}
133
+ </View>
134
+ {selected !== null && position !== -1 && (
135
+ <Modal
136
+ visible
137
+ transparent
138
+ animationType="fade"
139
+ onRequestClose={() => setSelected(null)}
140
+ >
141
+ <View style={s.preview}>
142
+ <View style={s.toolbar}>
143
+ <Text accessibilityLiveRegion="polite" style={white}>
144
+ {t('Image {{number}} of {{total}}', {
145
+ number: position + 1,
146
+ total: images.length,
147
+ })}
148
+ </Text>
149
+ <Pressable
150
+ accessibilityRole="button"
151
+ accessibilityLabel={t('Close image')}
152
+ style={s.navButton}
153
+ onPress={() => setSelected(null)}
154
+ >
155
+ <Text style={white}>{t('Close')}</Text>
156
+ </Pressable>
157
+ </View>
158
+ <View style={s.full}>
159
+ <LoadedImage
160
+ eventId={eventId}
161
+ comment={comment}
162
+ index={selected}
163
+ previewKey={previewKey}
164
+ locale={locale}
165
+ full
166
+ />
167
+ </View>
168
+ {images.length > 1 && (
169
+ <View style={s.navigation}>
170
+ <Pressable
171
+ accessibilityRole="button"
172
+ accessibilityLabel={t('Previous image')}
173
+ style={s.navButton}
174
+ accessibilityState={{ disabled: position === 0 }}
175
+ disabled={position === 0}
176
+ onPress={() => move(-1)}
177
+ >
178
+ <Text style={[white, position === 0 && s.disabled]}>
179
+ {t('Previous')}
180
+ </Text>
181
+ </Pressable>
182
+ <View style={s.dots}>
183
+ {images.map((image, i) => (
184
+ <Pressable
185
+ key={image.index}
186
+ accessibilityRole="tab"
187
+ accessibilityLabel={t('Image {{number}} of {{total}}', {
188
+ number: i + 1,
189
+ total: images.length,
190
+ })}
191
+ accessibilityState={{ selected: image.index === selected }}
192
+ hitSlop={8}
193
+ onPress={() => setSelected(image.index)}
194
+ >
195
+ <View
196
+ style={[
197
+ s.dot,
198
+ {
199
+ backgroundColor:
200
+ image.index === selected ? theme.accent : '#777383',
201
+ },
202
+ ]}
203
+ />
204
+ </Pressable>
205
+ ))}
206
+ </View>
207
+ <Pressable
208
+ accessibilityRole="button"
209
+ accessibilityLabel={t('Next image')}
210
+ style={s.navButton}
211
+ accessibilityState={{ disabled: position === images.length - 1 }}
212
+ disabled={position === images.length - 1}
213
+ onPress={() => move(1)}
214
+ >
215
+ <Text style={[white, position === images.length - 1 && s.disabled]}>
216
+ {t('Next')}
217
+ </Text>
218
+ </Pressable>
219
+ </View>
220
+ )}
221
+ </View>
222
+ </Modal>
223
+ )}
224
+ </>
225
+ )
226
+ }
227
+
228
+ export const CommentImageDrafts = ({
229
+ images,
230
+ disabled,
231
+ onRemove,
232
+ locale,
233
+ }: {
234
+ images: string[]
235
+ disabled: boolean
236
+ onRemove: (index: number) => void
237
+ locale?: string
238
+ }) => {
239
+ const t = useAccountT(locale)
240
+ const theme = useTheme()
241
+ if (!images.length) return null
242
+ return (
243
+ <View style={s.drafts}>
244
+ <Text style={{ color: theme.muted, fontFamily: theme.fontFamily }}>
245
+ {t(
246
+ images.length === 1 ? '{{count}} image attached' : '{{count}} images attached',
247
+ { count: images.length }
248
+ )}
249
+ </Text>
250
+ <ScrollView
251
+ horizontal
252
+ showsHorizontalScrollIndicator={false}
253
+ contentContainerStyle={s.draftList}
254
+ >
255
+ {images.map((image, index) => (
256
+ <View key={index} style={s.draft}>
257
+ <Image
258
+ source={{ uri: eventCommentImageDataUri(image) ?? undefined }}
259
+ style={s.image}
260
+ resizeMode="cover"
261
+ />
262
+ <Pressable
263
+ accessibilityRole="button"
264
+ accessibilityLabel={
265
+ images.length === 1
266
+ ? t('Remove attached image')
267
+ : t('Remove attached image {{number}}', { number: index + 1 })
268
+ }
269
+ disabled={disabled}
270
+ hitSlop={8}
271
+ onPress={() => onRemove(index)}
272
+ style={s.remove}
273
+ >
274
+ <Text style={[s.white, { fontFamily: theme.fontFamily }]}>×</Text>
275
+ </Pressable>
276
+ </View>
277
+ ))}
278
+ </ScrollView>
279
+ </View>
280
+ )
281
+ }
282
+ const s = StyleSheet.create({
283
+ grid: {
284
+ width: '100%',
285
+ flexDirection: 'row',
286
+ gap: 8,
287
+ marginVertical: 4,
288
+ aspectRatio: 3,
289
+ },
290
+ single: { aspectRatio: 1.5 },
291
+ tile: {
292
+ flex: 1,
293
+ borderRadius: 8,
294
+ overflow: 'hidden',
295
+ backgroundColor: 'rgba(235,235,238,0.05)',
296
+ },
297
+ image: { width: '100%', height: '100%' },
298
+ loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
299
+ more: {
300
+ ...StyleSheet.absoluteFillObject,
301
+ backgroundColor: 'rgba(11,10,21,0.8)',
302
+ alignItems: 'center',
303
+ justifyContent: 'center',
304
+ },
305
+ moreText: { color: '#EBEBEE', fontSize: 16, fontWeight: '500' },
306
+ preview: {
307
+ flex: 1,
308
+ backgroundColor: 'rgba(11,10,21,0.96)',
309
+ paddingTop: 60,
310
+ paddingBottom: 40,
311
+ paddingHorizontal: 16,
312
+ },
313
+ toolbar: { flexDirection: 'row', justifyContent: 'space-between', padding: 12 },
314
+ full: { flex: 1, width: '100%' },
315
+ white: { color: '#EBEBEE', fontSize: 16 },
316
+ navButton: {
317
+ minHeight: 44,
318
+ minWidth: 60,
319
+ alignItems: 'center',
320
+ justifyContent: 'center',
321
+ },
322
+ disabled: { opacity: 0.35 },
323
+ navigation: {
324
+ flexDirection: 'row',
325
+ alignItems: 'center',
326
+ justifyContent: 'space-between',
327
+ padding: 12,
328
+ },
329
+ dots: { flexDirection: 'row', gap: 12 },
330
+ dot: { width: 7, height: 7, borderRadius: 4 },
331
+ drafts: { padding: 12, gap: 8 },
332
+ draftList: { gap: 8 },
333
+ draft: { width: 84, height: 84, borderRadius: 8, overflow: 'hidden' },
334
+ remove: {
335
+ position: 'absolute',
336
+ right: 0,
337
+ top: 0,
338
+ backgroundColor: 'rgba(11,10,21,0.8)',
339
+ paddingHorizontal: 8,
340
+ paddingVertical: 4,
341
+ },
342
+ })
@@ -0,0 +1,88 @@
1
+ import React, { useMemo, useRef } from 'react'
2
+ import { Animated, PanResponder, StyleSheet, Text, View } from 'react-native'
3
+ import { useTheme } from '../../ui/CheckoutProvider'
4
+
5
+ /** An alternate input for the common reply action; vertical scrolling stays native. */
6
+ export const SwipeReply = ({
7
+ enabled,
8
+ onReply,
9
+ renderIcon,
10
+ hint,
11
+ children,
12
+ }: {
13
+ enabled: boolean
14
+ onReply: () => void
15
+ renderIcon?: (color: string) => React.ReactNode
16
+ hint: string
17
+ children: React.ReactNode
18
+ }) => {
19
+ const theme = useTheme()
20
+ const offset = useRef(new Animated.Value(0)).current
21
+ const gesture = useMemo(() => {
22
+ const reset = () =>
23
+ Animated.spring(offset, { toValue: 0, useNativeDriver: true }).start()
24
+ return PanResponder.create({
25
+ onMoveShouldSetPanResponder: (_, value) =>
26
+ enabled && value.dx < -12 && Math.abs(value.dx) > Math.abs(value.dy) * 1.5,
27
+ onPanResponderMove: (_, value) =>
28
+ offset.setValue(Math.max(-88, Math.min(0, value.dx))),
29
+ onPanResponderRelease: (_, value) => {
30
+ if (enabled && value.dx < -56) onReply()
31
+ reset()
32
+ },
33
+ onPanResponderTerminate: reset,
34
+ onPanResponderTerminationRequest: () => true,
35
+ })
36
+ }, [enabled, offset, onReply])
37
+ return (
38
+ <View style={s.container}>
39
+ {enabled && (
40
+ <Animated.View
41
+ pointerEvents="none"
42
+ accessibilityElementsHidden
43
+ importantForAccessibility="no-hide-descendants"
44
+ style={[
45
+ s.indicator,
46
+ {
47
+ borderColor: theme.border,
48
+ backgroundColor: theme.surface,
49
+ opacity: offset.interpolate({
50
+ inputRange: [-56, -12, 0],
51
+ outputRange: [1, 0, 0],
52
+ extrapolate: 'clamp',
53
+ }),
54
+ },
55
+ ]}
56
+ >
57
+ {renderIcon ? (
58
+ renderIcon(theme.text)
59
+ ) : (
60
+ <Text style={{ color: theme.text }}>↪</Text>
61
+ )}
62
+ </Animated.View>
63
+ )}
64
+ <Animated.View
65
+ accessibilityHint={enabled ? hint : undefined}
66
+ {...gesture.panHandlers}
67
+ style={{ transform: [{ translateX: offset }] }}
68
+ >
69
+ {children}
70
+ </Animated.View>
71
+ </View>
72
+ )
73
+ }
74
+ const s = StyleSheet.create({
75
+ container: { width: '100%', position: 'relative', overflow: 'hidden' },
76
+ indicator: {
77
+ position: 'absolute',
78
+ right: 16,
79
+ top: '50%',
80
+ marginTop: -21,
81
+ width: 42,
82
+ height: 42,
83
+ borderRadius: 21,
84
+ borderWidth: 1,
85
+ alignItems: 'center',
86
+ justifyContent: 'center',
87
+ },
88
+ })
@@ -1,8 +1,28 @@
1
1
  /** The native checkout sections — the RN analogues of the web `sections/`. */
2
2
  export { Tickets } from './Tickets'
3
+ export { SeatMap, type SeatMapProps } from './SeatMap'
3
4
  export { AddOns } from './AddOns'
5
+ export { Bundles, type BundlesProps } from './Bundles'
4
6
  export { BillingInfo, type CheckoutPrivacyPolicy } from './BillingInfo'
5
7
  export { OrderSummary } from './OrderSummary'
6
8
  export { Payment } from './Payment'
9
+ export { WaiverSigning, type WaiverSigningProps } from './WaiverSigning'
7
10
  export { IDVerification } from './IDVerification'
8
11
  export { Confirmation } from './Confirmation'
12
+ export { Rewards, type RewardsProps } from './Rewards'
13
+ export { TicketChange, type TicketChangeProps } from './TicketChange'
14
+ export { RewardsLadder, type RewardsLadderProps } from './rewards/RewardsLadder'
15
+ export { RewardsTree, type RewardsTreeProps } from './rewards/RewardsTree'
16
+ export { Trips, type TripsProps } from './Trips'
17
+
18
+ export { EventDiscussionView, type EventDiscussionViewProps } from './EventDiscussionView'
19
+
20
+ export * from './Accommodation'
21
+
22
+ export * from './TicketRefund'
23
+
24
+ export * from './Passes'
25
+ export * from './TicketGift'
26
+
27
+ export { AccountRewardsSection } from './AccountRewards'
28
+ export * from './PostReaction'
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The prize ladder on native — cash-back tiers and prizes in one list.
3
+ *
4
+ * The wording comes from the shared `ladderRowCopy` as translation keys, and
5
+ * this package's idiom is copy in through props, so the caller passes a
6
+ * `translate`. `<Rewards/>` defaults it to the shared translator, which means a
7
+ * host that passes nothing still gets correct English.
8
+ */
9
+ import React, { useState, type FC } from 'react'
10
+ import { StyleSheet, View } from 'react-native'
11
+
12
+ import {
13
+ awardDetails,
14
+ errorMessage,
15
+ prizeDetailsComplete,
16
+ type RewardsPrizeDetailField,
17
+ ladderRowCopy,
18
+ ladderRowStatus,
19
+ type RewardsLadderRow,
20
+ type Translate,
21
+ } from 'tf-checkout-shared'
22
+
23
+ import { BodyText, MutedText, Button, ErrorText } from '../../ui/primitives'
24
+ import { useTheme } from '../../ui/CheckoutProvider'
25
+ import { Field, Input } from '../../ui/Field'
26
+
27
+ /** Award statuses are wire values; these are the sentences a buyer reads. */
28
+ const AWARD_STATUS_KEYS: Record<string, string> = {
29
+ pending_details: 'rewards.awards.pendingDetails',
30
+ ready: 'rewards.awards.ready',
31
+ delivered: 'rewards.awards.delivered',
32
+ cancelled: 'rewards.awards.cancelled',
33
+ }
34
+
35
+ export interface RewardsLadderProps {
36
+ rows: RewardsLadderRow[]
37
+ translate: Translate
38
+ onSubmitDetails?: (awardId: string, details: Record<string, string>) => Promise<void>
39
+ }
40
+
41
+ const DeliveryDetails: FC<{
42
+ fields: RewardsPrizeDetailField[]
43
+ translate: Translate
44
+ onSubmit: (values: Record<string, string>) => Promise<void>
45
+ }> = ({ fields, translate: t, onSubmit }) => {
46
+ const [values, setValues] = useState<Record<string, string>>({})
47
+ const [saving, setSaving] = useState(false)
48
+ const [failure, setFailure] = useState('')
49
+ return (
50
+ <View style={{ gap: 10 }}>
51
+ {fields.map((field) => (
52
+ <Field key={field.key} label={field.label}>
53
+ <Input
54
+ accessibilityLabel={field.label}
55
+ value={values[field.key] ?? ''}
56
+ editable={!saving}
57
+ onChangeText={(value) =>
58
+ setValues((previous) => ({ ...previous, [field.key]: value }))
59
+ }
60
+ />
61
+ </Field>
62
+ ))}
63
+ {failure && <ErrorText>{failure}</ErrorText>}
64
+ <Button
65
+ loading={saving}
66
+ disabled={!prizeDetailsComplete(fields, values)}
67
+ onPress={() => {
68
+ setSaving(true)
69
+ setFailure('')
70
+ void onSubmit(values)
71
+ .catch((error: unknown) => setFailure(errorMessage(error)))
72
+ .finally(() => setSaving(false))
73
+ }}
74
+ >
75
+ {t('rewards.awards.submit')}
76
+ </Button>
77
+ </View>
78
+ )
79
+ }
80
+
81
+ export const RewardsLadder: FC<RewardsLadderProps> = ({
82
+ rows,
83
+ translate,
84
+ onSubmitDetails,
85
+ }) => {
86
+ const theme = useTheme()
87
+ if (!rows || rows.length === 0) return null
88
+
89
+ return (
90
+ <View style={styles.list}>
91
+ {rows.map((row, index) => {
92
+ const copy = ladderRowCopy(row)
93
+ const target = row.target_value
94
+ // A rank row has no target, so it has no bar to fill; nor has a row with
95
+ // nothing measured yet — a pack prize with no pack behind it, whose
96
+ // progress is null rather than zero.
97
+ const percent =
98
+ target && target > 0 && row.progress_value != null
99
+ ? Math.min(100, Math.round((row.progress_value / target) * 100))
100
+ : null
101
+ const award = row.award
102
+ const statusKey = award ? AWARD_STATUS_KEYS[award.status] : undefined
103
+ const details = awardDetails(award)
104
+ const fields = details.fields.length
105
+ ? details.fields
106
+ : [
107
+ {
108
+ key: 'details',
109
+ label: translate('rewards.awards.detailsFallbackLabel'),
110
+ required: true,
111
+ },
112
+ ]
113
+
114
+ return (
115
+ <View
116
+ key={
117
+ row.prize_id != null
118
+ ? String(row.prize_id)
119
+ : `${row.kind}-${row.metric ?? 'tier'}-${index}`
120
+ }
121
+ style={[styles.row, { borderColor: theme.border }]}
122
+ >
123
+ <BodyText style={styles.rowStatus}>
124
+ {ladderRowStatus(copy, translate)}
125
+ </BodyText>
126
+
127
+ {percent != null ? (
128
+ <View style={[styles.bar, { backgroundColor: theme.border }]}>
129
+ <View
130
+ style={[
131
+ styles.barFill,
132
+ { backgroundColor: theme.accent, width: `${percent}%` },
133
+ ]}
134
+ />
135
+ </View>
136
+ ) : null}
137
+
138
+ {copy.timing.map((line) => (
139
+ <MutedText key={line.key}>{translate(line.key, line.params)}</MutedText>
140
+ ))}
141
+
142
+ {award && statusKey ? (
143
+ <MutedText>
144
+ {translate(statusKey, { at: String(award.delivered_at ?? '') })}
145
+ </MutedText>
146
+ ) : null}
147
+ {award && details.needed && onSubmitDetails ? (
148
+ <DeliveryDetails
149
+ fields={fields}
150
+ translate={translate}
151
+ onSubmit={(values) => onSubmitDetails(String(award.id), values)}
152
+ />
153
+ ) : null}
154
+ </View>
155
+ )
156
+ })}
157
+ </View>
158
+ )
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ list: { gap: 10 },
163
+ row: { borderWidth: StyleSheet.hairlineWidth, borderRadius: 10, padding: 12, gap: 4 },
164
+ rowStatus: { fontWeight: '600' },
165
+ bar: { height: 6, borderRadius: 999, overflow: 'hidden', marginVertical: 4 },
166
+ barFill: { height: '100%' },
167
+ })