jfs-components 0.1.2 → 0.1.8
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/CHANGELOG.md +29 -0
- package/lib/commonjs/components/AmountInput/AmountInput.js +8 -5
- package/lib/commonjs/components/BenefitCard/BenefitCard.js +231 -0
- package/lib/commonjs/components/CcCard/CcCard.js +470 -0
- package/lib/commonjs/components/Checkbox/Checkbox.js +4 -3
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +4 -3
- package/lib/commonjs/components/CompareTable/CompareTable.js +372 -0
- package/lib/commonjs/components/ComparisonBar/ComparisonBar.js +266 -0
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +35 -3
- package/lib/commonjs/components/FormField/FormField.js +4 -3
- package/lib/commonjs/components/InputSearch/InputSearch.js +6 -4
- package/lib/commonjs/components/NoteInput/NoteInput.js +6 -5
- package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +273 -0
- package/lib/commonjs/components/ProductMerchandisingCard/GlassFill.js +263 -0
- package/lib/commonjs/components/ProductMerchandisingCard/GlassFill.web.js +116 -0
- package/lib/commonjs/components/ProductMerchandisingCard/ProductMerchandisingCard.js +353 -0
- package/lib/commonjs/components/ProjectionMarker/ProjectionMarker.js +161 -0
- package/lib/commonjs/components/Radio/Radio.js +5 -5
- package/lib/commonjs/components/Slider/Slider.js +473 -0
- package/lib/commonjs/components/TextInput/TextInput.js +13 -8
- package/lib/commonjs/components/TextSegment/TextSegment.js +118 -0
- package/lib/commonjs/components/index.js +63 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/design-tokens/figma-modes.generated.js +38 -9
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/utils/react-utils.js +22 -0
- package/lib/module/components/AmountInput/AmountInput.js +6 -4
- package/lib/module/components/BenefitCard/BenefitCard.js +225 -0
- package/lib/module/components/CcCard/CcCard.js +464 -0
- package/lib/module/components/Checkbox/Checkbox.js +5 -4
- package/lib/module/components/CheckboxItem/CheckboxItem.js +5 -4
- package/lib/module/components/CompareTable/CompareTable.js +367 -0
- package/lib/module/components/ComparisonBar/ComparisonBar.js +260 -0
- package/lib/module/components/DropdownInput/DropdownInput.js +36 -4
- package/lib/module/components/FormField/FormField.js +5 -4
- package/lib/module/components/InputSearch/InputSearch.js +6 -4
- package/lib/module/components/NoteInput/NoteInput.js +7 -6
- package/lib/module/components/PdpCcCard/PdpCcCard.js +267 -0
- package/lib/module/components/ProductMerchandisingCard/GlassFill.js +257 -0
- package/lib/module/components/ProductMerchandisingCard/GlassFill.web.js +111 -0
- package/lib/module/components/ProductMerchandisingCard/ProductMerchandisingCard.js +347 -0
- package/lib/module/components/ProjectionMarker/ProjectionMarker.js +156 -0
- package/lib/module/components/Radio/Radio.js +5 -4
- package/lib/module/components/Slider/Slider.js +468 -0
- package/lib/module/components/TextInput/TextInput.js +15 -10
- package/lib/module/components/TextSegment/TextSegment.js +113 -0
- package/lib/module/components/index.js +9 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/design-tokens/figma-modes.generated.js +38 -9
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/utils/react-utils.js +21 -0
- package/lib/typescript/src/components/AmountInput/AmountInput.d.ts +3 -2
- package/lib/typescript/src/components/BenefitCard/BenefitCard.d.ts +93 -0
- package/lib/typescript/src/components/CcCard/CcCard.d.ts +137 -0
- package/lib/typescript/src/components/Checkbox/Checkbox.d.ts +3 -2
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +2 -2
- package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +88 -0
- package/lib/typescript/src/components/ComparisonBar/ComparisonBar.d.ts +118 -0
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +20 -1
- package/lib/typescript/src/components/FormField/FormField.d.ts +2 -2
- package/lib/typescript/src/components/InputSearch/InputSearch.d.ts +23 -2
- package/lib/typescript/src/components/NoteInput/NoteInput.d.ts +19 -2
- package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +84 -0
- package/lib/typescript/src/components/ProductMerchandisingCard/GlassFill.d.ts +56 -0
- package/lib/typescript/src/components/ProductMerchandisingCard/GlassFill.web.d.ts +27 -0
- package/lib/typescript/src/components/ProductMerchandisingCard/ProductMerchandisingCard.d.ts +81 -0
- package/lib/typescript/src/components/ProjectionMarker/ProjectionMarker.d.ts +82 -0
- package/lib/typescript/src/components/Radio/Radio.d.ts +3 -2
- package/lib/typescript/src/components/RadioButton/RadioButton.d.ts +2 -2
- package/lib/typescript/src/components/Slider/Slider.d.ts +99 -0
- package/lib/typescript/src/components/TextInput/TextInput.d.ts +9 -29
- package/lib/typescript/src/components/TextSegment/TextSegment.d.ts +100 -0
- package/lib/typescript/src/components/index.d.ts +10 -1
- package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +22 -2
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/lib/typescript/src/utils/react-utils.d.ts +10 -0
- package/package.json +2 -1
- package/src/components/AmountInput/AmountInput.tsx +7 -5
- package/src/components/BenefitCard/BenefitCard.tsx +309 -0
- package/src/components/CcCard/CcCard.tsx +598 -0
- package/src/components/Checkbox/Checkbox.tsx +5 -4
- package/src/components/CheckboxItem/CheckboxItem.tsx +5 -4
- package/src/components/CompareTable/CompareTable.tsx +477 -0
- package/src/components/ComparisonBar/ComparisonBar.tsx +356 -0
- package/src/components/DropdownInput/DropdownInput.tsx +55 -3
- package/src/components/FormField/FormField.tsx +5 -4
- package/src/components/InputSearch/InputSearch.tsx +8 -5
- package/src/components/NoteInput/NoteInput.tsx +8 -6
- package/src/components/PdpCcCard/PdpCcCard.tsx +356 -0
- package/src/components/ProductMerchandisingCard/GlassFill.tsx +276 -0
- package/src/components/ProductMerchandisingCard/GlassFill.web.tsx +127 -0
- package/src/components/ProductMerchandisingCard/ProductMerchandisingCard.tsx +423 -0
- package/src/components/ProjectionMarker/ProjectionMarker.tsx +277 -0
- package/src/components/Radio/Radio.tsx +5 -4
- package/src/components/Slider/Slider.tsx +628 -0
- package/src/components/TextInput/TextInput.tsx +15 -11
- package/src/components/TextSegment/TextSegment.tsx +166 -0
- package/src/components/index.ts +10 -1
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/design-tokens/figma-modes.generated.ts +38 -9
- package/src/icons/registry.ts +1 -1
- package/src/utils/react-utils.ts +23 -0
- package/lib/typescript/scripts/extract-component-tokens.d.ts +0 -9
- package/lib/typescript/scripts/generate-component-docs.d.ts +0 -9
- package/lib/typescript/scripts/generate-icon-registry.d.ts +0 -3
- package/lib/typescript/scripts/generate-mode-types.d.ts +0 -2
- package/lib/typescript/scripts/retype-modes.d.cts +0 -2
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
type TextStyle,
|
|
9
|
+
type StyleProp,
|
|
10
|
+
type ImageSourcePropType,
|
|
11
|
+
} from 'react-native'
|
|
12
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
13
|
+
import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
|
|
14
|
+
import Title from '../Title/Title'
|
|
15
|
+
import Divider from '../Divider/Divider'
|
|
16
|
+
import Button from '../Button/Button'
|
|
17
|
+
import Image from '../Image/Image'
|
|
18
|
+
import Icon from '../Icon/Icon'
|
|
19
|
+
import type { Modes } from '../../design-tokens'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single metric column inside the card's stats row: a small title, a bold
|
|
23
|
+
* value and an optional muted caption.
|
|
24
|
+
*/
|
|
25
|
+
export interface PdpCcCardMetric {
|
|
26
|
+
/** Small label rendered above the value (e.g. `"Weight"`). */
|
|
27
|
+
title?: string
|
|
28
|
+
/** The prominent value (e.g. `"24K"`). */
|
|
29
|
+
value?: string
|
|
30
|
+
/** Optional muted caption rendered below the value. */
|
|
31
|
+
caption?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PdpCcCardProps {
|
|
35
|
+
/**
|
|
36
|
+
* Image source for the default media slot. Accepts a URL string or any RN
|
|
37
|
+
* `ImageSourcePropType`. Ignored when `media` is provided.
|
|
38
|
+
*/
|
|
39
|
+
imageSource?: ImageSourcePropType | string
|
|
40
|
+
/** Default media image width. Defaults to the Figma spec (`100`). */
|
|
41
|
+
imageWidth?: number
|
|
42
|
+
/** Default media image height. Defaults to the Figma spec (`60`). */
|
|
43
|
+
imageHeight?: number
|
|
44
|
+
/**
|
|
45
|
+
* Full override for the media slot (top of the card). Takes precedence over
|
|
46
|
+
* `imageSource`. `modes` cascade into it automatically.
|
|
47
|
+
*/
|
|
48
|
+
media?: React.ReactNode
|
|
49
|
+
/** Headline title (26px black). */
|
|
50
|
+
title?: string
|
|
51
|
+
/** Subtitle rendered below the title (14px medium). */
|
|
52
|
+
subtitle?: string
|
|
53
|
+
/**
|
|
54
|
+
* The metric columns rendered in the stats row. Vertical dividers are
|
|
55
|
+
* inserted automatically between adjacent metrics. Defaults to two sample
|
|
56
|
+
* metrics.
|
|
57
|
+
*/
|
|
58
|
+
metrics?: PdpCcCardMetric[]
|
|
59
|
+
/** CTA button label. Defaults to `"button"`. */
|
|
60
|
+
buttonLabel?: string
|
|
61
|
+
/**
|
|
62
|
+
* Registry icon name for the button's leading glyph. Defaults to
|
|
63
|
+
* `'ic_add_circle'`. Pass `null` to hide the leading icon.
|
|
64
|
+
*/
|
|
65
|
+
buttonIcon?: string | null
|
|
66
|
+
/** CTA press handler. */
|
|
67
|
+
onButtonPress?: () => void
|
|
68
|
+
/** Full override for the CTA. Takes precedence over `buttonLabel`. */
|
|
69
|
+
button?: React.ReactNode
|
|
70
|
+
/** Toggles the CTA button. Defaults to `true`. */
|
|
71
|
+
showButton?: boolean
|
|
72
|
+
/** Press handler for the whole card. When set, the card becomes pressable. */
|
|
73
|
+
onPress?: () => void
|
|
74
|
+
/** Card width. Defaults to the Figma spec (`344`). Pass `'100%'` to fill the parent. */
|
|
75
|
+
width?: number | `${number}%`
|
|
76
|
+
/** Modes object for design-token resolution. Cascaded to all children. */
|
|
77
|
+
modes?: Modes
|
|
78
|
+
/** Style overrides for the card container. */
|
|
79
|
+
style?: StyleProp<ViewStyle>
|
|
80
|
+
/** Accessibility label for the card. */
|
|
81
|
+
accessibilityLabel?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* PdpCcCard — Figma node 5352:935 ("PDP cc card").
|
|
86
|
+
*
|
|
87
|
+
* A centered white product/PDP card composed from the shared design-system
|
|
88
|
+
* primitives so it stays in sync with the rest of the library:
|
|
89
|
+
*
|
|
90
|
+
* - **Media** — a top image slot (`Image`, rounded via `image/radius`). Pass
|
|
91
|
+
* `imageSource` for the default image or `media` for a full slot override.
|
|
92
|
+
* - **Title** — a centered headline + subtitle rendered through the shared
|
|
93
|
+
* {@link Title} component (`title/*`, `pageSubtitle/*` tokens).
|
|
94
|
+
* - **Metrics** — a row of {@link PdpCcCardMetric} columns (title / value /
|
|
95
|
+
* caption) separated by vertical `Divider`s (`metricdata/*` tokens).
|
|
96
|
+
* - **CTA** — a small tonal {@link Button} (`Button / Size: S`,
|
|
97
|
+
* `AppearanceBrand: Secondary`, `Emphasis: Medium`) with a leading icon.
|
|
98
|
+
*
|
|
99
|
+
* All defaults can be overridden via `modes`.
|
|
100
|
+
*/
|
|
101
|
+
function PdpCcCard({
|
|
102
|
+
imageSource,
|
|
103
|
+
imageWidth = 100,
|
|
104
|
+
imageHeight = 60,
|
|
105
|
+
media,
|
|
106
|
+
title = 'Title',
|
|
107
|
+
subtitle = 'Subtitle',
|
|
108
|
+
metrics = DEFAULT_METRICS,
|
|
109
|
+
buttonLabel = 'button',
|
|
110
|
+
buttonIcon = 'ic_add_circle',
|
|
111
|
+
onButtonPress,
|
|
112
|
+
button,
|
|
113
|
+
showButton = true,
|
|
114
|
+
onPress,
|
|
115
|
+
width = 344,
|
|
116
|
+
modes = EMPTY_MODES,
|
|
117
|
+
style,
|
|
118
|
+
accessibilityLabel,
|
|
119
|
+
}: PdpCcCardProps) {
|
|
120
|
+
const tokens = useMemo(() => resolveTokens(modes), [modes])
|
|
121
|
+
|
|
122
|
+
// The CTA uses the brand "Secondary" appearance at "Medium" emphasis (lilac
|
|
123
|
+
// fill + purple label) and the small size — matching the design. A
|
|
124
|
+
// consumer-supplied `modes` value still wins via spread order.
|
|
125
|
+
const ctaModes = useMemo<Modes>(
|
|
126
|
+
() => ({ AppearanceBrand: 'Secondary', Emphasis: 'Medium', 'Button / Size': 'S', ...modes }),
|
|
127
|
+
[modes]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const buttonForeground = useMemo(
|
|
131
|
+
() => asStr(getVariableByName('button/foreground', ctaModes), '#5d00b5'),
|
|
132
|
+
[ctaModes]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const mediaNode = media ? (
|
|
136
|
+
cloneChildrenWithModes(media, modes)
|
|
137
|
+
) : (
|
|
138
|
+
<Image
|
|
139
|
+
imageSource={imageSource}
|
|
140
|
+
width={imageWidth}
|
|
141
|
+
height={imageHeight}
|
|
142
|
+
borderRadius={tokens.imageRadius}
|
|
143
|
+
resizeMode="cover"
|
|
144
|
+
accessibilityElementsHidden
|
|
145
|
+
importantForAccessibility="no"
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const ctaNode = button ? (
|
|
150
|
+
cloneChildrenWithModes(button, ctaModes)
|
|
151
|
+
) : (
|
|
152
|
+
<Button
|
|
153
|
+
label={buttonLabel}
|
|
154
|
+
modes={ctaModes}
|
|
155
|
+
onPress={onButtonPress}
|
|
156
|
+
leading={
|
|
157
|
+
buttonIcon ? (
|
|
158
|
+
<Icon iconName={buttonIcon} size={tokens.buttonIconSize} color={buttonForeground} />
|
|
159
|
+
) : undefined
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
const content = (
|
|
165
|
+
<>
|
|
166
|
+
<View style={styles.mediaSlot}>{mediaNode}</View>
|
|
167
|
+
|
|
168
|
+
<Title title={title} subtitle={subtitle} textAlign="Center" modes={modes} />
|
|
169
|
+
|
|
170
|
+
{metrics.length > 0 ? (
|
|
171
|
+
<View style={styles.metricsRow}>
|
|
172
|
+
{metrics.map((metric, index) => (
|
|
173
|
+
<React.Fragment key={index}>
|
|
174
|
+
{index > 0 ? (
|
|
175
|
+
<Divider direction="vertical" modes={modes} style={styles.metricDivider} />
|
|
176
|
+
) : null}
|
|
177
|
+
<Metricdata metric={metric} tokens={tokens} />
|
|
178
|
+
</React.Fragment>
|
|
179
|
+
))}
|
|
180
|
+
</View>
|
|
181
|
+
) : null}
|
|
182
|
+
|
|
183
|
+
{showButton ? ctaNode : null}
|
|
184
|
+
</>
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const containerStyle = useMemo<ViewStyle>(
|
|
188
|
+
() => ({ ...tokens.container, width }),
|
|
189
|
+
[tokens.container, width]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if (onPress) {
|
|
193
|
+
return (
|
|
194
|
+
<Pressable
|
|
195
|
+
style={({ pressed }) => [containerStyle, pressed ? styles.pressed : null, style]}
|
|
196
|
+
accessibilityRole="button"
|
|
197
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
198
|
+
onPress={onPress}
|
|
199
|
+
>
|
|
200
|
+
{content}
|
|
201
|
+
</Pressable>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<View style={[containerStyle, style]} accessibilityLabel={accessibilityLabel}>
|
|
207
|
+
{content}
|
|
208
|
+
</View>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Metricdata — internal metric column (Figma node 5352:256)
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function Metricdata({ metric, tokens }: { metric: PdpCcCardMetric; tokens: ResolvedTokens }) {
|
|
217
|
+
return (
|
|
218
|
+
<View style={tokens.metric}>
|
|
219
|
+
{metric.title != null ? <Text style={tokens.metricTitle}>{metric.title}</Text> : null}
|
|
220
|
+
{metric.value != null ? <Text style={tokens.metricValue}>{metric.value}</Text> : null}
|
|
221
|
+
{metric.caption != null ? <Text style={tokens.metricCaption}>{metric.caption}</Text> : null}
|
|
222
|
+
</View>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Tokens / static styles
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
interface ResolvedTokens {
|
|
231
|
+
container: ViewStyle
|
|
232
|
+
imageRadius: number
|
|
233
|
+
buttonIconSize: number
|
|
234
|
+
metric: ViewStyle
|
|
235
|
+
metricTitle: TextStyle
|
|
236
|
+
metricValue: TextStyle
|
|
237
|
+
metricCaption: TextStyle
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function asNum(raw: unknown, fallback: number): number {
|
|
241
|
+
const n = typeof raw === 'number' ? raw : parseFloat(raw as string)
|
|
242
|
+
return Number.isFinite(n) ? n : fallback
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function asStr(raw: unknown, fallback: string): string {
|
|
246
|
+
return raw != null ? String(raw) : fallback
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveTokens(modes: Modes): ResolvedTokens {
|
|
250
|
+
// NOTE: token names are passed as string literals DIRECTLY to
|
|
251
|
+
// getVariableByName so the `extract-component-tokens` script can statically
|
|
252
|
+
// collect them for the generated docs. Do not refactor these into a helper
|
|
253
|
+
// that receives the name as a variable.
|
|
254
|
+
const background = asStr(getVariableByName('PDPcccard/bg/color', modes), '#ffffff')
|
|
255
|
+
const paddingHorizontal = asNum(getVariableByName('PDPcccard/padding/horizontal', modes), 16)
|
|
256
|
+
const paddingVertical = asNum(getVariableByName('PDPcccard/padding/vertical', modes), 20)
|
|
257
|
+
const gap = asNum(getVariableByName('PDPcccard/gap', modes), 12)
|
|
258
|
+
|
|
259
|
+
const imageRadius = asNum(getVariableByName('image/radius', modes), 8)
|
|
260
|
+
const buttonIconSize = asNum(getVariableByName('button/iconSize', modes), 16)
|
|
261
|
+
|
|
262
|
+
const metricGap = asNum(getVariableByName('metricdata/gap', modes), 4)
|
|
263
|
+
const metricPadH = asNum(getVariableByName('metricdata/padding/horizontal', modes), 10)
|
|
264
|
+
const metricPadV = asNum(getVariableByName('metricdata/padding/vertical', modes), 10)
|
|
265
|
+
|
|
266
|
+
const titleColor = asStr(getVariableByName('metricdata/title/color', modes), '#000000')
|
|
267
|
+
const titleSize = asNum(getVariableByName('metricdata/title/fontsize', modes), 12)
|
|
268
|
+
const titleFamily = asStr(getVariableByName('metricdata/title/fontfamily', modes), 'JioType Var')
|
|
269
|
+
const titleWeight = asStr(getVariableByName('metricdata/title/fontweight', modes), '400')
|
|
270
|
+
|
|
271
|
+
const valueColor = asStr(getVariableByName('metricdata/value/color', modes), '#000000')
|
|
272
|
+
const valueSize = asNum(getVariableByName('metricdata/value/fontsize', modes), 20)
|
|
273
|
+
const valueFamily = asStr(getVariableByName('metricdata/value/fontfamily', modes), 'JioType Var')
|
|
274
|
+
const valueWeight = asStr(getVariableByName('metricdata/value/fontweight', modes), '700')
|
|
275
|
+
|
|
276
|
+
const captionColor = asStr(getVariableByName('metricdata/caption/color', modes), '#777777')
|
|
277
|
+
const captionSize = asNum(getVariableByName('metricdata/caption/fontsize', modes), 12)
|
|
278
|
+
const captionFamily = asStr(getVariableByName('metricdata/caption/fontfamily', modes), 'JioType Var')
|
|
279
|
+
const captionWeight = asStr(getVariableByName('metricdata/caption/fontweight', modes), '500')
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
container: {
|
|
283
|
+
backgroundColor: background,
|
|
284
|
+
paddingHorizontal,
|
|
285
|
+
paddingVertical,
|
|
286
|
+
gap,
|
|
287
|
+
flexDirection: 'column',
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
justifyContent: 'center',
|
|
290
|
+
},
|
|
291
|
+
imageRadius,
|
|
292
|
+
buttonIconSize,
|
|
293
|
+
metric: {
|
|
294
|
+
flex: 1,
|
|
295
|
+
gap: metricGap,
|
|
296
|
+
paddingHorizontal: metricPadH,
|
|
297
|
+
paddingVertical: metricPadV,
|
|
298
|
+
alignItems: 'center',
|
|
299
|
+
justifyContent: 'center',
|
|
300
|
+
},
|
|
301
|
+
metricTitle: {
|
|
302
|
+
color: titleColor,
|
|
303
|
+
fontSize: titleSize,
|
|
304
|
+
fontFamily: titleFamily,
|
|
305
|
+
fontWeight: titleWeight as TextStyle['fontWeight'],
|
|
306
|
+
lineHeight: Math.round(titleSize * 1.2),
|
|
307
|
+
textAlign: 'center',
|
|
308
|
+
includeFontPadding: false as any,
|
|
309
|
+
},
|
|
310
|
+
metricValue: {
|
|
311
|
+
color: valueColor,
|
|
312
|
+
fontSize: valueSize,
|
|
313
|
+
fontFamily: valueFamily,
|
|
314
|
+
fontWeight: valueWeight as TextStyle['fontWeight'],
|
|
315
|
+
lineHeight: Math.round(valueSize * 1.2),
|
|
316
|
+
textAlign: 'center',
|
|
317
|
+
includeFontPadding: false as any,
|
|
318
|
+
},
|
|
319
|
+
metricCaption: {
|
|
320
|
+
color: captionColor,
|
|
321
|
+
fontSize: captionSize,
|
|
322
|
+
fontFamily: captionFamily,
|
|
323
|
+
fontWeight: captionWeight as TextStyle['fontWeight'],
|
|
324
|
+
lineHeight: Math.round(captionSize * 1.2),
|
|
325
|
+
textAlign: 'center',
|
|
326
|
+
includeFontPadding: false as any,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const DEFAULT_METRICS: PdpCcCardMetric[] = [
|
|
332
|
+
{ title: 'Title', value: 'Value', caption: 'caption' },
|
|
333
|
+
{ title: 'Title', value: 'Value', caption: 'caption' },
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
const styles = StyleSheet.create({
|
|
337
|
+
mediaSlot: {
|
|
338
|
+
alignSelf: 'stretch',
|
|
339
|
+
alignItems: 'center',
|
|
340
|
+
justifyContent: 'center',
|
|
341
|
+
},
|
|
342
|
+
metricsRow: {
|
|
343
|
+
alignSelf: 'stretch',
|
|
344
|
+
flexDirection: 'row',
|
|
345
|
+
alignItems: 'stretch',
|
|
346
|
+
},
|
|
347
|
+
metricDivider: {
|
|
348
|
+
alignSelf: 'center',
|
|
349
|
+
height: '70%',
|
|
350
|
+
},
|
|
351
|
+
pressed: {
|
|
352
|
+
opacity: 0.92,
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
export default PdpCcCard
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import React, { useId } from 'react'
|
|
2
|
+
import { View, StyleSheet, Platform, UIManager, type ViewStyle, type StyleProp } from 'react-native'
|
|
3
|
+
import { BlurView } from '@react-native-community/blur'
|
|
4
|
+
import MaskedView from '@react-native-masked-view/masked-view'
|
|
5
|
+
import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg'
|
|
6
|
+
|
|
7
|
+
export type GlassTint = 'dark' | 'light'
|
|
8
|
+
|
|
9
|
+
export interface GlassFillProps {
|
|
10
|
+
/**
|
|
11
|
+
* Visual tint of the glass surface. Maps to `BlurView`'s `blurType`
|
|
12
|
+
* (`'dark'` | `'light'`) and drives the iOS
|
|
13
|
+
* `reducedTransparencyFallbackColor` so the surface degrades gracefully
|
|
14
|
+
* when "Reduce Transparency" is enabled in system accessibility settings.
|
|
15
|
+
*/
|
|
16
|
+
tint?: GlassTint
|
|
17
|
+
/**
|
|
18
|
+
* Blur strength as a 0–100 "intensity" value. Internally mapped to
|
|
19
|
+
* `@react-native-community/blur`'s `blurAmount`. When `progressive` is set,
|
|
20
|
+
* this is the strength at the BOTTOM of the ramp (the strongest point); the
|
|
21
|
+
* ramp is kept intentionally gentle so the surface reads as subtle glass
|
|
22
|
+
* rather than a heavy frosted block.
|
|
23
|
+
*/
|
|
24
|
+
intensity?: number
|
|
25
|
+
/**
|
|
26
|
+
* Token-derived color tint laid OVER the live blur. Painted as a
|
|
27
|
+
* translucent overlay so the glass keeps its color signature even when the
|
|
28
|
+
* platform blur quality varies (or realtime blur is unavailable).
|
|
29
|
+
*/
|
|
30
|
+
overlayColor?: string
|
|
31
|
+
/**
|
|
32
|
+
* Render a *progressive* (variable) blur instead of a uniform one: fully
|
|
33
|
+
* clear at the top, easing into a soft blur toward the bottom. Implemented
|
|
34
|
+
* by stacking two `MaskedView` + `BlurView` layers (a faint base + a
|
|
35
|
+
* slightly stronger accent near the bottom), each revealed via an eased
|
|
36
|
+
* multi-stop SVG gradient mask so the blur swells smoothly rather than
|
|
37
|
+
* along a hard seam. Works on iOS and Android with no extra native module.
|
|
38
|
+
*/
|
|
39
|
+
progressive?: boolean
|
|
40
|
+
/** Container style overrides. Defaults to `StyleSheet.absoluteFill`. */
|
|
41
|
+
style?: StyleProp<ViewStyle>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_FALLBACK_DARK = '#1414174a'
|
|
45
|
+
const DEFAULT_FALLBACK_LIGHT = '#ffffff66'
|
|
46
|
+
|
|
47
|
+
// The native view-manager name registered by `@react-native-community/blur`
|
|
48
|
+
// (`AndroidBlurView` on Android, `BlurView` on iOS).
|
|
49
|
+
const NATIVE_BLUR_NAME = Platform.OS === 'android' ? 'AndroidBlurView' : 'BlurView'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Alpha stop on a layer's vertical reveal mask. `offset` is the vertical
|
|
53
|
+
* position (0 = top of the surface, 1 = bottom); `opacity` is the mask alpha
|
|
54
|
+
* there (0 = layer hidden / fully clear, 1 = layer fully applied).
|
|
55
|
+
*
|
|
56
|
+
* Using several stops (rather than a single linear 0 → 1 ramp) lets each layer
|
|
57
|
+
* EASE in, so the blur swells smoothly toward the bottom instead of appearing
|
|
58
|
+
* along a hard horizontal seam. That soft S-curve is what gives the surface its
|
|
59
|
+
* "glass" feel rather than a flat translucent panel.
|
|
60
|
+
*/
|
|
61
|
+
interface MaskStop {
|
|
62
|
+
offset: number
|
|
63
|
+
opacity: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A single layer of the progressive ramp.
|
|
68
|
+
* - `stops` describe how this layer is revealed from top to bottom.
|
|
69
|
+
* - `amount` is this layer's share (0–1) of the max `blurAmount`.
|
|
70
|
+
*
|
|
71
|
+
* We stack just TWO layers on both platforms: a faint base blur that covers
|
|
72
|
+
* most of the footer and a slightly stronger accent concentrated near the
|
|
73
|
+
* bottom. Keeping the overlap shallow avoids compounding the dark tint of
|
|
74
|
+
* multiple `BlurView`s (which is what made the earlier 3-layer ramp read as a
|
|
75
|
+
* heavy block), while still giving a genuine variable-radius result — the blur
|
|
76
|
+
* radius grows toward the bottom where the two layers overlap.
|
|
77
|
+
*/
|
|
78
|
+
interface ProgressiveLayer {
|
|
79
|
+
stops: MaskStop[]
|
|
80
|
+
amount: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Base reveal: a faint trace of blur begins near the very top and grows
|
|
84
|
+
// steadily downward, so the upper half still carries visible glass rather than
|
|
85
|
+
// snapping clear. Also reused for the no-native-blur fallback scrim.
|
|
86
|
+
const BASE_MASK_STOPS: MaskStop[] = [
|
|
87
|
+
{ offset: 0.0, opacity: 0 },
|
|
88
|
+
{ offset: 0.08, opacity: 0.12 },
|
|
89
|
+
{ offset: 0.35, opacity: 0.4 },
|
|
90
|
+
{ offset: 0.65, opacity: 0.8 },
|
|
91
|
+
{ offset: 1.0, opacity: 1 },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
const PROGRESSIVE_LAYERS: ProgressiveLayer[] = [
|
|
95
|
+
{ amount: 0.65, stops: BASE_MASK_STOPS },
|
|
96
|
+
// Accent: the strongest blur, gathering over the lower portion for depth.
|
|
97
|
+
{
|
|
98
|
+
amount: 1.0,
|
|
99
|
+
stops: [
|
|
100
|
+
{ offset: 0.0, opacity: 0 },
|
|
101
|
+
{ offset: 0.3, opacity: 0.15 },
|
|
102
|
+
{ offset: 0.65, opacity: 0.65 },
|
|
103
|
+
{ offset: 1.0, opacity: 1 },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Probe ONCE whether the native blur view is actually present in this binary.
|
|
110
|
+
*
|
|
111
|
+
* `@react-native-community/blur` is a peer dependency so its JS always imports,
|
|
112
|
+
* but on a build where the native module was never linked (e.g. `pod install`
|
|
113
|
+
* wasn't run on iOS) rendering `<BlurView>` shows React Native's red
|
|
114
|
+
* "Unimplemented component <BlurView>" placeholder. Detecting availability up
|
|
115
|
+
* front lets us fall back to a tinted scrim instead of crashing the surface.
|
|
116
|
+
*
|
|
117
|
+
* - New architecture (bridgeless): `getViewManagerConfig` raises a soft error,
|
|
118
|
+
* so we MUST use `hasViewManagerConfig` -> Fabric component registry.
|
|
119
|
+
* - Old architecture (Paper): `getViewManagerConfig` returns null when the
|
|
120
|
+
* view manager isn't registered.
|
|
121
|
+
*/
|
|
122
|
+
const NATIVE_BLUR_SUPPORTED: boolean = (() => {
|
|
123
|
+
try {
|
|
124
|
+
const um = UIManager as unknown as {
|
|
125
|
+
hasViewManagerConfig?: (name: string) => boolean
|
|
126
|
+
getViewManagerConfig?: (name: string) => unknown
|
|
127
|
+
}
|
|
128
|
+
if (typeof um.hasViewManagerConfig === 'function') {
|
|
129
|
+
return um.hasViewManagerConfig(NATIVE_BLUR_NAME) === true
|
|
130
|
+
}
|
|
131
|
+
if (typeof um.getViewManagerConfig === 'function') {
|
|
132
|
+
return um.getViewManagerConfig(NATIVE_BLUR_NAME) != null
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Any probe failure -> treat blur as unavailable and use the fallback.
|
|
136
|
+
}
|
|
137
|
+
return false
|
|
138
|
+
})()
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Vertical alpha-gradient mask drawn with `react-native-svg`. `MaskedView`
|
|
142
|
+
* reveals its child in proportion to this mask's alpha, so feeding it an eased
|
|
143
|
+
* multi-stop gradient makes the layer's blur swell in smoothly from top to
|
|
144
|
+
* bottom instead of along a hard seam.
|
|
145
|
+
*/
|
|
146
|
+
function GradientMask({ id, stops }: { id: string; stops: MaskStop[] }) {
|
|
147
|
+
return (
|
|
148
|
+
<Svg width="100%" height="100%">
|
|
149
|
+
<Defs>
|
|
150
|
+
<LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
|
151
|
+
{stops.map((s, i) => (
|
|
152
|
+
<Stop key={i} offset={s.offset} stopColor="#000000" stopOpacity={s.opacity} />
|
|
153
|
+
))}
|
|
154
|
+
</LinearGradient>
|
|
155
|
+
</Defs>
|
|
156
|
+
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${id})`} />
|
|
157
|
+
</Svg>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Glass / frosted surface for native (iOS + Android).
|
|
163
|
+
*
|
|
164
|
+
* Why this lives in its own platform-split file (mirrors `MediaCard/GlassFill`):
|
|
165
|
+
* - `@react-native-community/blur` is a native-only module; importing it on
|
|
166
|
+
* web throws because it references native components not registered there.
|
|
167
|
+
* Metro's platform-extension resolution picks `GlassFill.tsx` for native
|
|
168
|
+
* and `GlassFill.web.tsx` for web, keeping the web bundle native-free.
|
|
169
|
+
* - Centralizes the `intensity` (0–100) -> `blurAmount` (0–32) mapping so the
|
|
170
|
+
* Figma `blur/minimal` token semantics survive across platforms.
|
|
171
|
+
*
|
|
172
|
+
* On iOS (with the pod installed) this is a real `UIVisualEffectView` (true
|
|
173
|
+
* OS-level live blur). On Android it uses the community blur view. When the
|
|
174
|
+
* native module isn't linked in the running binary, the component degrades to
|
|
175
|
+
* a translucent tinted scrim (`reducedTransparencyFallbackColor` / fallback
|
|
176
|
+
* color) instead of rendering the "Unimplemented component" placeholder.
|
|
177
|
+
*/
|
|
178
|
+
function GlassFill({
|
|
179
|
+
tint = 'dark',
|
|
180
|
+
intensity = 50,
|
|
181
|
+
overlayColor,
|
|
182
|
+
progressive = false,
|
|
183
|
+
style,
|
|
184
|
+
}: GlassFillProps) {
|
|
185
|
+
const rawId = useId()
|
|
186
|
+
const maskId = `glass-mask-${rawId.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
|
187
|
+
|
|
188
|
+
const blurType: 'light' | 'dark' = tint === 'light' ? 'light' : 'dark'
|
|
189
|
+
const uniformBlurAmount = Math.max(0, Math.min(32, Math.round(intensity * 0.32)))
|
|
190
|
+
const fallbackColor = overlayColor ?? (tint === 'light' ? DEFAULT_FALLBACK_LIGHT : DEFAULT_FALLBACK_DARK)
|
|
191
|
+
|
|
192
|
+
// ----- Progressive (variable) blur -------------------------------------
|
|
193
|
+
if (progressive) {
|
|
194
|
+
// Peak blur radius (at the bottom). Full strength so the frosted glass
|
|
195
|
+
// is clearly engaged where the layers overlap, while the eased masks
|
|
196
|
+
// keep the top of the surface fully clear.
|
|
197
|
+
const peakBlur = Math.max(1, Math.min(32, Math.round(intensity * 0.32)))
|
|
198
|
+
|
|
199
|
+
// Native blur not linked -> tint eases from transparent (top) to a soft
|
|
200
|
+
// fallback color (bottom) so the surface still reads as gentle glass.
|
|
201
|
+
if (!NATIVE_BLUR_SUPPORTED) {
|
|
202
|
+
return (
|
|
203
|
+
<View style={[StyleSheet.absoluteFill, style]} pointerEvents="none">
|
|
204
|
+
<MaskedView
|
|
205
|
+
style={StyleSheet.absoluteFill}
|
|
206
|
+
maskElement={<GradientMask id={`${maskId}-fb`} stops={BASE_MASK_STOPS} />}
|
|
207
|
+
>
|
|
208
|
+
<View style={[StyleSheet.absoluteFill, { backgroundColor: fallbackColor }]} />
|
|
209
|
+
</MaskedView>
|
|
210
|
+
</View>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<View style={[StyleSheet.absoluteFill, style]} pointerEvents="none">
|
|
216
|
+
{PROGRESSIVE_LAYERS.map((layer, i) => {
|
|
217
|
+
const amount = Math.max(1, Math.round(peakBlur * layer.amount))
|
|
218
|
+
return (
|
|
219
|
+
<MaskedView
|
|
220
|
+
key={i}
|
|
221
|
+
style={StyleSheet.absoluteFill}
|
|
222
|
+
maskElement={<GradientMask id={`${maskId}-${i}`} stops={layer.stops} />}
|
|
223
|
+
>
|
|
224
|
+
<BlurView
|
|
225
|
+
style={StyleSheet.absoluteFill}
|
|
226
|
+
blurType={blurType}
|
|
227
|
+
blurAmount={amount}
|
|
228
|
+
reducedTransparencyFallbackColor={fallbackColor}
|
|
229
|
+
/>
|
|
230
|
+
</MaskedView>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</View>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ----- Uniform blur (default) ------------------------------------------
|
|
238
|
+
// Native blur not linked in this build -> render a translucent tinted scrim
|
|
239
|
+
// so the surface still reads as frosted glass and never shows RN's
|
|
240
|
+
// "Unimplemented component <BlurView>" box.
|
|
241
|
+
if (!NATIVE_BLUR_SUPPORTED) {
|
|
242
|
+
return (
|
|
243
|
+
<View
|
|
244
|
+
style={[StyleSheet.absoluteFill, { backgroundColor: fallbackColor }, style]}
|
|
245
|
+
pointerEvents="none"
|
|
246
|
+
/>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<View style={[StyleSheet.absoluteFill, style]} pointerEvents="none">
|
|
252
|
+
<BlurView
|
|
253
|
+
style={StyleSheet.absoluteFill}
|
|
254
|
+
blurType={blurType}
|
|
255
|
+
blurAmount={uniformBlurAmount}
|
|
256
|
+
reducedTransparencyFallbackColor={fallbackColor}
|
|
257
|
+
/>
|
|
258
|
+
{overlayColor != null ? (
|
|
259
|
+
<View style={[StyleSheet.absoluteFill, { backgroundColor: overlayColor }]} />
|
|
260
|
+
) : null}
|
|
261
|
+
{Platform.OS === 'android' ? (
|
|
262
|
+
<View
|
|
263
|
+
style={[
|
|
264
|
+
StyleSheet.absoluteFill,
|
|
265
|
+
{
|
|
266
|
+
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
267
|
+
opacity: 0.6,
|
|
268
|
+
},
|
|
269
|
+
]}
|
|
270
|
+
/>
|
|
271
|
+
) : null}
|
|
272
|
+
</View>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export default GlassFill
|