jfs-components 0.1.36 → 0.1.49

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 (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/commonjs/components/Accordion/Accordion.js +16 -3
  3. package/lib/commonjs/components/AppBar/AppBar.js +118 -93
  4. package/lib/commonjs/components/CompareTable/CompareTable.js +98 -33
  5. package/lib/commonjs/components/FormField/FormField.js +9 -1
  6. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +4 -14
  7. package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +20 -4
  8. package/lib/commonjs/components/PageHero/PageHero.js +3 -0
  9. package/lib/commonjs/components/ProductMerchandisingCard/ProductMerchandisingCard.js +50 -30
  10. package/lib/commonjs/components/Section/Section.js +3 -1
  11. package/lib/commonjs/components/ValueBackMetric/ValueBackMetric.js +117 -89
  12. package/lib/commonjs/components/index.js +7 -1
  13. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -42204
  14. package/lib/commonjs/design-tokens/figma-modes.generated.js +8 -7
  15. package/lib/commonjs/icons/registry.js +1 -1
  16. package/lib/module/components/Accordion/Accordion.js +16 -3
  17. package/lib/module/components/AppBar/AppBar.js +116 -93
  18. package/lib/module/components/CompareTable/CompareTable.js +99 -34
  19. package/lib/module/components/FormField/FormField.js +9 -1
  20. package/lib/module/components/FullscreenModal/FullscreenModal.js +4 -14
  21. package/lib/module/components/HelloJioInput/HelloJioInput.js +20 -4
  22. package/lib/module/components/PageHero/PageHero.js +3 -0
  23. package/lib/module/components/ProductMerchandisingCard/ProductMerchandisingCard.js +50 -30
  24. package/lib/module/components/Section/Section.js +3 -1
  25. package/lib/module/components/ValueBackMetric/ValueBackMetric.js +120 -93
  26. package/lib/module/components/index.js +1 -1
  27. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -42204
  28. package/lib/module/design-tokens/figma-modes.generated.js +8 -7
  29. package/lib/module/icons/registry.js +1 -1
  30. package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -1
  31. package/lib/typescript/src/components/AppBar/AppBar.d.ts +46 -14
  32. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +29 -12
  33. package/lib/typescript/src/components/HelloJioInput/HelloJioInput.d.ts +6 -4
  34. package/lib/typescript/src/components/PageHero/PageHero.d.ts +1 -0
  35. package/lib/typescript/src/components/ProductMerchandisingCard/ProductMerchandisingCard.d.ts +8 -1
  36. package/lib/typescript/src/components/Section/Section.d.ts +2 -1
  37. package/lib/typescript/src/components/ValueBackMetric/ValueBackMetric.d.ts +27 -16
  38. package/lib/typescript/src/components/index.d.ts +1 -1
  39. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +2 -1
  40. package/lib/typescript/src/icons/registry.d.ts +1 -1
  41. package/package.json +1 -1
  42. package/src/components/Accordion/Accordion.tsx +61 -33
  43. package/src/components/AppBar/AppBar.tsx +154 -124
  44. package/src/components/CompareTable/CompareTable.tsx +138 -52
  45. package/src/components/FormField/FormField.tsx +7 -0
  46. package/src/components/FullscreenModal/FullscreenModal.tsx +5 -14
  47. package/src/components/HelloJioInput/HelloJioInput.tsx +31 -12
  48. package/src/components/PageHero/PageHero.tsx +5 -0
  49. package/src/components/ProductMerchandisingCard/ProductMerchandisingCard.tsx +63 -30
  50. package/src/components/Section/Section.tsx +121 -156
  51. package/src/components/ValueBackMetric/ValueBackMetric.tsx +178 -104
  52. package/src/components/index.ts +6 -1
  53. package/src/design-tokens/Coin Variables-variables-full.json +1 -42204
  54. package/src/design-tokens/figma-modes.generated.ts +8 -7
  55. package/src/icons/registry.ts +1 -1
  56. package/D2C.md +0 -113
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useRef } from 'react'
1
+ import React, { useCallback, useRef, useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -151,25 +151,42 @@ export type CompareTableProps = {
151
151
  * horizontal `ScrollView`s whose scroll events are **chained** — moving
152
152
  * one moves every other in lockstep (the cards always stay aligned with
153
153
  * the cells beneath them).
154
- * - The cards row becomes a **sticky header** at the top: it pins in place
155
- * on the vertical axis (does not scroll away) while the sections scroll
156
- * beneath it. Requires the host to give `CompareTable` a bounded height.
157
- * - The gray table-header rows (each section's `header`) are rendered
158
- * full-width **outside** the horizontal scroll, so they stay put
159
- * horizontally (they don't slide with the cells) while still scrolling
160
- * vertically with the table.
154
+ * - Selection cards stick on the vertical axis by default (`stickyCards`).
155
+ * Accordion titles also stick by default (`stickyAccordionTitles`) and
156
+ * replace each other under the pinned cards as you scroll. Gray
157
+ * table-header rows can stick via `stickyHeaders`. Sticky header labels
158
+ * stay put horizontally when `stickyHeaderLabel` is on. Requires the
159
+ * host to give `CompareTable` a bounded height.
161
160
  *
162
161
  * Omit to keep the original flex layout (columns share the available width,
163
162
  * no scrolling). @default undefined
164
163
  */
165
164
  columnWidth?: number;
166
165
  /**
167
- * When `true` (default), each section's header row (the gray header label)
168
- * sticks to the top of the viewport when scrolling vertically, with multiple
169
- * sticky headers stacking up as each reaches the top. Only applies in
170
- * fixed-width scroll mode (`columnWidth` is set).
166
+ * When `true`, the selection-cards row is pinned above the vertical scroll
167
+ * surface (not inside it), so cards never scroll away. Sticky accordion
168
+ * titles then stick under the pinned cards, replacing each other as each
169
+ * section reaches the top. Only applies in fixed-width scroll mode
170
+ * (`columnWidth` is set).
171
+ * @default true
172
+ */
173
+ stickyCards?: boolean;
174
+ /**
175
+ * When `true`, each section's accordion title sticks to the top of the
176
+ * viewport when scrolling vertically (under sticky cards when
177
+ * `stickyCards` is also on), with titles replacing each other as each
178
+ * reaches the top. Only applies in fixed-width scroll mode
179
+ * (`columnWidth` is set).
171
180
  * @default true
172
181
  */
182
+ stickyAccordionTitles?: boolean;
183
+ /**
184
+ * When `true`, each section's header row (the gray header label) sticks to
185
+ * the top of the viewport when scrolling vertically, with multiple sticky
186
+ * headers stacking up as each reaches the top. Only applies in fixed-width
187
+ * scroll mode (`columnWidth` is set).
188
+ * @default false
189
+ */
173
190
  stickyHeaders?: boolean;
174
191
  /**
175
192
  * When `true` (default), the section header label stays fixed on the left
@@ -220,7 +237,9 @@ function CompareTable({
220
237
  style,
221
238
  disableTruncation,
222
239
  columnWidth,
223
- stickyHeaders = true,
240
+ stickyCards = true,
241
+ stickyAccordionTitles = true,
242
+ stickyHeaders = false,
224
243
  stickyHeaderLabel = true,
225
244
  }: CompareTableProps) {
226
245
  // --- selection card tokens ------------------------------------------------
@@ -235,11 +254,10 @@ function CompareTable({
235
254
  const cardLabelFontFamily = (getVariableByName('selectionCard/label/fontfamily', modes) as string) ?? 'JioType Var'
236
255
  const cardLabelFontWeight = (getVariableByName('selectionCard/label/fontweight', modes) as number | string) ?? 700
237
256
  const imageRadius = (getVariableByName('image/radius', modes) as number) ?? 8
238
- // `selectionCard/radius` (Figma node 5568:3487) rounds the card corners.
239
- // The published token store is still out of sync (the variable resolves to
240
- // `0` in the design), so the read falls back to `0` and will pick up a
241
- // rounded value automatically once the store syncs.
242
- const cardRadius = (getVariableByName('selectionCard/radius', modes) as number) ?? 0
257
+ // Selection cards are square no corner radius (Figma compare-table
258
+ // treatment). Ignore `selectionCard/radius` so a non-zero token value
259
+ // cannot round the card edges.
260
+ const cardRadius = 0
243
261
 
244
262
  // --- add-a-card icon button tokens ---------------------------------------
245
263
  // padding/size/border come from the shared iconButton tokens; the filled
@@ -301,6 +319,16 @@ function CompareTable({
301
319
  const draggingSource = useRef<'cards' | number | null>(null)
302
320
  const lastSyncX = useRef(0)
303
321
 
322
+ // Controlled expand state for sticky accordion titles (header + body are
323
+ // split across two Accordion instances that must stay in sync).
324
+ const [expandedByIndex, setExpandedByIndex] = useState<Record<number, boolean>>(() => {
325
+ const initial: Record<number, boolean> = {}
326
+ sections.forEach((section, index) => {
327
+ initial[index] = section.defaultExpanded ?? index === 0
328
+ })
329
+ return initial
330
+ })
331
+
304
332
  const syncOthers = useCallback((source: 'cards' | number, x: number) => {
305
333
  if (source !== 'cards' && cardsScrollRef.current) {
306
334
  cardsScrollRef.current.scrollTo({ x, animated: false })
@@ -420,6 +448,10 @@ function CompareTable({
420
448
  minHeight: 147,
421
449
  backgroundColor: cardBg,
422
450
  borderRadius: cardRadius,
451
+ borderTopLeftRadius: cardRadius,
452
+ borderTopRightRadius: cardRadius,
453
+ borderBottomLeftRadius: cardRadius,
454
+ borderBottomRightRadius: cardRadius,
423
455
  alignItems: 'center',
424
456
  justifyContent: 'center',
425
457
  gap: cardGap,
@@ -692,6 +724,7 @@ function CompareTable({
692
724
  backgroundColor: cardBg,
693
725
  borderWidth: 1,
694
726
  borderColor: '#e8e8e8',
727
+ borderRadius: 0,
695
728
  overflow: 'hidden',
696
729
  }
697
730
 
@@ -723,17 +756,17 @@ function CompareTable({
723
756
  }
724
757
 
725
758
  // --- fixed-width scroll mode: chained horizontal sync + sticky cards -----
726
- // Build children and stickyHeaderIndices for the vertical ScrollView.
727
- // When stickyHeaders is on, section headers are injected as direct children
728
- // so React Native's stickyHeaderIndices can pin them with stacking.
729
- const scrollChildren: React.ReactNode[] = []
730
- const stickyIndices: number[] = []
731
-
732
- if (showCardsRow) {
733
- stickyIndices.push(scrollChildren.length)
734
- scrollChildren.push(
759
+ // Sticky cards are pinned *outside* the vertical ScrollView (sibling above
760
+ // it) so they cannot scroll away `stickyHeaderIndices` is unreliable for
761
+ // a nested horizontal ScrollView on Fabric. Sticky accordion titles /
762
+ // table headers still use stickyHeaderIndices inside the scroll surface
763
+ // and therefore stick directly under the pinned cards.
764
+ const renderCardsRow = () => (
765
+ <View
766
+ key="cards-row"
767
+ style={{ width: '100%', backgroundColor: cardBg, zIndex: 2 }}
768
+ >
735
769
  <ScrollView
736
- key="cards-row"
737
770
  ref={cardsScrollRef}
738
771
  horizontal
739
772
  showsHorizontalScrollIndicator={false}
@@ -747,13 +780,59 @@ function CompareTable({
747
780
  {columns.map(renderCard)}
748
781
  {showAddCard && renderAddCard()}
749
782
  </View>
750
- </ScrollView>,
751
- )
783
+ </ScrollView>
784
+ </View>
785
+ )
786
+
787
+ const scrollChildren: React.ReactNode[] = []
788
+ const stickyIndices: number[] = []
789
+
790
+ // Non-sticky cards scroll away with the sections (inside the ScrollView).
791
+ if (showCardsRow && !stickyCards) {
792
+ scrollChildren.push(renderCardsRow())
752
793
  }
753
794
 
754
795
  sections.forEach((section, index) => {
755
796
  const sectionKey = section.key ?? section.title ?? index
756
797
  const headerContent = renderHeaderContent(section)
798
+ const expanded = expandedByIndex[index] ?? (section.defaultExpanded ?? index === 0)
799
+ const onExpandedChange = (next: boolean) => {
800
+ setExpandedByIndex((prev) => ({ ...prev, [index]: next }))
801
+ }
802
+
803
+ const inlineHeader =
804
+ !stickyHeaders && headerContent != null
805
+ ? stickyHeaderLabel
806
+ ? headerContent
807
+ : (
808
+ <ScrollView
809
+ horizontal
810
+ showsHorizontalScrollIndicator={false}
811
+ >
812
+ <View style={{ width: contentWidth }}>
813
+ {headerContent}
814
+ </View>
815
+ </ScrollView>
816
+ )
817
+ : null
818
+
819
+ if (stickyAccordionTitles) {
820
+ stickyIndices.push(scrollChildren.length)
821
+ scrollChildren.push(
822
+ <View
823
+ key={`sticky-accordion-title-${sectionKey}`}
824
+ style={{ width: '100%', backgroundColor: cardBg }}
825
+ >
826
+ <Accordion
827
+ title={section.title}
828
+ expanded={expanded}
829
+ onExpandedChange={onExpandedChange}
830
+ modes={modes}
831
+ showContent={false}
832
+ />
833
+ </View>,
834
+ )
835
+ }
757
836
 
758
837
  if (stickyHeaders && headerContent != null) {
759
838
  stickyIndices.push(scrollChildren.length)
@@ -778,34 +857,41 @@ function CompareTable({
778
857
  )
779
858
  }
780
859
 
781
- scrollChildren.push(
782
- <Accordion
783
- key={sectionKey}
784
- title={section.title}
785
- defaultExpanded={section.defaultExpanded ?? index === 0}
786
- modes={modes}
787
- >
788
- {!stickyHeaders && headerContent != null && (
789
- stickyHeaderLabel ? headerContent : (
790
- <ScrollView
791
- horizontal
792
- showsHorizontalScrollIndicator={false}
793
- >
794
- <View style={{ width: contentWidth }}>
795
- {headerContent}
796
- </View>
797
- </ScrollView>
798
- )
799
- )}
800
- {renderBodyContent(section, index)}
801
- </Accordion>,
802
- )
860
+ if (stickyAccordionTitles) {
861
+ scrollChildren.push(
862
+ <Accordion
863
+ key={sectionKey}
864
+ title={section.title}
865
+ expanded={expanded}
866
+ onExpandedChange={onExpandedChange}
867
+ modes={modes}
868
+ showHeader={false}
869
+ >
870
+ {inlineHeader}
871
+ {renderBodyContent(section, index)}
872
+ </Accordion>,
873
+ )
874
+ } else {
875
+ scrollChildren.push(
876
+ <Accordion
877
+ key={sectionKey}
878
+ title={section.title}
879
+ defaultExpanded={section.defaultExpanded ?? index === 0}
880
+ modes={modes}
881
+ >
882
+ {inlineHeader}
883
+ {renderBodyContent(section, index)}
884
+ </Accordion>,
885
+ )
886
+ }
803
887
  })
804
888
 
805
889
  return (
806
890
  <View style={[outerStyle, style, { flexDirection: 'column', flex: 1 }]}>
891
+ {showCardsRow && stickyCards ? renderCardsRow() : null}
807
892
  <ScrollView
808
893
  style={{ width: '100%', flex: 1 }}
894
+ contentContainerStyle={{ flexGrow: 1 }}
809
895
  stickyHeaderIndices={stickyIndices}
810
896
  showsVerticalScrollIndicator={false}
811
897
  directionalLockEnabled
@@ -521,6 +521,9 @@ const FormField = forwardRef<RNTextInput, FormFieldProps>(function FormField({
521
521
  fontWeight: tokens.inputFontWeight,
522
522
  padding: 0,
523
523
  margin: 0,
524
+ // Match the caret to the tokenized input text color on web (native uses
525
+ // cursorColor / selectionColor on the TextInput below).
526
+ caretColor: tokens.inputTextColor,
524
527
  // Remove the default web focus ring; the input row's border acts as the
525
528
  // focus indicator via the FormField States cascade.
526
529
  outlineStyle: 'none' as TextStyle['outlineStyle'],
@@ -601,6 +604,10 @@ const FormField = forwardRef<RNTextInput, FormFieldProps>(function FormField({
601
604
  onSubmitEditing={onSubmitEditing}
602
605
  placeholder={placeholder ?? ''}
603
606
  placeholderTextColor={placeholderColor}
607
+ // Kill platform accent cursors (Android Material green, iOS system
608
+ // blue) — match NoteInput/MoneyValue and use the tokenized text color.
609
+ cursorColor={tokens.inputTextColor}
610
+ selectionColor={tokens.inputTextColor}
604
611
  editable={interactive}
605
612
  maxLength={maxLength}
606
613
  autoFocus={autoFocus}
@@ -270,22 +270,15 @@ function FullscreenModal({
270
270
 
271
271
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes))
272
272
 
273
- // Safe-area insets so the floating chrome clears the system bars: the close
274
- // button drops below the status bar / notch, and the sticky footer keeps its
275
- // designed bottom padding ON TOP of the bottom inset (home indicator /
276
- // Android gesture or nav bar). On web — and anywhere without a
277
- // SafeAreaProvider — every inset is 0, so the layout is unchanged.
273
+ // Safe-area inset so the floating close button clears the status bar / notch.
274
+ // Footer padding stays at the design-token value from ActionFooter no
275
+ // extra inset is layered on top (the token already accounts for bottom chrome).
276
+ // On web — and anywhere without a SafeAreaProvider — insets.top is 0.
278
277
  const insets = useSafeAreaInsets()
279
278
  const closeButtonInsetStyle = useMemo<ViewStyle>(
280
279
  () => ({ top: 12 + insets.top + closeOffsetY }),
281
280
  [insets.top, closeOffsetY]
282
281
  )
283
- // Extend (not replace) the footer's token bottom padding by the bottom inset
284
- // so the action button never sits under the system navigation area.
285
- const footerInsetStyle = useMemo<ViewStyle>(() => {
286
- const base = Number(getVariableByName('actionFooter/padding/bottom', modes))
287
- return { paddingBottom: base + insets.bottom }
288
- }, [modes, insets.bottom])
289
282
 
290
283
  // Drives the background's parallax-free sync with the scroll. The hero media
291
284
  // lives at the ROOT (so it is never clipped to the content height and sits
@@ -471,9 +464,7 @@ function FullscreenModal({
471
464
  </Animated.ScrollView>
472
465
 
473
466
  {footerContent ? (
474
- <ActionFooter modes={modes} style={footerInsetStyle}>
475
- {footerContent}
476
- </ActionFooter>
467
+ <ActionFooter modes={modes}>{footerContent}</ActionFooter>
477
468
  ) : null}
478
469
 
479
470
  {showClose ? (
@@ -85,7 +85,15 @@ const STATE_FALLBACKS: Record<
85
85
  iconColor: '#24262b',
86
86
  },
87
87
  IdleJioPlus: {
88
- background: '#f5f5f5',
88
+ // Glass fill. In Figma the IdleJioPlus surface resolves
89
+ // `helloJioInput/background` under the `Page type = JioPlus` variable mode,
90
+ // which yields a TRANSLUCENT light fill (not the opaque `#f5f5f5` the
91
+ // MainPage mode gives). Painted over the backdrop blur, an opaque value
92
+ // would read as a solid bar and hide the blur entirely — so, matching the
93
+ // translucent glass fallbacks the sibling GlassFill consumers use
94
+ // (FavoriteToggle `#ffffff33`, NumberPagination `rgba(141,141,141,0.4)`),
95
+ // this fallback carries alpha so the frost tints the blurred content.
96
+ background: '#f5f5f566',
89
97
  borderColor: '#f5f5f5',
90
98
  labelColor: '#545961',
91
99
  iconColor: '#545961',
@@ -113,9 +121,10 @@ export type HelloJioInputProps = {
113
121
  leadingIconName?: string
114
122
  /**
115
123
  * Slot replacing the default leading icon (Figma Slot "start"). Receives
116
- * `modes` recursively.
124
+ * `modes` recursively. Pass `null` to hide the leading icon entirely
125
+ * (the design's `startSlot={false}`), mirroring `trailing`.
117
126
  */
118
- leading?: React.ReactNode
127
+ leading?: React.ReactNode | null
119
128
  /**
120
129
  * Slot replacing the default send `IconButton` (Figma Slot "end").
121
130
  * Pass `null` to hide the trailing control entirely.
@@ -246,7 +255,11 @@ function resolveHelloJioInputTokens(
246
255
  // Glass keeps the container transparent so GlassFill shows through.
247
256
  backgroundColor: useGlass ? 'transparent' : background,
248
257
  borderColor,
249
- borderWidth: strokeWidth,
258
+ // The Figma IdleJioPlus variant is a borderless frosted pill — it has no
259
+ // `border/color`/`strokeWidth` tokens. Drawing the Idle border color here
260
+ // would leave a faint light ring the design doesn't have, so drop the
261
+ // border entirely in the glass state.
262
+ borderWidth: useGlass ? 0 : strokeWidth,
250
263
  borderStyle: 'solid',
251
264
  borderRadius,
252
265
  paddingLeft,
@@ -382,15 +395,21 @@ const HelloJioInput = forwardRef<RNTextInput, HelloJioInputProps>(
382
395
  [onBlur]
383
396
  )
384
397
 
398
+ // Symmetric with `trailing`: an explicit `null` hides the leading icon
399
+ // (the design's `startSlot={false}`); `undefined` falls back to the
400
+ // default brand icon.
385
401
  const leadingElement =
386
- leading ??
387
- (leadingIconName ? (
388
- <Icon
389
- name={leadingIconName}
390
- size={tokens.iconSize}
391
- color={tokens.iconColor}
392
- />
393
- ) : null)
402
+ leading === undefined
403
+ ? leadingIconName
404
+ ? (
405
+ <Icon
406
+ name={leadingIconName}
407
+ size={tokens.iconSize}
408
+ color={tokens.iconColor}
409
+ />
410
+ )
411
+ : null
412
+ : leading
394
413
 
395
414
  const processedLeading = leadingElement
396
415
  ? cloneChildrenWithModes(React.Children.toArray(leadingElement), modes)
@@ -93,6 +93,7 @@ export type PageHeroProps = {
93
93
  * eyebrow="Upgrade to JioFinance+"
94
94
  * headline="Resume earning cashback, extra points, and 1% gold"
95
95
  * supportingText="₹999/year · ₹0 until 2027"
96
+ * body="₹999/year · ₹0 until 2027"
96
97
  * buttonLabel="Renew for free"
97
98
  * onButtonPress={() => navigate('Upgrade')}
98
99
  * modes={{ 'Page type': 'JioPlus' }}
@@ -124,6 +125,9 @@ function PageHero({
124
125
  )
125
126
 
126
127
  const gap = Number(getVariableByName('PageHero/gap', modes))
128
+ const paddingHorizontal = Number(
129
+ getVariableByName('PageHero/padding/horizontal', modes)
130
+ )
127
131
  const textWrapGap = Number(getVariableByName('PageHero/textWrap/gap', modes))
128
132
 
129
133
  // Media slot box — Figma frame 4540:7845 ("Video"), sized by the
@@ -218,6 +222,7 @@ function PageHero({
218
222
  flexDirection: 'column',
219
223
  alignItems: 'center',
220
224
  gap,
225
+ paddingHorizontal,
221
226
  width: '100%',
222
227
  }
223
228
 
@@ -51,6 +51,13 @@ export interface ProductMerchandisingCardProps {
51
51
  * `undefined` and no `badge` override is provided.
52
52
  */
53
53
  badgeLabel?: string
54
+ /**
55
+ * Optional leading node (icon/image) rendered before the header badge
56
+ * label, inside the badge surface. Maps to `Badge`'s `leading` slot — the
57
+ * same pattern as `specialBadgeIcon` for the footer badge. Ignored when a
58
+ * full `badge` override is supplied.
59
+ */
60
+ badgeIcon?: React.ReactNode
54
61
  /**
55
62
  * Full override for the header badge. Takes precedence over `badgeLabel`.
56
63
  */
@@ -122,6 +129,7 @@ function ProductMerchandisingCard({
122
129
  title = 'Title',
123
130
  subtitle = 'Subtitle',
124
131
  badgeLabel,
132
+ badgeIcon,
125
133
  badge,
126
134
  showAvatar = true,
127
135
  avatarSource,
@@ -175,25 +183,27 @@ function ProductMerchandisingCard({
175
183
  )
176
184
 
177
185
  const headerBadge =
178
- badge ?? (badgeLabel != null ? <Badge label={badgeLabel} modes={badgeModes} /> : null)
186
+ badge ??
187
+ (badgeLabel != null ? (
188
+ <Badge label={badgeLabel} leading={badgeIcon} modes={badgeModes} />
189
+ ) : null)
179
190
 
180
191
  const headerAvatar = showAvatar
181
192
  ? avatar ?? (
182
193
  <Avatar
183
194
  style={avatarSource ? 'Image' : avatarMonogram ? 'Monogram' : 'Image'}
184
- imageSource={avatarSource}
185
- monogram={avatarMonogram}
195
+ {...(avatarSource != null ? { imageSource: avatarSource } : {})}
196
+ {...(avatarMonogram != null ? { monogram: avatarMonogram } : {})}
186
197
  modes={avatarModes}
187
198
  />
188
199
  )
189
200
  : null
190
201
 
191
- // The footer "special badge" is now the shared <Badge type="glass">. The
192
- // hardcoded literals that defined its look (warm frosted overlay, gentle
193
- // 5px blur, 6/2 padding, 4px radius, weight-500 white label) are preserved
194
- // 1:1 via the glass overrides + `style`/`labelStyle`, and the optional
195
- // `specialBadgeIcon` maps to Badge's `leading` slot — so every existing
196
- // instantiation renders byte-for-byte the same as before.
202
+ // The footer "special badge" is the shared <Badge type="glass">. Overlay +
203
+ // blur come from `badge/glass/*` + `blur/minimal`; box/gap from
204
+ // `productMerchandisingcard/specialbadge/*`; label color/weight from the
205
+ // glass badge tokens (Figma: white / 600). Optional `specialBadgeIcon`
206
+ // maps to Badge's `leading` slot.
197
207
  const footerSpecialBadge =
198
208
  specialBadge ??
199
209
  (specialBadgeLabel != null ? (
@@ -201,10 +211,10 @@ function ProductMerchandisingCard({
201
211
  type="glass"
202
212
  label={specialBadgeLabel}
203
213
  leading={specialBadgeIcon}
204
- glassOverlayColor={SPECIAL_BADGE_BG}
205
- glassIntensity={SPECIAL_BADGE_INTENSITY}
214
+ glassOverlayColor={tokens.specialBadgeBg}
215
+ glassIntensity={tokens.specialBadgeIntensity}
206
216
  modes={modes}
207
- style={styles.specialBadge}
217
+ style={tokens.specialBadge}
208
218
  labelStyle={tokens.specialBadgeText}
209
219
  {...(disableTruncation != null ? { disableTruncation } : null)}
210
220
  />
@@ -219,7 +229,13 @@ function ProductMerchandisingCard({
219
229
  )
220
230
 
221
231
  const footerCta =
222
- cta ?? <Button label={ctaLabel} modes={ctaModes} onPress={onCtaPress} />
232
+ cta ?? (
233
+ <Button
234
+ label={ctaLabel}
235
+ modes={ctaModes}
236
+ {...(onCtaPress != null ? { onPress: onCtaPress } : {})}
237
+ />
238
+ )
223
239
 
224
240
  const content = (
225
241
  <>
@@ -372,11 +388,6 @@ function ProductMerchandisingCard({
372
388
  // intentionally small to match the design's subtle "minimal" blur.
373
389
  const BLUR_INTENSITY_FACTOR = 1.0
374
390
 
375
- // Special badge ("frosted" pill) — values are literals in the Figma node
376
- // (no dedicated tokens): bg rgba(255,245,229,0.15), 5px backdrop blur.
377
- const SPECIAL_BADGE_BG = 'rgba(255,245,229,0.15)'
378
- const SPECIAL_BADGE_INTENSITY = 17 // ~5px CSS blur / ~2 native blurAmount
379
-
380
391
  interface ResolvedTokens {
381
392
  radius: number
382
393
  blurIntensity: number
@@ -386,7 +397,10 @@ interface ResolvedTokens {
386
397
  textWrap: ViewStyle
387
398
  title: TextStyle
388
399
  subtitle: TextStyle
400
+ specialBadge: ViewStyle
389
401
  specialBadgeText: TextStyle
402
+ specialBadgeBg: string
403
+ specialBadgeIntensity: number
390
404
  }
391
405
 
392
406
  function asNum(raw: unknown, fallback: number): number {
@@ -410,6 +424,7 @@ function resolveTokens(modes: Modes): ResolvedTokens {
410
424
  const footerPadH = asNum(getVariableByName('productMerchandisingcard/footer/padding/horizontal', modes), 16)
411
425
  const footerPadV = asNum(getVariableByName('productMerchandisingcard/footer/padding/vertical', modes), 16)
412
426
  const textGap = asNum(getVariableByName('productMerchandisingcard/footer/text/gap', modes), 2)
427
+ const textWrapMinWidth = asNum(getVariableByName('productMerchandisingcard/footer/textWrap/minWidth', modes), 100)
413
428
  const blurRadius = asNum(getVariableByName('blur/minimal', modes), 29)
414
429
 
415
430
  const titleColor = asStr(getVariableByName('productMerchandisingcard/footer/title/font/color', modes), '#ffffff')
@@ -424,7 +439,20 @@ function resolveTokens(modes: Modes): ResolvedTokens {
424
439
 
425
440
  const sbSize = asNum(getVariableByName('productMerchandisingcard/specialbadge/text/fontsize', modes), 12)
426
441
  const sbFamily = asStr(getVariableByName('productMerchandisingcard/specialbadge/text/fontfamily', modes), 'JioType Var')
427
- const sbWeight = asStr(getVariableByName('productMerchandisingcard/specialbadge/text/fontweight', modes), '500')
442
+ // No dedicated `specialbadge/text/fontweight` in the collection — Figma
443
+ // binds the glass badge label to `badge/label/fontWeight` (600).
444
+ const sbWeight = asStr(getVariableByName('badge/label/fontWeight', modes), '600')
445
+ const sbPadH = asNum(getVariableByName('productMerchandisingcard/specialbadge/padding/horizontal', modes), 6)
446
+ const sbGap = asNum(getVariableByName('productMerchandisingcard/specialbadge/gap', modes), 4)
447
+ const sbPadV = asNum(getVariableByName('badge/padding/vertical', modes), 4)
448
+ const sbRadius = asNum(getVariableByName('badge/radius', modes), 4)
449
+ const sbLabelColor = asStr(getVariableByName('badge/glass/color/label', modes), '#ffffff')
450
+
451
+ // The special badge is a `type="glass"` Badge: its overlay + blur come
452
+ // straight from the shared badge-glass tokens the design references
453
+ // (`badge/glass/color/background` and the `glass/minimal` → `blur/minimal`
454
+ // effect), so it frosts at the same strength as the footer.
455
+ const specialBadgeBg = asStr(getVariableByName('badge/glass/color/background', modes), 'rgba(255,255,255,0.15)')
428
456
 
429
457
  // SVG `<FeGaussianBlur stdDeviation>` is resolution-independent and renders
430
458
  // an identical Gaussian on iOS, Android and web — so unlike the old
@@ -437,6 +465,10 @@ function resolveTokens(modes: Modes): ResolvedTokens {
437
465
  radius,
438
466
  blurIntensity: Math.max(0, Math.min(100, Math.round(blurRadius * BLUR_INTENSITY_FACTOR))),
439
467
  imageBlurStdDeviation,
468
+ specialBadgeBg,
469
+ // Same `blur/minimal`-derived intensity the footer glass uses, so the
470
+ // special badge frosts consistently with the surface behind it.
471
+ specialBadgeIntensity: Math.max(0, Math.min(100, Math.round(blurRadius * BLUR_INTENSITY_FACTOR))),
440
472
  header: {
441
473
  flexDirection: 'row',
442
474
  alignItems: 'flex-start',
@@ -454,6 +486,9 @@ function resolveTokens(modes: Modes): ResolvedTokens {
454
486
  },
455
487
  textWrap: {
456
488
  flex: 1,
489
+ // The Figma footer pins the text column to a minimum width so a long
490
+ // CTA can never squeeze the title/subtitle away (`footer/textWrap/minWidth`).
491
+ minWidth: textWrapMinWidth,
457
492
  gap: textGap,
458
493
  justifyContent: 'center',
459
494
  },
@@ -473,8 +508,16 @@ function resolveTokens(modes: Modes): ResolvedTokens {
473
508
  lineHeight: Math.round(subtitleSize * 1.2),
474
509
  includeFontPadding: false as any,
475
510
  },
511
+ // Pin the special-badge box to the card collection tokens so a parent
512
+ // `Badge Size` mode cannot inflate/deflate this pill away from Figma.
513
+ specialBadge: {
514
+ paddingHorizontal: sbPadH,
515
+ paddingVertical: sbPadV,
516
+ borderRadius: sbRadius,
517
+ gap: sbGap,
518
+ },
476
519
  specialBadgeText: {
477
- color: '#ffffff',
520
+ color: sbLabelColor,
478
521
  fontSize: sbSize,
479
522
  fontFamily: sbFamily,
480
523
  fontWeight: sbWeight as TextStyle['fontWeight'],
@@ -501,16 +544,6 @@ const styles = StyleSheet.create({
501
544
  // the intended fade strength.
502
545
  opacity: 0.9,
503
546
  },
504
- // Overrides layered on top of <Badge type="glass">. Badge already supplies
505
- // the row layout + 4px gap (when a leading icon is present), `overflow:
506
- // 'hidden'`, `alignSelf: 'flex-start'` and `alignItems: 'center'`; these
507
- // pin the special badge's literal Figma box (6/2 padding, 4px radius)
508
- // regardless of the badge size tokens resolved by the active modes.
509
- specialBadge: {
510
- paddingHorizontal: 6,
511
- paddingVertical: 2,
512
- borderRadius: 4,
513
- },
514
547
  pressed: {
515
548
  opacity: 0.92,
516
549
  },