jfs-components 0.0.74 → 0.0.77

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 (92) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/lib/commonjs/components/ActionFooter/ActionFooter.js +147 -82
  3. package/lib/commonjs/components/Avatar/Avatar.js +20 -0
  4. package/lib/commonjs/components/Badge/Badge.js +23 -0
  5. package/lib/commonjs/components/Button/Button.js +37 -0
  6. package/lib/commonjs/components/IconButton/IconButton.js +20 -0
  7. package/lib/commonjs/components/Image/Image.js +26 -1
  8. package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
  9. package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
  10. package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
  11. package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
  12. package/lib/commonjs/components/PageHero/PageHero.js +41 -5
  13. package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
  14. package/lib/commonjs/components/Text/Text.js +31 -1
  15. package/lib/commonjs/components/index.js +7 -0
  16. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  17. package/lib/commonjs/icons/Icon.js +16 -0
  18. package/lib/commonjs/icons/registry.js +1 -1
  19. package/lib/commonjs/index.js +12 -0
  20. package/lib/commonjs/skeleton/Skeleton.js +234 -0
  21. package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
  22. package/lib/commonjs/skeleton/index.js +58 -0
  23. package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
  24. package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
  25. package/lib/module/components/ActionFooter/ActionFooter.js +146 -82
  26. package/lib/module/components/Avatar/Avatar.js +19 -0
  27. package/lib/module/components/Badge/Badge.js +23 -0
  28. package/lib/module/components/Button/Button.js +37 -0
  29. package/lib/module/components/IconButton/IconButton.js +20 -0
  30. package/lib/module/components/Image/Image.js +25 -1
  31. package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
  32. package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
  33. package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
  34. package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
  35. package/lib/module/components/PageHero/PageHero.js +41 -5
  36. package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
  37. package/lib/module/components/Text/Text.js +31 -1
  38. package/lib/module/components/index.js +1 -0
  39. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  40. package/lib/module/icons/Icon.js +16 -0
  41. package/lib/module/icons/registry.js +1 -1
  42. package/lib/module/index.js +2 -1
  43. package/lib/module/skeleton/Skeleton.js +229 -0
  44. package/lib/module/skeleton/SkeletonGroup.js +133 -0
  45. package/lib/module/skeleton/index.js +6 -0
  46. package/lib/module/skeleton/shimmer-tokens.js +181 -0
  47. package/lib/module/skeleton/useReducedMotion.js +61 -0
  48. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
  49. package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
  50. package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
  51. package/lib/typescript/src/components/Button/Button.d.ts +8 -1
  52. package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
  53. package/lib/typescript/src/components/Image/Image.d.ts +8 -1
  54. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
  55. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
  56. package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
  57. package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
  58. package/lib/typescript/src/components/PageHero/PageHero.d.ts +31 -5
  59. package/lib/typescript/src/components/Text/Text.d.ts +20 -1
  60. package/lib/typescript/src/components/index.d.ts +1 -0
  61. package/lib/typescript/src/icons/Icon.d.ts +7 -1
  62. package/lib/typescript/src/icons/registry.d.ts +1 -1
  63. package/lib/typescript/src/index.d.ts +1 -0
  64. package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
  65. package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
  66. package/lib/typescript/src/skeleton/index.d.ts +5 -0
  67. package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
  68. package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
  69. package/package.json +11 -1
  70. package/src/components/ActionFooter/ActionFooter.tsx +152 -86
  71. package/src/components/Avatar/Avatar.tsx +26 -0
  72. package/src/components/Badge/Badge.tsx +27 -0
  73. package/src/components/Button/Button.tsx +40 -0
  74. package/src/components/IconButton/IconButton.tsx +27 -0
  75. package/src/components/Image/Image.tsx +25 -0
  76. package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
  77. package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
  78. package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
  79. package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
  80. package/src/components/PageHero/PageHero.tsx +61 -4
  81. package/src/components/RechargeCard/RechargeCard.tsx +32 -24
  82. package/src/components/Text/Text.tsx +54 -0
  83. package/src/components/index.ts +1 -0
  84. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  85. package/src/icons/Icon.tsx +17 -0
  86. package/src/icons/registry.ts +1 -1
  87. package/src/index.ts +1 -0
  88. package/src/skeleton/Skeleton.tsx +298 -0
  89. package/src/skeleton/SkeletonGroup.tsx +193 -0
  90. package/src/skeleton/index.ts +10 -0
  91. package/src/skeleton/shimmer-tokens.ts +221 -0
  92. package/src/skeleton/useReducedMotion.ts +72 -0
@@ -14,6 +14,8 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
14
14
  import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
15
15
  import { EMPTY_MODES } from '../../utils/react-utils'
16
16
  import Icon from '../../icons/Icon'
17
+ import Skeleton from '../../skeleton/Skeleton'
18
+ import { useSkeleton } from '../../skeleton/SkeletonGroup'
17
19
 
18
20
  export type ButtonProps = SafePressableProps & {
19
21
  label?: string;
@@ -54,6 +56,13 @@ export type ButtonProps = SafePressableProps & {
54
56
  * Web-specific accessibility props (only used on web platform)
55
57
  */
56
58
  webAccessibilityProps?: WebAccessibilityProps;
59
+ /**
60
+ * Explicit per-instance loading override. When `true`, the button renders
61
+ * as a pill-shaped skeleton of the same size; when `false`, the
62
+ * surrounding `<SkeletonGroup>` is ignored. Defaults to inheriting from
63
+ * the group.
64
+ */
65
+ loading?: boolean;
57
66
  };
58
67
 
59
68
  // ---------------------------------------------------------------------------
@@ -224,6 +233,7 @@ function ButtonImpl({
224
233
  accessibilityHint,
225
234
  accessibilityState,
226
235
  webAccessibilityProps,
236
+ loading,
227
237
  ...rest
228
238
  }: ButtonProps) {
229
239
  // Hover state is web-only in practice; the setter is gated so native taps
@@ -248,6 +258,12 @@ function ButtonImpl({
248
258
  [modes, disabled]
249
259
  )
250
260
 
261
+ // Skeleton context — read unconditionally so React's hook order stays
262
+ // stable. The actual short-circuit return happens AFTER all remaining
263
+ // hooks have been called below.
264
+ const { active: groupActive } = useSkeleton()
265
+ const isLoading = loading ?? groupActive
266
+
251
267
  // Active label color: base by default; hover override (web-only) when hovered.
252
268
  // Press color is intentionally NOT applied to the label on native — applying
253
269
  // it would require a React render per touch and re-introduce the flicker.
@@ -354,6 +370,30 @@ function ButtonImpl({
354
370
  }
355
371
  }
356
372
 
373
+ if (isLoading) {
374
+ const { container, baseLabel, iconSize, accessoryOffset } = tokens
375
+ const paddingHorizontal = (container.paddingHorizontal as number) ?? 20
376
+ const paddingVertical = (container.paddingVertical as number) ?? 12
377
+ const lineHeight = (baseLabel.lineHeight as number) ?? 19
378
+ const fontSize = (baseLabel.fontSize as number) ?? 16
379
+ const labelText = typeof label === 'string' ? label : 'Button'
380
+ const charWidth = fontSize * 0.55
381
+ const labelWidth = Math.max(labelText.length, 4) * charWidth
382
+ const hasAccessory = !!(leading || trailing || icon)
383
+ const accessoryWidth = hasAccessory ? iconSize + accessoryOffset * 2 : 0
384
+ const skeletonWidth = paddingHorizontal * 2 + labelWidth + accessoryWidth
385
+ const skeletonHeight = paddingVertical * 2 + lineHeight
386
+ return (
387
+ <Skeleton
388
+ kind="other"
389
+ width={skeletonWidth}
390
+ height={skeletonHeight}
391
+ style={style as any}
392
+ modes={modes}
393
+ />
394
+ )
395
+ }
396
+
357
397
  return (
358
398
  <Pressable
359
399
  accessibilityRole="button"
@@ -12,6 +12,8 @@ import Icon from '../../icons/Icon'
12
12
  import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
13
13
  import { EMPTY_MODES } from '../../utils/react-utils'
14
14
  import type { UnifiedSource } from '../../utils/MediaSource'
15
+ import Skeleton from '../../skeleton/Skeleton'
16
+ import { useSkeleton } from '../../skeleton/SkeletonGroup'
15
17
 
16
18
  type IconButtonProps = SafePressableProps & {
17
19
  /** Built-in icon name from the registry (default state). */
@@ -65,6 +67,12 @@ type IconButtonProps = SafePressableProps & {
65
67
  * Web-specific accessibility props (only used on web platform)
66
68
  */
67
69
  webAccessibilityProps?: WebAccessibilityProps;
70
+ /**
71
+ * Explicit per-instance loading override. When `true`, renders a
72
+ * same-size pill-shaped skeleton instead of the button. Defaults to
73
+ * inheriting from the surrounding `<SkeletonGroup>`.
74
+ */
75
+ loading?: boolean;
68
76
  };
69
77
 
70
78
  // ---------------------------------------------------------------------------
@@ -155,6 +163,7 @@ function IconButton({
155
163
  inactiveIcon,
156
164
  inactiveSource,
157
165
  isActive = false,
166
+ loading,
158
167
  ...rest
159
168
  }: IconButtonProps) {
160
169
  // Merge explicit props with modes for token resolution. Memoize the merged
@@ -174,6 +183,11 @@ function IconButton({
174
183
  [componentModes, disabled]
175
184
  )
176
185
 
186
+ // Hook called unconditionally — short-circuit below comes AFTER all hooks
187
+ // to keep React's hook order stable across renders.
188
+ const { active: groupActive } = useSkeleton()
189
+ const isLoading = loading ?? groupActive
190
+
177
191
  const [isFocused, setIsFocused] = useState(false)
178
192
  const [isHovered, setIsHovered] = useState(false)
179
193
 
@@ -271,6 +285,19 @@ function IconButton({
271
285
  [tokens.baseContainerStyle, style, isHovered, isFocused, disabled]
272
286
  )
273
287
 
288
+ if (isLoading) {
289
+ const size = tokens.baseContainerStyle.width as number
290
+ return (
291
+ <Skeleton
292
+ kind="other"
293
+ width={size}
294
+ height={size}
295
+ style={style as any}
296
+ modes={componentModes}
297
+ />
298
+ )
299
+ }
300
+
274
301
  return (
275
302
  <Pressable
276
303
  accessibilityRole="button"
@@ -8,6 +8,8 @@ import {
8
8
  type ViewStyle,
9
9
  type ImageResizeMode,
10
10
  } from 'react-native'
11
+ import Skeleton from '../../skeleton/Skeleton'
12
+ import { useSkeleton } from '../../skeleton/SkeletonGroup'
11
13
 
12
14
  export type ImageProps = {
13
15
  /**
@@ -52,6 +54,13 @@ export type ImageProps = {
52
54
  | 'no'
53
55
  | 'no-hide-descendants'
54
56
  | undefined
57
+ /**
58
+ * Explicit per-instance loading override. When `true`, the image renders as
59
+ * a skeleton placeholder at the same box size; when `false`, the
60
+ * surrounding `<SkeletonGroup>` is ignored. Defaults to inheriting from
61
+ * the group.
62
+ */
63
+ loading?: boolean | undefined
55
64
  }
56
65
 
57
66
  function normalizeSource(
@@ -94,6 +103,7 @@ function Image({
94
103
  accessibilityLabel,
95
104
  accessibilityElementsHidden,
96
105
  importantForAccessibility,
106
+ loading,
97
107
  }: ImageProps) {
98
108
  const source = useMemo(() => normalizeSource(imageSource), [imageSource])
99
109
 
@@ -112,6 +122,21 @@ function Image({
112
122
  return s
113
123
  }, [ratio, width, height, borderRadius])
114
124
 
125
+ const { active: groupActive } = useSkeleton()
126
+ const isLoading = loading ?? groupActive
127
+ if (isLoading) {
128
+ // Match the loaded image's exact box. If height is unknown but a ratio
129
+ // is set, the skeleton uses `aspectRatio` the same way the loaded image
130
+ // would, so layout never jumps when the load resolves.
131
+ const skeletonStyle: ViewStyle = {
132
+ width: (width ?? '100%') as ViewStyle['width'],
133
+ ...(height != null
134
+ ? { height: height as number }
135
+ : { aspectRatio: ratio }),
136
+ }
137
+ return <Skeleton kind="image" style={skeletonStyle} />
138
+ }
139
+
115
140
  if (!source) {
116
141
  return <View style={[layoutStyle, style as StyleProp<ViewStyle>]} />
117
142
  }
@@ -0,0 +1,145 @@
1
+ import React, { useMemo } from 'react'
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
+ import { EMPTY_MODES } from '../../utils/react-utils'
6
+ import { getNativeLottieView } from './loadNativeLottieView'
7
+
8
+ /**
9
+ * A parsed Lottie animation. The JSON object you get from
10
+ * `require('./animation.json')` or `fetch().then(r => r.json())`. We keep the
11
+ * type intentionally loose because both `lottie-react-native` and `lottie-react`
12
+ * accept slightly different shapes — `LottiePlayer` narrows back to the
13
+ * platform-specific type internally.
14
+ */
15
+ export type LottieAnimationSource = Record<string, unknown>
16
+
17
+ export type LottiePlayerProps = {
18
+ /**
19
+ * Parsed Lottie animation JSON. Use `require('./animation.json')` in React
20
+ * Native or `import animation from './animation.json'` on web.
21
+ *
22
+ * URI sources (`{ uri: '...' }`) are intentionally not accepted here — web
23
+ * Lottie players require the animation data to be pre-parsed. Fetch and
24
+ * parse the JSON yourself before passing it in if you need a remote source.
25
+ */
26
+ source: LottieAnimationSource
27
+ /**
28
+ * Override the rendered size. Pass a number for a square box, or
29
+ * `{ width, height }` for non-square.
30
+ *
31
+ * When omitted, size is resolved from the `media/width` and `media/height`
32
+ * design tokens (default `117 × 117`). The `Media / Output` collection
33
+ * exposes `L | M | S` modes (117 / 70 / 20) — pass
34
+ * `modes={{ 'Media / Output': 'M' }}` to render at 70×70, etc.
35
+ */
36
+ size?: number | { width: number; height: number }
37
+ /** Play the animation on mount. Defaults to `true`. */
38
+ autoPlay?: boolean
39
+ /** Loop the animation. Defaults to `true`. */
40
+ loop?: boolean
41
+ /** Mode configuration for design-token theming. */
42
+ modes?: Record<string, any>
43
+ /** Style overrides applied to the underlying view. */
44
+ style?: StyleProp<ViewStyle>
45
+ /** Accessibility label. Lottie is decorative by default. */
46
+ accessibilityLabel?: string
47
+ testID?: string
48
+ }
49
+
50
+ const DEFAULT_SIZE = 117
51
+
52
+ function resolveSize(
53
+ size: LottiePlayerProps['size'],
54
+ modes: Record<string, any>
55
+ ) {
56
+ if (typeof size === 'number') return { width: size, height: size }
57
+ if (size && typeof size === 'object') return size
58
+ const width =
59
+ Number(getVariableByName('media/width', modes)) || DEFAULT_SIZE
60
+ const height =
61
+ Number(getVariableByName('media/height', modes)) || DEFAULT_SIZE
62
+ return { width, height }
63
+ }
64
+
65
+ /**
66
+ * Renders a Lottie animation using the consumer's installed
67
+ * `lottie-react-native` (native) or `lottie-react` (web) — both are declared
68
+ * as **optional peer dependencies** of `jfs-components`, so installing the
69
+ * library does not pull them in. Add the relevant package to your app only
70
+ * if you actually use `LottiePlayer`:
71
+ *
72
+ * ```sh
73
+ * # React Native (iOS / Android)
74
+ * npm install lottie-react-native
75
+ * cd ios && pod install
76
+ *
77
+ * # Web (or react-native-web)
78
+ * npm install lottie-react
79
+ * ```
80
+ *
81
+ * The web build (`LottiePlayer.web.tsx`) is picked automatically by Metro /
82
+ * webpack via platform extensions — same pattern as `MediaCard/GlassFill`.
83
+ *
84
+ * Token-driven sizing: when `size` is omitted, `LottiePlayer` reads
85
+ * `media/width` and `media/height` from the Figma variables resolver, so the
86
+ * animation matches the surrounding component's `Media / Output` mode
87
+ * automatically. This is the same sizing contract `PageHero` and
88
+ * `LottieIntroBlock` use for their `media` slots.
89
+ *
90
+ * @component
91
+ * @example
92
+ * ```tsx
93
+ * import animation from './assets/loader.json';
94
+ *
95
+ * <LottiePlayer source={animation} /> // 117 × 117 (default)
96
+ * <LottiePlayer source={animation} size={70} /> // 70 × 70
97
+ * <LottiePlayer source={animation} modes={{ 'Media / Output': 'S' }} /> // 20 × 20
98
+ * <PageHero media={<LottiePlayer source={animation} />} />
99
+ * ```
100
+ */
101
+ function LottiePlayer({
102
+ source,
103
+ size,
104
+ autoPlay = true,
105
+ loop = true,
106
+ modes: propModes = EMPTY_MODES,
107
+ style,
108
+ accessibilityLabel,
109
+ testID,
110
+ }: LottiePlayerProps) {
111
+ const { modes: globalModes } = useTokens()
112
+ const modes = useMemo(
113
+ () =>
114
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
115
+ ? EMPTY_MODES
116
+ : { ...globalModes, ...propModes },
117
+ [globalModes, propModes]
118
+ )
119
+
120
+ const { width, height } = useMemo(
121
+ () => resolveSize(size, modes),
122
+ [size, modes]
123
+ )
124
+
125
+ const NativeLottieView = useMemo(() => getNativeLottieView(), [])
126
+
127
+ return (
128
+ <View
129
+ style={[{ width, height }, style]}
130
+ testID={testID}
131
+ accessibilityLabel={accessibilityLabel}
132
+ accessibilityElementsHidden={accessibilityLabel ? undefined : true}
133
+ importantForAccessibility={accessibilityLabel ? 'auto' : 'no'}
134
+ >
135
+ <NativeLottieView
136
+ source={source}
137
+ autoPlay={autoPlay}
138
+ loop={loop}
139
+ style={{ width: '100%', height: '100%' }}
140
+ />
141
+ </View>
142
+ )
143
+ }
144
+
145
+ export default React.memo(LottiePlayer)
@@ -0,0 +1,94 @@
1
+ import React, { useMemo } from 'react'
2
+ import { type StyleProp, type ViewStyle } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
+ import { EMPTY_MODES } from '../../utils/react-utils'
6
+ import { getWebLottieView } from './loadWebLottieView'
7
+
8
+ export type LottieAnimationSource = Record<string, unknown>
9
+
10
+ export type LottiePlayerProps = {
11
+ source: LottieAnimationSource
12
+ size?: number | { width: number; height: number }
13
+ autoPlay?: boolean
14
+ loop?: boolean
15
+ modes?: Record<string, any>
16
+ style?: StyleProp<ViewStyle>
17
+ accessibilityLabel?: string
18
+ testID?: string
19
+ }
20
+
21
+ const DEFAULT_SIZE = 117
22
+
23
+ function resolveSize(
24
+ size: LottiePlayerProps['size'],
25
+ modes: Record<string, any>
26
+ ) {
27
+ if (typeof size === 'number') return { width: size, height: size }
28
+ if (size && typeof size === 'object') return size
29
+ const width =
30
+ Number(getVariableByName('media/width', modes)) || DEFAULT_SIZE
31
+ const height =
32
+ Number(getVariableByName('media/height', modes)) || DEFAULT_SIZE
33
+ return { width, height }
34
+ }
35
+
36
+ /**
37
+ * Web build of `LottiePlayer` — picked automatically by webpack /
38
+ * Metro-for-web via the `.web.tsx` platform extension. Uses `lottie-react`
39
+ * (which wraps `lottie-web`) and renders a plain DOM container.
40
+ *
41
+ * Public API mirrors `LottiePlayer.tsx` (native). See that file for the
42
+ * documented prop reference and usage patterns.
43
+ */
44
+ function LottiePlayer({
45
+ source,
46
+ size,
47
+ autoPlay = true,
48
+ loop = true,
49
+ modes: propModes = EMPTY_MODES,
50
+ style,
51
+ accessibilityLabel,
52
+ testID,
53
+ }: LottiePlayerProps) {
54
+ const { modes: globalModes } = useTokens()
55
+ const modes = useMemo(
56
+ () =>
57
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
58
+ ? EMPTY_MODES
59
+ : { ...globalModes, ...propModes },
60
+ [globalModes, propModes]
61
+ )
62
+
63
+ const { width, height } = useMemo(
64
+ () => resolveSize(size, modes),
65
+ [size, modes]
66
+ )
67
+
68
+ const WebLottieView = useMemo(() => getWebLottieView(), [])
69
+
70
+ return (
71
+ <div
72
+ style={{
73
+ width,
74
+ height,
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ justifyContent: 'center',
78
+ ...(style as React.CSSProperties),
79
+ }}
80
+ data-testid={testID}
81
+ aria-label={accessibilityLabel}
82
+ aria-hidden={accessibilityLabel ? undefined : true}
83
+ >
84
+ <WebLottieView
85
+ animationData={source}
86
+ autoplay={autoPlay}
87
+ loop={loop}
88
+ style={{ width: '100%', height: '100%' }}
89
+ />
90
+ </div>
91
+ )
92
+ }
93
+
94
+ export default React.memo(LottiePlayer)
@@ -0,0 +1,87 @@
1
+ import React from 'react'
2
+ import { Text, View, type ViewStyle } from 'react-native'
3
+
4
+ /** Props we forward to the underlying native Lottie view. */
5
+ export type NativeLottieViewProps = {
6
+ source: Record<string, unknown>
7
+ autoPlay?: boolean
8
+ loop?: boolean
9
+ style?: ViewStyle
10
+ }
11
+
12
+ const INSTALL_HINT =
13
+ 'LottiePlayer requires lottie-react-native in your app.\n' +
14
+ ' npm install lottie-react-native\n' +
15
+ ' cd ios && pod install'
16
+
17
+ /**
18
+ * Metro resolves `require('lottie-react-native')` at bundle time even inside
19
+ * try/catch, which breaks apps that import `jfs-components` without having
20
+ * the optional peer installed. Splitting the module id into a runtime string
21
+ * keeps Metro from statically linking it — the native module is loaded only
22
+ * when present in the consumer's node_modules.
23
+ */
24
+ function resolveNativeLottieModuleName() {
25
+ return ['lottie', '-react', '-native'].join('')
26
+ }
27
+
28
+ function LottieUnavailableView({ style }: Pick<NativeLottieViewProps, 'style'>) {
29
+ if (__DEV__) {
30
+ return (
31
+ <View
32
+ style={[
33
+ style,
34
+ {
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ backgroundColor: 'rgba(255, 196, 0, 0.12)',
38
+ borderWidth: 1,
39
+ borderColor: 'rgba(255, 196, 0, 0.45)',
40
+ borderRadius: 8,
41
+ padding: 8,
42
+ },
43
+ ]}
44
+ >
45
+ <Text
46
+ style={{
47
+ color: '#8a6d00',
48
+ fontSize: 11,
49
+ textAlign: 'center',
50
+ lineHeight: 15,
51
+ }}
52
+ >
53
+ {INSTALL_HINT}
54
+ </Text>
55
+ </View>
56
+ )
57
+ }
58
+
59
+ return <View style={style} />
60
+ }
61
+
62
+ function LottieUnavailable(props: NativeLottieViewProps) {
63
+ React.useEffect(() => {
64
+ if (__DEV__) {
65
+ console.warn(`[jfs-components/LottiePlayer] ${INSTALL_HINT}`)
66
+ }
67
+ }, [])
68
+
69
+ return <LottieUnavailableView style={props.style} />
70
+ }
71
+
72
+ let cachedView: React.ComponentType<NativeLottieViewProps> | undefined
73
+
74
+ export function getNativeLottieView(): React.ComponentType<NativeLottieViewProps> {
75
+ if (cachedView !== undefined) return cachedView
76
+
77
+ try {
78
+ const mod = require(resolveNativeLottieModuleName()) as {
79
+ default?: React.ComponentType<NativeLottieViewProps>
80
+ }
81
+ cachedView = mod.default ?? LottieUnavailable
82
+ } catch {
83
+ cachedView = LottieUnavailable
84
+ }
85
+
86
+ return cachedView
87
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react'
2
+ import type { CSSProperties } from 'react'
3
+
4
+ /** Props we forward to the underlying web Lottie view. */
5
+ export type WebLottieViewProps = {
6
+ animationData: Record<string, unknown>
7
+ autoplay?: boolean
8
+ loop?: boolean
9
+ style?: CSSProperties
10
+ }
11
+
12
+ const INSTALL_HINT =
13
+ 'LottiePlayer requires lottie-react in your app.\n' +
14
+ ' npm install lottie-react'
15
+
16
+ function resolveWebLottieModuleName() {
17
+ return ['lottie', '-react'].join('')
18
+ }
19
+
20
+ function LottieUnavailable(props: WebLottieViewProps) {
21
+ React.useEffect(() => {
22
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
23
+ console.warn(`[jfs-components/LottiePlayer] ${INSTALL_HINT}`)
24
+ }
25
+ }, [])
26
+
27
+ return (
28
+ <div
29
+ style={{
30
+ ...props.style,
31
+ display: 'flex',
32
+ alignItems: 'center',
33
+ justifyContent: 'center',
34
+ backgroundColor: 'rgba(255, 196, 0, 0.12)',
35
+ border: '1px solid rgba(255, 196, 0, 0.45)',
36
+ borderRadius: 8,
37
+ padding: 8,
38
+ color: '#8a6d00',
39
+ fontSize: 11,
40
+ textAlign: 'center',
41
+ lineHeight: '15px',
42
+ }}
43
+ >
44
+ {typeof __DEV__ !== 'undefined' && __DEV__ ? INSTALL_HINT : null}
45
+ </div>
46
+ )
47
+ }
48
+
49
+ let cachedView: React.ComponentType<WebLottieViewProps> | undefined
50
+
51
+ export function getWebLottieView(): React.ComponentType<WebLottieViewProps> {
52
+ if (cachedView !== undefined) return cachedView
53
+
54
+ try {
55
+ const mod = require(resolveWebLottieModuleName()) as {
56
+ default?: React.ComponentType<WebLottieViewProps>
57
+ }
58
+ cachedView = mod.default ?? LottieUnavailable
59
+ } catch {
60
+ cachedView = LottieUnavailable
61
+ }
62
+
63
+ return cachedView
64
+ }
@@ -32,6 +32,23 @@ export type PageHeroProps = {
32
32
  * `modes` are automatically cascaded into this slot.
33
33
  */
34
34
  buttonSlot?: React.ReactNode
35
+ /**
36
+ * Optional media element shown above the text block (eyebrow + headline).
37
+ *
38
+ * Intentionally typed as `React.ReactNode` so the consumer can bring any
39
+ * renderer they like — `<Image />`, `<IconCapsule />`, a Lottie player
40
+ * (`lottie-react-native` / `@lottiefiles/dotlottie-react`), an `<SvgXml />`
41
+ * from `react-native-svg`, a `<Video />` from `react-native-video`, a
42
+ * gradient view, or a custom illustration. The library deliberately does
43
+ * NOT wrap Lottie / video runtimes (they require native modules + pod
44
+ * autolinking on every consumer's app), so PageHero just allocates a
45
+ * token-sized container and lets the slot render whatever it wants.
46
+ *
47
+ * The slot is rendered inside a `width × height` box driven by
48
+ * `media/width` and `media/height` tokens (default 117×117). `modes` are
49
+ * automatically cascaded into the slot via `cloneChildrenWithModes`.
50
+ */
51
+ media?: React.ReactNode
35
52
  /** Mode configuration for design-token theming. */
36
53
  modes?: Record<string, any>
37
54
  /** Style overrides applied to the outer container. */
@@ -41,12 +58,14 @@ export type PageHeroProps = {
41
58
 
42
59
  /**
43
60
  * PageHero displays a centered hero block typically used at the top of a page
44
- * or feature screen. It contains an eyebrow line, a large headline, an optional
45
- * supporting line (e.g. price/timeline), and an optional action button.
61
+ * or feature screen. It contains an optional media slot (illustration / image
62
+ * / Lottie / SVG / video — consumer's choice), an eyebrow line, a large
63
+ * headline, an optional supporting line (e.g. price / timeline), and an
64
+ * optional action button.
46
65
  *
47
66
  * All visual values are resolved from Figma design tokens via
48
- * `getVariableByName`. The button slot cascades the active `modes` to its
49
- * children through `cloneChildrenWithModes`.
67
+ * `getVariableByName`. Slots cascade the active `modes` to their children
68
+ * through `cloneChildrenWithModes`.
50
69
  *
51
70
  * @component
52
71
  * @example
@@ -57,6 +76,13 @@ export type PageHeroProps = {
57
76
  * supportingText="₹999/year · ₹0 until 2027"
58
77
  * buttonLabel="Renew for free"
59
78
  * onButtonPress={() => navigate('Upgrade')}
79
+ * media={
80
+ * <Image
81
+ * imageSource={require('./assets/upgrade.png')}
82
+ * width={117}
83
+ * height={117}
84
+ * />
85
+ * }
60
86
  * />
61
87
  * ```
62
88
  */
@@ -69,6 +95,7 @@ function PageHero({
69
95
  onButtonPress,
70
96
  showButton = true,
71
97
  buttonSlot,
98
+ media,
72
99
  modes: propModes = EMPTY_MODES,
73
100
  style,
74
101
  testID,
@@ -86,6 +113,12 @@ function PageHero({
86
113
  const textWrapGap =
87
114
  Number(getVariableByName('PageHero/textWrap/gap', modes)) || 8
88
115
 
116
+ // Media slot box — matches the 117×117 frame in Figma (node 4540:7845).
117
+ // Tokens fall back to 117 when not defined in the variables collection,
118
+ // so the layout stays stable on consumers that haven't tokenized this yet.
119
+ const mediaWidth = Number(getVariableByName('media/width', modes)) || 117
120
+ const mediaHeight = Number(getVariableByName('media/height', modes)) || 117
121
+
89
122
  const eyebrowColor =
90
123
  getVariableByName('PageHero/eyebrow/color', modes) || '#ffffff'
91
124
  const eyebrowFontFamily =
@@ -183,8 +216,32 @@ function PageHero({
183
216
  // eslint-disable-next-line react-hooks/exhaustive-deps
184
217
  }, [buttonSlot, showButton, buttonLabel, onButtonPress, modes])
185
218
 
219
+ // Sized container for the media slot. Always rendered when `media` is
220
+ // provided, so the slot has a predictable box (matches Figma frame
221
+ // 4540:7845 — 117×117 by default) even if the inner element omits its
222
+ // own width/height. `overflow: 'hidden'` mirrors the Figma frame's
223
+ // `clipsContent` so a slightly oversized illustration doesn't break
224
+ // the centered layout.
225
+ const mediaContent = useMemo<React.ReactNode>(() => {
226
+ if (media === undefined || media === null) return null
227
+ return (
228
+ <View
229
+ style={{
230
+ width: mediaWidth,
231
+ height: mediaHeight,
232
+ alignItems: 'center',
233
+ justifyContent: 'center',
234
+ overflow: 'hidden',
235
+ }}
236
+ >
237
+ {cloneChildrenWithModes(media, modes)}
238
+ </View>
239
+ )
240
+ }, [media, mediaWidth, mediaHeight, modes])
241
+
186
242
  return (
187
243
  <View style={[containerStyle, style]} testID={testID}>
244
+ {mediaContent}
188
245
  <View style={textWrapStyle}>
189
246
  {eyebrow ? <Text style={eyebrowStyle}>{eyebrow}</Text> : null}
190
247
  {headline ? <Text style={headlineStyle}>{headline}</Text> : null}