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,366 @@
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
+ accessible={full}
63
+ accessibilityLabel={
64
+ full
65
+ ? t('Image {{number}} of {{total}}', {
66
+ number: index + 1,
67
+ total: comment.images.length,
68
+ })
69
+ : undefined
70
+ }
71
+ accessibilityLiveRegion={full ? 'polite' : undefined}
72
+ style={s.image}
73
+ resizeMode={full ? 'contain' : 'cover'}
74
+ />
75
+ )
76
+ }
77
+
78
+ /** Images are fetched through the common authenticated/preview-key transport. */
79
+ export const CommentImages = ({
80
+ eventId,
81
+ comment,
82
+ previewKey,
83
+ onPreviewChange,
84
+ locale,
85
+ renderCloseIcon,
86
+ }: {
87
+ eventId: string
88
+ comment: EventComment
89
+ previewKey?: string
90
+ onPreviewChange: (open: boolean) => void
91
+ locale?: string
92
+ renderCloseIcon?: (color: string) => React.ReactNode
93
+ }) => {
94
+ const t = useAccountT(locale)
95
+ const theme = useTheme()
96
+ const white = [s.white, { fontFamily: theme.fontFamily }]
97
+ const [selected, setSelected] = useState<number | null>(null)
98
+ useEffect(() => {
99
+ if (selected === null) return
100
+ onPreviewChange(true)
101
+ return () => onPreviewChange(false)
102
+ }, [selected, onPreviewChange])
103
+ const images = comment.images
104
+ if (!images.length) return null
105
+ const position =
106
+ selected === null ? -1 : images.findIndex((image) => image.index === selected)
107
+ const label = (index: number) =>
108
+ images.length === 1
109
+ ? t('View attached image')
110
+ : t('View attached image {{number}} of {{total}}', {
111
+ number: index + 1,
112
+ total: images.length,
113
+ })
114
+ const move = (delta: number) => {
115
+ const target = images[position + delta]
116
+ if (target) setSelected(target.index)
117
+ }
118
+ return (
119
+ <>
120
+ <View style={[s.grid, images.length === 1 && s.single]}>
121
+ {images.slice(0, 3).map((image, i) => (
122
+ <Pressable
123
+ key={image.index}
124
+ style={s.tile}
125
+ accessibilityRole="button"
126
+ accessibilityLabel={label(i)}
127
+ onPress={() => setSelected(image.index)}
128
+ >
129
+ <LoadedImage
130
+ eventId={eventId}
131
+ comment={comment}
132
+ index={image.index}
133
+ previewKey={previewKey}
134
+ locale={locale}
135
+ />
136
+ {i === 2 && images.length > 3 && (
137
+ <View pointerEvents="none" style={s.more}>
138
+ <Text style={[s.moreText, { fontFamily: theme.fontFamily }]}>
139
+ +{images.length - 3}
140
+ </Text>
141
+ </View>
142
+ )}
143
+ </Pressable>
144
+ ))}
145
+ </View>
146
+ {selected !== null && position !== -1 && (
147
+ <Modal
148
+ visible
149
+ transparent
150
+ animationType="fade"
151
+ onRequestClose={() => setSelected(null)}
152
+ >
153
+ <View style={s.preview}>
154
+ <View style={s.toolbar}>
155
+ <Pressable
156
+ accessibilityRole="button"
157
+ accessibilityLabel={t('Close image')}
158
+ style={s.closeButton}
159
+ onPress={() => setSelected(null)}
160
+ >
161
+ <View
162
+ pointerEvents="none"
163
+ accessible={false}
164
+ importantForAccessibility="no-hide-descendants"
165
+ >
166
+ {renderCloseIcon ? (
167
+ renderCloseIcon('#9F9CB0')
168
+ ) : (
169
+ <Text style={[white, s.closeGlyph]}>×</Text>
170
+ )}
171
+ </View>
172
+ </Pressable>
173
+ </View>
174
+ <View style={s.full}>
175
+ <LoadedImage
176
+ eventId={eventId}
177
+ comment={comment}
178
+ index={selected}
179
+ previewKey={previewKey}
180
+ locale={locale}
181
+ full
182
+ />
183
+ </View>
184
+ {images.length > 1 && (
185
+ <View style={s.navigation}>
186
+ <Pressable
187
+ accessibilityRole="button"
188
+ accessibilityLabel={t('Previous image')}
189
+ style={s.navButton}
190
+ accessibilityState={{ disabled: position === 0 }}
191
+ disabled={position === 0}
192
+ onPress={() => move(-1)}
193
+ >
194
+ <Text style={[white, position === 0 && s.disabled]}>
195
+ {t('Previous')}
196
+ </Text>
197
+ </Pressable>
198
+ <View style={s.dots}>
199
+ {images.map((image, i) => (
200
+ <Pressable
201
+ key={image.index}
202
+ accessibilityRole="tab"
203
+ accessibilityLabel={t('Image {{number}} of {{total}}', {
204
+ number: i + 1,
205
+ total: images.length,
206
+ })}
207
+ accessibilityState={{ selected: image.index === selected }}
208
+ hitSlop={8}
209
+ onPress={() => setSelected(image.index)}
210
+ >
211
+ <View
212
+ style={[
213
+ s.dot,
214
+ {
215
+ backgroundColor:
216
+ image.index === selected ? theme.accent : '#777383',
217
+ },
218
+ ]}
219
+ />
220
+ </Pressable>
221
+ ))}
222
+ </View>
223
+ <Pressable
224
+ accessibilityRole="button"
225
+ accessibilityLabel={t('Next image')}
226
+ style={s.navButton}
227
+ accessibilityState={{ disabled: position === images.length - 1 }}
228
+ disabled={position === images.length - 1}
229
+ onPress={() => move(1)}
230
+ >
231
+ <Text style={[white, position === images.length - 1 && s.disabled]}>
232
+ {t('Next')}
233
+ </Text>
234
+ </Pressable>
235
+ </View>
236
+ )}
237
+ </View>
238
+ </Modal>
239
+ )}
240
+ </>
241
+ )
242
+ }
243
+
244
+ export const CommentImageDrafts = ({
245
+ images,
246
+ disabled,
247
+ onRemove,
248
+ locale,
249
+ }: {
250
+ images: string[]
251
+ disabled: boolean
252
+ onRemove: (index: number) => void
253
+ locale?: string
254
+ }) => {
255
+ const t = useAccountT(locale)
256
+ const theme = useTheme()
257
+ if (!images.length) return null
258
+ return (
259
+ <View style={s.drafts}>
260
+ <Text style={{ color: theme.muted, fontFamily: theme.fontFamily }}>
261
+ {t(
262
+ images.length === 1 ? '{{count}} image attached' : '{{count}} images attached',
263
+ { count: images.length }
264
+ )}
265
+ </Text>
266
+ <ScrollView
267
+ horizontal
268
+ showsHorizontalScrollIndicator={false}
269
+ contentContainerStyle={s.draftList}
270
+ >
271
+ {images.map((image, index) => (
272
+ <View key={index} style={s.draft}>
273
+ <Image
274
+ source={{ uri: eventCommentImageDataUri(image) ?? undefined }}
275
+ style={s.image}
276
+ resizeMode="cover"
277
+ />
278
+ <Pressable
279
+ accessibilityRole="button"
280
+ accessibilityLabel={
281
+ images.length === 1
282
+ ? t('Remove attached image')
283
+ : t('Remove attached image {{number}}', { number: index + 1 })
284
+ }
285
+ disabled={disabled}
286
+ hitSlop={8}
287
+ onPress={() => onRemove(index)}
288
+ style={s.remove}
289
+ >
290
+ <Text style={[s.white, { fontFamily: theme.fontFamily }]}>×</Text>
291
+ </Pressable>
292
+ </View>
293
+ ))}
294
+ </ScrollView>
295
+ </View>
296
+ )
297
+ }
298
+ const s = StyleSheet.create({
299
+ grid: {
300
+ width: '100%',
301
+ flexDirection: 'row',
302
+ gap: 8,
303
+ marginVertical: 4,
304
+ aspectRatio: 3,
305
+ },
306
+ single: { aspectRatio: 1.5 },
307
+ tile: {
308
+ flex: 1,
309
+ borderRadius: 8,
310
+ overflow: 'hidden',
311
+ backgroundColor: 'rgba(235,235,238,0.05)',
312
+ },
313
+ image: { width: '100%', height: '100%' },
314
+ loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
315
+ more: {
316
+ ...StyleSheet.absoluteFillObject,
317
+ backgroundColor: 'rgba(11,10,21,0.8)',
318
+ alignItems: 'center',
319
+ justifyContent: 'center',
320
+ },
321
+ moreText: { color: '#EBEBEE', fontSize: 16, fontWeight: '500' },
322
+ preview: {
323
+ flex: 1,
324
+ backgroundColor: 'rgba(11,10,21,0.96)',
325
+ paddingTop: 53,
326
+ paddingBottom: 40,
327
+ paddingHorizontal: 16,
328
+ },
329
+ toolbar: { flexDirection: 'row', justifyContent: 'flex-end' },
330
+ closeButton: {
331
+ width: 44,
332
+ height: 44,
333
+ marginRight: -10,
334
+ alignItems: 'center',
335
+ justifyContent: 'center',
336
+ },
337
+ closeGlyph: { fontSize: 28, lineHeight: 28, color: '#9F9CB0', textAlign: 'center' },
338
+ full: { flex: 1, width: '100%' },
339
+ white: { color: '#EBEBEE', fontSize: 16 },
340
+ navButton: {
341
+ minHeight: 44,
342
+ minWidth: 60,
343
+ alignItems: 'center',
344
+ justifyContent: 'center',
345
+ },
346
+ disabled: { opacity: 0.35 },
347
+ navigation: {
348
+ flexDirection: 'row',
349
+ alignItems: 'center',
350
+ justifyContent: 'space-between',
351
+ padding: 12,
352
+ },
353
+ dots: { flexDirection: 'row', gap: 12 },
354
+ dot: { width: 7, height: 7, borderRadius: 4 },
355
+ drafts: { padding: 12, gap: 8 },
356
+ draftList: { gap: 8 },
357
+ draft: { width: 84, height: 84, borderRadius: 8, overflow: 'hidden' },
358
+ remove: {
359
+ position: 'absolute',
360
+ right: 0,
361
+ top: 0,
362
+ backgroundColor: 'rgba(11,10,21,0.8)',
363
+ paddingHorizontal: 8,
364
+ paddingVertical: 4,
365
+ },
366
+ })
@@ -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,34 @@
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'
29
+ export {
30
+ Streaming,
31
+ StreamingView,
32
+ type StreamingProps,
33
+ type StreamingProvider,
34
+ } from './Streaming'
@@ -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
+ })