jfs-components 0.0.71 → 0.0.73
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 +60 -0
- package/lib/commonjs/components/AccordionCheckbox/AccordionCheckbox.js +239 -0
- package/lib/commonjs/components/BrandChip/BrandChip.js +149 -0
- package/lib/commonjs/components/CardAdvisory/CardAdvisory.js +2 -2
- package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +213 -0
- package/lib/commonjs/components/CardFinancialCondition/CardFinancialCondition.js +213 -0
- package/lib/commonjs/components/CardInsight/CardInsight.js +166 -0
- package/lib/commonjs/components/Carousel/Carousel.js +9 -7
- package/lib/commonjs/components/CheckboxGroup/CheckboxGroup.js +67 -0
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +125 -0
- package/lib/commonjs/components/CircularProgressBar/CircularProgressBar.js +56 -9
- package/lib/commonjs/components/CoverageBarComparison/CoverageBarComparison.js +272 -0
- package/lib/commonjs/components/CoverageRing/CoverageRing.js +141 -0
- package/lib/commonjs/components/DonutChart/DonutChart.js +309 -0
- package/lib/commonjs/components/DonutChartSummary/DonutChartSummary.js +155 -0
- package/lib/commonjs/components/HoldingsCard/HoldingsCard.js +2 -2
- package/lib/commonjs/components/InstitutionBadge/InstitutionBadge.js +132 -0
- package/lib/commonjs/components/LinearMeter/LinearMeter.js +9 -28
- package/lib/commonjs/components/LinearProgress/LinearProgress.js +68 -0
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +95 -0
- package/lib/commonjs/components/MonthlyStatusGrid/MonthlyStatusGrid.js +286 -0
- package/lib/commonjs/components/OTP/OTP.js +381 -37
- package/lib/commonjs/components/ProductOverview/ProductOverview.js +147 -0
- package/lib/commonjs/components/Radio/Radio.js +194 -0
- package/lib/commonjs/components/RadioButton/RadioButton.js +21 -188
- package/lib/commonjs/components/RangeTrack/RangeTrack.js +269 -0
- package/lib/commonjs/components/SavingsGoalSummary/SavingsGoalSummary.js +181 -0
- package/lib/commonjs/components/SegmentedTrack/SegmentedTrack.js +171 -0
- package/lib/commonjs/components/StatGroup/StatGroup.js +128 -0
- package/lib/commonjs/components/StatItem/StatItem.js +65 -35
- package/lib/commonjs/components/StrengthIndicator/StrengthIndicator.js +157 -0
- package/lib/commonjs/components/SummaryTile/SummaryTile.js +150 -0
- package/lib/commonjs/components/index.js +192 -1
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/utils/index.js +7 -0
- package/lib/commonjs/utils/number-utils.js +57 -0
- package/lib/module/components/AccordionCheckbox/AccordionCheckbox.js +233 -0
- package/lib/module/components/BrandChip/BrandChip.js +143 -0
- package/lib/module/components/CardAdvisory/CardAdvisory.js +2 -2
- package/lib/module/components/CardBankAccount/CardBankAccount.js +208 -0
- package/lib/module/components/CardFinancialCondition/CardFinancialCondition.js +207 -0
- package/lib/module/components/CardInsight/CardInsight.js +161 -0
- package/lib/module/components/Carousel/Carousel.js +9 -7
- package/lib/module/components/CheckboxGroup/CheckboxGroup.js +62 -0
- package/lib/module/components/CheckboxItem/CheckboxItem.js +119 -0
- package/lib/module/components/CircularProgressBar/CircularProgressBar.js +56 -9
- package/lib/module/components/CoverageBarComparison/CoverageBarComparison.js +266 -0
- package/lib/module/components/CoverageRing/CoverageRing.js +136 -0
- package/lib/module/components/DonutChart/DonutChart.js +303 -0
- package/lib/module/components/DonutChartSummary/DonutChartSummary.js +150 -0
- package/lib/module/components/HoldingsCard/HoldingsCard.js +2 -2
- package/lib/module/components/InstitutionBadge/InstitutionBadge.js +127 -0
- package/lib/module/components/LinearMeter/LinearMeter.js +9 -28
- package/lib/module/components/LinearProgress/LinearProgress.js +63 -0
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +90 -0
- package/lib/module/components/MonthlyStatusGrid/MonthlyStatusGrid.js +281 -0
- package/lib/module/components/OTP/OTP.js +381 -38
- package/lib/module/components/ProductOverview/ProductOverview.js +142 -0
- package/lib/module/components/Radio/Radio.js +188 -0
- package/lib/module/components/RadioButton/RadioButton.js +20 -185
- package/lib/module/components/RangeTrack/RangeTrack.js +263 -0
- package/lib/module/components/SavingsGoalSummary/SavingsGoalSummary.js +175 -0
- package/lib/module/components/SegmentedTrack/SegmentedTrack.js +166 -0
- package/lib/module/components/StatGroup/StatGroup.js +123 -0
- package/lib/module/components/StatItem/StatItem.js +66 -36
- package/lib/module/components/StrengthIndicator/StrengthIndicator.js +152 -0
- package/lib/module/components/SummaryTile/SummaryTile.js +145 -0
- package/lib/module/components/index.js +28 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/utils/index.js +2 -1
- package/lib/module/utils/number-utils.js +53 -0
- package/lib/typescript/src/components/AccordionCheckbox/AccordionCheckbox.d.ts +71 -0
- package/lib/typescript/src/components/BrandChip/BrandChip.d.ts +43 -0
- package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +79 -0
- package/lib/typescript/src/components/CardFinancialCondition/CardFinancialCondition.d.ts +50 -0
- package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +48 -0
- package/lib/typescript/src/components/CheckboxGroup/CheckboxGroup.d.ts +41 -0
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +56 -0
- package/lib/typescript/src/components/CircularProgressBar/CircularProgressBar.d.ts +11 -1
- package/lib/typescript/src/components/CoverageBarComparison/CoverageBarComparison.d.ts +105 -0
- package/lib/typescript/src/components/CoverageRing/CoverageRing.d.ts +90 -0
- package/lib/typescript/src/components/DonutChart/DonutChart.d.ts +117 -0
- package/lib/typescript/src/components/DonutChartSummary/DonutChartSummary.d.ts +103 -0
- package/lib/typescript/src/components/InstitutionBadge/InstitutionBadge.d.ts +30 -0
- package/lib/typescript/src/components/LinearProgress/LinearProgress.d.ts +17 -0
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +37 -0
- package/lib/typescript/src/components/MonthlyStatusGrid/MonthlyStatusGrid.d.ts +119 -0
- package/lib/typescript/src/components/OTP/OTP.d.ts +88 -2
- package/lib/typescript/src/components/ProductOverview/ProductOverview.d.ts +39 -0
- package/lib/typescript/src/components/Radio/Radio.d.ts +30 -0
- package/lib/typescript/src/components/RadioButton/RadioButton.d.ts +20 -28
- package/lib/typescript/src/components/RangeTrack/RangeTrack.d.ts +173 -0
- package/lib/typescript/src/components/SavingsGoalSummary/SavingsGoalSummary.d.ts +95 -0
- package/lib/typescript/src/components/SegmentedTrack/SegmentedTrack.d.ts +108 -0
- package/lib/typescript/src/components/StatGroup/StatGroup.d.ts +45 -0
- package/lib/typescript/src/components/StatItem/StatItem.d.ts +24 -7
- package/lib/typescript/src/components/StrengthIndicator/StrengthIndicator.d.ts +58 -0
- package/lib/typescript/src/components/SummaryTile/SummaryTile.d.ts +60 -0
- package/lib/typescript/src/components/index.d.ts +29 -2
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/lib/typescript/src/utils/index.d.ts +1 -0
- package/lib/typescript/src/utils/number-utils.d.ts +29 -0
- package/package.json +1 -1
- package/src/components/AccordionCheckbox/AccordionCheckbox.tsx +323 -0
- package/src/components/BrandChip/BrandChip.tsx +235 -0
- package/src/components/CardAdvisory/CardAdvisory.tsx +2 -2
- package/src/components/CardBankAccount/CardBankAccount.tsx +295 -0
- package/src/components/CardFinancialCondition/CardFinancialCondition.tsx +366 -0
- package/src/components/CardInsight/CardInsight.tsx +239 -0
- package/src/components/Carousel/Carousel.tsx +14 -6
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +86 -0
- package/src/components/CheckboxItem/CheckboxItem.tsx +174 -0
- package/src/components/CircularProgressBar/CircularProgressBar.tsx +74 -9
- package/src/components/CoverageBarComparison/CoverageBarComparison.tsx +378 -0
- package/src/components/CoverageRing/CoverageRing.tsx +225 -0
- package/src/components/DonutChart/DonutChart.tsx +503 -0
- package/src/components/DonutChartSummary/DonutChartSummary.tsx +256 -0
- package/src/components/HoldingsCard/HoldingsCard.tsx +2 -2
- package/src/components/InstitutionBadge/InstitutionBadge.tsx +216 -0
- package/src/components/LinearMeter/LinearMeter.tsx +9 -39
- package/src/components/LinearProgress/LinearProgress.tsx +92 -0
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +167 -0
- package/src/components/MonthlyStatusGrid/MonthlyStatusGrid.tsx +438 -0
- package/src/components/OTP/OTP.tsx +476 -29
- package/src/components/ProductOverview/ProductOverview.tsx +236 -0
- package/src/components/Radio/Radio.tsx +227 -0
- package/src/components/RadioButton/RadioButton.tsx +23 -225
- package/src/components/RangeTrack/RangeTrack.tsx +394 -0
- package/src/components/SavingsGoalSummary/SavingsGoalSummary.tsx +269 -0
- package/src/components/SegmentedTrack/SegmentedTrack.tsx +268 -0
- package/src/components/StatGroup/StatGroup.tsx +169 -0
- package/src/components/StatItem/StatItem.tsx +117 -40
- package/src/components/StrengthIndicator/StrengthIndicator.tsx +205 -0
- package/src/components/SummaryTile/SummaryTile.tsx +251 -0
- package/src/components/index.ts +39 -2
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/registry.ts +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/number-utils.ts +60 -0
|
@@ -1,13 +1,252 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
4
4
|
import { View, Text, TextInput as RNTextInput, Pressable, Animated } from 'react-native';
|
|
5
5
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
|
|
6
6
|
import { useTokens } from '../../design-tokens/JFSThemeProvider';
|
|
7
7
|
import SupportText from '../SupportText/SupportText';
|
|
8
|
+
import Button from '../Button/Button';
|
|
8
9
|
import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils';
|
|
10
|
+
|
|
11
|
+
// Default mode overrides for the resend Button. Per design: a small,
|
|
12
|
+
// low-emphasis, neutral-appearance button. Consumers can override any of
|
|
13
|
+
// these via OTPResendConfig.resendButtonModes.
|
|
9
14
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
15
|
+
const DEFAULT_RESEND_BUTTON_MODES = {
|
|
16
|
+
AppearanceBrand: 'Neutral',
|
|
17
|
+
'Button / Size': 'S',
|
|
18
|
+
Emphasis: 'Low'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// useOtpResend — headless state machine for the resend countdown.
|
|
23
|
+
//
|
|
24
|
+
// State machine: counting -> ready -> sending -> counting -> ...
|
|
25
|
+
//
|
|
26
|
+
// counting : timer is ticking down; resend button disabled.
|
|
27
|
+
// ready : timer elapsed; resend button enabled.
|
|
28
|
+
// sending : an in-flight onResend() promise is pending; button shows
|
|
29
|
+
// a transient "sending" label, prevents double-fire, and
|
|
30
|
+
// restarts the countdown only on resolve.
|
|
31
|
+
//
|
|
32
|
+
// Designed as a hook so consumers can render any UI they want and still
|
|
33
|
+
// reuse the timing/lifecycle logic. The OTPResend component below is the
|
|
34
|
+
// opinionated default.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Headless hook that drives an OTP resend countdown.
|
|
39
|
+
*
|
|
40
|
+
* The hook is intentionally UI-agnostic: it returns just enough state
|
|
41
|
+
* (`state`, `secondsLeft`, plus `resend()` / `restart()` / `skip()`) so
|
|
42
|
+
* consumers can render their own button, support text, etc. The bundled
|
|
43
|
+
* `OTPResend` component is the canonical UI on top of this hook.
|
|
44
|
+
*/
|
|
45
|
+
export function useOtpResend({
|
|
46
|
+
durationSeconds = 30,
|
|
47
|
+
onResend,
|
|
48
|
+
autoStart = true
|
|
49
|
+
} = {}) {
|
|
50
|
+
// Initial state: if we're auto-starting, we begin in 'counting' with
|
|
51
|
+
// the full duration; otherwise we go straight to 'ready' (handy for
|
|
52
|
+
// flows where a previous screen already triggered the SMS and we
|
|
53
|
+
// mount with the timer already elapsed).
|
|
54
|
+
const [state, setState] = useState(autoStart ? 'counting' : 'ready');
|
|
55
|
+
const [secondsLeft, setSecondsLeft] = useState(autoStart ? durationSeconds : 0);
|
|
56
|
+
|
|
57
|
+
// Keep a ref to the latest onResend so the resend() callback is stable
|
|
58
|
+
// even if consumers pass an inline arrow function on every render.
|
|
59
|
+
const onResendRef = useRef(onResend);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
onResendRef.current = onResend;
|
|
62
|
+
}, [onResend]);
|
|
63
|
+
const intervalRef = useRef(null);
|
|
64
|
+
const stopTicker = useCallback(() => {
|
|
65
|
+
if (intervalRef.current !== null) {
|
|
66
|
+
clearInterval(intervalRef.current);
|
|
67
|
+
intervalRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
const startTicker = useCallback(seconds => {
|
|
71
|
+
stopTicker();
|
|
72
|
+
const initial = Math.max(0, Math.floor(seconds));
|
|
73
|
+
if (initial === 0) {
|
|
74
|
+
setState('ready');
|
|
75
|
+
setSecondsLeft(0);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setState('counting');
|
|
79
|
+
setSecondsLeft(initial);
|
|
80
|
+
intervalRef.current = setInterval(() => {
|
|
81
|
+
setSecondsLeft(prev => {
|
|
82
|
+
if (prev <= 1) {
|
|
83
|
+
stopTicker();
|
|
84
|
+
setState('ready');
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
return prev - 1;
|
|
88
|
+
});
|
|
89
|
+
}, 1000);
|
|
90
|
+
}, [stopTicker]);
|
|
91
|
+
|
|
92
|
+
// Auto-start once on mount if requested. We deliberately omit
|
|
93
|
+
// `durationSeconds` from the dependency array so changing the prop
|
|
94
|
+
// mid-flight does not silently reset the countdown — consumers should
|
|
95
|
+
// call `restart()` explicitly when they want a new duration.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (autoStart) startTicker(durationSeconds);
|
|
98
|
+
return stopTicker;
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, []);
|
|
101
|
+
const resend = useCallback(async () => {
|
|
102
|
+
// Guard against double-fire and out-of-state taps (e.g. an
|
|
103
|
+
// accessibility re-tap while we're already in 'sending').
|
|
104
|
+
if (state !== 'ready') return;
|
|
105
|
+
try {
|
|
106
|
+
setState('sending');
|
|
107
|
+
const result = onResendRef.current?.();
|
|
108
|
+
if (result && typeof result.then === 'function') {
|
|
109
|
+
await result;
|
|
110
|
+
}
|
|
111
|
+
startTicker(durationSeconds);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
// Surface the failure — the consumer can decide whether to
|
|
114
|
+
// show a toast, retry, etc. We move back to 'ready' so the
|
|
115
|
+
// user can try again immediately.
|
|
116
|
+
setState('ready');
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}, [state, durationSeconds, startTicker]);
|
|
120
|
+
const restart = useCallback(next => {
|
|
121
|
+
startTicker(next ?? durationSeconds);
|
|
122
|
+
}, [durationSeconds, startTicker]);
|
|
123
|
+
const skip = useCallback(() => {
|
|
124
|
+
stopTicker();
|
|
125
|
+
setState('ready');
|
|
126
|
+
setSecondsLeft(0);
|
|
127
|
+
}, [stopTicker]);
|
|
128
|
+
return {
|
|
129
|
+
state,
|
|
130
|
+
secondsLeft,
|
|
131
|
+
canResend: state === 'ready',
|
|
132
|
+
isSending: state === 'sending',
|
|
133
|
+
resend,
|
|
134
|
+
restart,
|
|
135
|
+
skip
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// OTPResend — opinionated UI built on useOtpResend.
|
|
141
|
+
//
|
|
142
|
+
// Renders a SupportText line that shows "Resend OTP in {n}s" while the
|
|
143
|
+
// countdown is active, and turns into a tappable "Resend" button once the
|
|
144
|
+
// countdown elapses. Re-tapping is debounced via the hook's state machine.
|
|
145
|
+
//
|
|
146
|
+
// Exported as a standalone so consumers can position it anywhere in their
|
|
147
|
+
// layout (it does not need to live inside <OTP />). When passed via
|
|
148
|
+
// <OTP resend={...} />, OTP renders this internally in the support area.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const defaultFormatCountdown = s => `Resend OTP in ${s}s`;
|
|
152
|
+
export function OTPResend({
|
|
153
|
+
durationSeconds = 30,
|
|
154
|
+
onResend,
|
|
155
|
+
autoStart = true,
|
|
156
|
+
formatCountdown,
|
|
157
|
+
sendingLabel = 'Sending…',
|
|
158
|
+
resendLabel = 'Resend',
|
|
159
|
+
countdownStatus = 'Loading',
|
|
160
|
+
resendButtonModes,
|
|
161
|
+
modes: propModes = EMPTY_MODES,
|
|
162
|
+
style
|
|
163
|
+
}) {
|
|
164
|
+
const {
|
|
165
|
+
modes: globalModes
|
|
166
|
+
} = useTokens();
|
|
167
|
+
const modes = useMemo(() => ({
|
|
168
|
+
...globalModes,
|
|
169
|
+
...propModes
|
|
170
|
+
}), [globalModes, propModes]);
|
|
171
|
+
|
|
172
|
+
// The Button gets the consumer's modes layered first, then the
|
|
173
|
+
// component-default brand/size/emphasis trio, and finally any caller
|
|
174
|
+
// overrides via `resendButtonModes`. Spreading in this order lets
|
|
175
|
+
// consumers (a) keep theming like Color Mode propagating through,
|
|
176
|
+
// (b) rely on the small/low/neutral defaults out of the box, and
|
|
177
|
+
// (c) selectively override e.g. `Button / Size: 'M'` without losing
|
|
178
|
+
// the other defaults.
|
|
179
|
+
const resolvedButtonModes = useMemo(() => ({
|
|
180
|
+
...modes,
|
|
181
|
+
...DEFAULT_RESEND_BUTTON_MODES,
|
|
182
|
+
...resendButtonModes
|
|
183
|
+
}), [modes, resendButtonModes]);
|
|
184
|
+
const {
|
|
185
|
+
state,
|
|
186
|
+
secondsLeft,
|
|
187
|
+
canResend,
|
|
188
|
+
resend
|
|
189
|
+
} = useOtpResend({
|
|
190
|
+
durationSeconds,
|
|
191
|
+
onResend,
|
|
192
|
+
autoStart
|
|
193
|
+
});
|
|
194
|
+
const formatter = formatCountdown ?? defaultFormatCountdown;
|
|
195
|
+
|
|
196
|
+
// counting → static SupportText. Not a Pressable: tapping the
|
|
197
|
+
// countdown should not do anything (it's an inert status line).
|
|
198
|
+
if (state === 'counting') {
|
|
199
|
+
return /*#__PURE__*/_jsx(SupportText, {
|
|
200
|
+
label: formatter(secondsLeft),
|
|
201
|
+
status: countdownStatus,
|
|
202
|
+
modes: modes,
|
|
203
|
+
style: style
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// sending → static SupportText with a transient "Sending…" label so
|
|
208
|
+
// the user has clear feedback that their tap was received and we
|
|
209
|
+
// don't accept another tap until the resend completes.
|
|
210
|
+
if (state === 'sending') {
|
|
211
|
+
return /*#__PURE__*/_jsx(SupportText, {
|
|
212
|
+
label: sendingLabel,
|
|
213
|
+
status: countdownStatus,
|
|
214
|
+
modes: modes,
|
|
215
|
+
style: style
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ready → render a real Button (no leading/trailing icon — design
|
|
220
|
+
// calls for a clean text-only pill once the countdown elapses). The
|
|
221
|
+
// Button handles its own pressed/hover states from tokens, so we
|
|
222
|
+
// don't need to layer extra opacity etc. on top.
|
|
223
|
+
return /*#__PURE__*/_jsx(Button, {
|
|
224
|
+
label: resendLabel,
|
|
225
|
+
modes: resolvedButtonModes,
|
|
226
|
+
disabled: !canResend,
|
|
227
|
+
onPress: () => {
|
|
228
|
+
// Swallow rejections here — the hook re-throws so callers
|
|
229
|
+
// wiring useOtpResend directly can react, but at the
|
|
230
|
+
// component boundary we never want an unhandled promise.
|
|
231
|
+
resend().catch(() => {});
|
|
232
|
+
},
|
|
233
|
+
accessibilityLabel: resendLabel,
|
|
234
|
+
style: style
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// OTP
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
10
242
|
const DIGITS_ONLY = /^\d*$/;
|
|
243
|
+
|
|
244
|
+
// How long the underline takes to fade in when a slot becomes
|
|
245
|
+
// "highlighted" (filled or actively focused), and to fade out when a slot
|
|
246
|
+
// reverts to idle (e.g. on backspace). The asymmetric durations make the
|
|
247
|
+
// "fades back" cue intentional without being sluggish.
|
|
248
|
+
const SLOT_FADE_IN_MS = 120;
|
|
249
|
+
const SLOT_FADE_OUT_MS = 220;
|
|
11
250
|
function OTP({
|
|
12
251
|
length = 6,
|
|
13
252
|
value: controlledValue,
|
|
@@ -22,20 +261,25 @@ function OTP({
|
|
|
22
261
|
modes: propModes = EMPTY_MODES,
|
|
23
262
|
style,
|
|
24
263
|
supportText,
|
|
25
|
-
supportTextStatus
|
|
264
|
+
supportTextStatus,
|
|
265
|
+
errorMessage,
|
|
266
|
+
resend,
|
|
267
|
+
enableSmsAutofill = true
|
|
26
268
|
}) {
|
|
27
269
|
const {
|
|
28
270
|
modes: globalModes
|
|
29
271
|
} = useTokens();
|
|
30
|
-
const modes = {
|
|
272
|
+
const modes = useMemo(() => ({
|
|
31
273
|
...globalModes,
|
|
32
274
|
...propModes
|
|
33
|
-
};
|
|
275
|
+
}), [globalModes, propModes]);
|
|
34
276
|
const isControlled = controlledValue !== undefined;
|
|
35
277
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
36
278
|
const currentValue = isControlled ? controlledValue : internalValue;
|
|
37
279
|
const inputRef = useRef(null);
|
|
38
280
|
const [isFocused, setIsFocused] = useState(false);
|
|
281
|
+
|
|
282
|
+
// --- Caret blink (unchanged) ---
|
|
39
283
|
const caretAnim = useRef(new Animated.Value(1)).current;
|
|
40
284
|
useEffect(() => {
|
|
41
285
|
if (!isFocused) return;
|
|
@@ -81,7 +325,6 @@ function OTP({
|
|
|
81
325
|
const otpPaddingV = Number(getVariableByName('otp/padding/vertical', modes)) || 8;
|
|
82
326
|
const slotWidth = Number(getVariableByName('pinSlot/width', modes)) || 48;
|
|
83
327
|
const slotGap = Number(getVariableByName('pinSlot/gap', modes)) || 8;
|
|
84
|
-
// digit/color has no state variants in Figma — resolved once from the Output collection
|
|
85
328
|
const digitColor = getVariableByName('pinSlot/digit/color', modes) || '#000000';
|
|
86
329
|
const digitFontSize = Number(getVariableByName('pinSlot/digit/fontSize', modes)) || 24;
|
|
87
330
|
const digitFontFamily = getVariableByName('pinSlot/digit/fontFamily', modes) || 'JioType Var';
|
|
@@ -90,25 +333,68 @@ function OTP({
|
|
|
90
333
|
const underlineHeight = Number(getVariableByName('pinSlot/underline/height', modes)) || 2;
|
|
91
334
|
const underlineRadius = Number(getVariableByName('pinSlot/underline/radius', modes)) || 1;
|
|
92
335
|
|
|
93
|
-
// ---
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
336
|
+
// --- Resolve the three underline colors ONCE per render. We then
|
|
337
|
+
// animate between idle ↔ active per-slot using interpolation; the
|
|
338
|
+
// error color is applied directly (no animation) when isInvalid.
|
|
339
|
+
const idleUnderlineColor = useMemo(() => getVariableByName('PinSlot/underline/color', {
|
|
340
|
+
...modes,
|
|
341
|
+
'Input/PINSlot States': 'Idle'
|
|
342
|
+
}) || '#303338', [modes]);
|
|
343
|
+
const activeUnderlineColor = useMemo(() => getVariableByName('PinSlot/underline/color', {
|
|
344
|
+
...modes,
|
|
345
|
+
'Input/PINSlot States': 'Active'
|
|
346
|
+
}) || '#5d00b5', [modes]);
|
|
347
|
+
const errorUnderlineColor = useMemo(() => getVariableByName('PinSlot/underline/color', {
|
|
348
|
+
...modes,
|
|
349
|
+
'Input/PINSlot States': 'Error'
|
|
350
|
+
}) || '#d92d20', [modes]);
|
|
351
|
+
|
|
352
|
+
// --- Per-slot underline highlight animations ---
|
|
353
|
+
//
|
|
354
|
+
// Each slot owns one Animated.Value in [0, 1]. 0 = Idle color, 1 =
|
|
355
|
+
// Active color. We re-target on every value/focus change:
|
|
356
|
+
//
|
|
357
|
+
// highlighted = isFilled || (isActiveSlot && isFocused)
|
|
358
|
+
//
|
|
359
|
+
// Filled slots stay lit, so adding a digit instantly recruits its
|
|
360
|
+
// slot into the lit cohort. Deleting a digit transitions that slot
|
|
361
|
+
// (which is no longer the active slot — the cursor moved back) from
|
|
362
|
+
// 1 → 0, producing the "fades back" cue the design calls for.
|
|
363
|
+
//
|
|
364
|
+
// We use useNativeDriver:false because backgroundColor cannot run on
|
|
365
|
+
// the native driver (it's a JS-thread layout property). The overhead
|
|
366
|
+
// is fine here: at most `length` (≤ ~8) values transitioning briefly
|
|
367
|
+
// on each keystroke.
|
|
368
|
+
const slotAnimsRef = useRef([]);
|
|
369
|
+
if (slotAnimsRef.current.length !== length) {
|
|
370
|
+
const next = [];
|
|
371
|
+
for (let i = 0; i < length; i++) {
|
|
372
|
+
const existing = slotAnimsRef.current[i];
|
|
373
|
+
// Initialize fresh slots to match their *current* highlight
|
|
374
|
+
// target. This avoids a flash on first mount when consumers
|
|
375
|
+
// pass a non-empty defaultValue (slots would otherwise fade
|
|
376
|
+
// in from 0 even though they're already filled).
|
|
377
|
+
const initial = i < currentValue.length ? 1 : 0;
|
|
378
|
+
next.push(existing ?? new Animated.Value(initial));
|
|
379
|
+
}
|
|
380
|
+
slotAnimsRef.current = next;
|
|
381
|
+
}
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
const anims = slotAnimsRef.current;
|
|
384
|
+
const filledLen = currentValue.length;
|
|
385
|
+
for (let i = 0; i < length; i++) {
|
|
386
|
+
const slotAnim = anims[i];
|
|
387
|
+
if (!slotAnim) continue;
|
|
388
|
+
const isFilled = i < filledLen;
|
|
389
|
+
const isActiveSlot = i === filledLen && filledLen < length && isFocused;
|
|
390
|
+
const target = isFilled || isActiveSlot ? 1 : 0;
|
|
391
|
+
Animated.timing(slotAnim, {
|
|
392
|
+
toValue: target,
|
|
393
|
+
duration: target === 1 ? SLOT_FADE_IN_MS : SLOT_FADE_OUT_MS,
|
|
394
|
+
useNativeDriver: false
|
|
395
|
+
}).start();
|
|
396
|
+
}
|
|
397
|
+
}, [currentValue, isFocused, length]);
|
|
112
398
|
|
|
113
399
|
// --- Styles ---
|
|
114
400
|
const containerStyle = {
|
|
@@ -128,12 +414,6 @@ function OTP({
|
|
|
128
414
|
const char = currentValue[index];
|
|
129
415
|
const isActiveSlot = index === currentValue.length && currentValue.length < length;
|
|
130
416
|
const isFilled = char !== undefined;
|
|
131
|
-
|
|
132
|
-
// Underline color is the only state-sensitive token (lives in "Input/PINSlot States" collection).
|
|
133
|
-
// Note: token name is "PinSlot/underline/color" (capital P/S) — different from the static
|
|
134
|
-
// "pinSlot/underline/color" in the Output collection.
|
|
135
|
-
const slotModes = getSlotModes(isActiveSlot);
|
|
136
|
-
const underlineColor = getVariableByName('PinSlot/underline/color', slotModes) || '#303338';
|
|
137
417
|
const slotStyle = {
|
|
138
418
|
width: slotWidth,
|
|
139
419
|
flexDirection: 'column',
|
|
@@ -150,11 +430,22 @@ function OTP({
|
|
|
150
430
|
textAlign: 'center',
|
|
151
431
|
minWidth: '100%'
|
|
152
432
|
};
|
|
433
|
+
|
|
434
|
+
// Pull the per-slot animated value (always exists by this point —
|
|
435
|
+
// we resync the ref array above before render).
|
|
436
|
+
const slotAnim = slotAnimsRef.current[index] ?? new Animated.Value(0);
|
|
437
|
+
const interpolatedColor = slotAnim.interpolate({
|
|
438
|
+
inputRange: [0, 1],
|
|
439
|
+
outputRange: [idleUnderlineColor, activeUnderlineColor]
|
|
440
|
+
});
|
|
153
441
|
const underlineStyle = {
|
|
154
442
|
width: slotWidth,
|
|
155
443
|
height: underlineHeight,
|
|
156
444
|
borderRadius: underlineRadius,
|
|
157
|
-
|
|
445
|
+
// Error state takes precedence and snaps directly to the
|
|
446
|
+
// error color — animating *to* an error feels less urgent
|
|
447
|
+
// than the snap, and the design uses an instant transition.
|
|
448
|
+
backgroundColor: isInvalid ? errorUnderlineColor : interpolatedColor
|
|
158
449
|
};
|
|
159
450
|
return /*#__PURE__*/_jsxs(View, {
|
|
160
451
|
style: slotStyle,
|
|
@@ -181,24 +472,67 @@ function OTP({
|
|
|
181
472
|
}],
|
|
182
473
|
children: '\u00A0'
|
|
183
474
|
})
|
|
184
|
-
}), /*#__PURE__*/_jsx(View, {
|
|
475
|
+
}), /*#__PURE__*/_jsx(Animated.View, {
|
|
185
476
|
style: underlineStyle
|
|
186
477
|
})]
|
|
187
478
|
}, index);
|
|
188
479
|
};
|
|
189
|
-
|
|
480
|
+
|
|
481
|
+
// --- Support area rendering ---
|
|
482
|
+
//
|
|
483
|
+
// Priority:
|
|
484
|
+
// 1. isInvalid → errorMessage (with Error status). Falls back to
|
|
485
|
+
// `supportText` if errorMessage isn't provided, promoting its
|
|
486
|
+
// status to Error.
|
|
487
|
+
// 2. resend (and !isInvalid) → managed countdown / button.
|
|
488
|
+
// 3. supportText → user's static support text.
|
|
489
|
+
// 4. nothing.
|
|
490
|
+
//
|
|
491
|
+
// This split keeps validation a parent concern (the component never
|
|
492
|
+
// tries to "know" what valid means) while still giving consumers a
|
|
493
|
+
// turnkey error UI when they flip `isInvalid`.
|
|
494
|
+
//
|
|
495
|
+
// While `isInvalid` is true we also inject `Status: 'Error'` into the
|
|
496
|
+
// mode set forwarded to the support area. This lets the SupportText
|
|
497
|
+
// (and any nested icon) resolve error-themed Figma tokens — foreground
|
|
498
|
+
// color, icon color, etc. — without consumers having to thread the
|
|
499
|
+
// collection mode in by hand.
|
|
500
|
+
const supportModes = useMemo(() => isInvalid ? {
|
|
501
|
+
...modes,
|
|
502
|
+
Status: 'Error'
|
|
503
|
+
} : modes, [modes, isInvalid]);
|
|
504
|
+
const renderStaticSupportText = overrideStatus => {
|
|
190
505
|
if (!supportText) return null;
|
|
191
506
|
if (typeof supportText === 'string') {
|
|
192
507
|
return /*#__PURE__*/_jsx(SupportText, {
|
|
193
508
|
label: supportText,
|
|
194
|
-
status:
|
|
195
|
-
modes:
|
|
509
|
+
status: overrideStatus ?? supportTextStatus ?? 'Neutral',
|
|
510
|
+
modes: supportModes
|
|
196
511
|
});
|
|
197
512
|
}
|
|
198
513
|
return /*#__PURE__*/_jsx(_Fragment, {
|
|
199
|
-
children: cloneChildrenWithModes(React.Children.toArray(supportText),
|
|
514
|
+
children: cloneChildrenWithModes(React.Children.toArray(supportText), supportModes)
|
|
200
515
|
});
|
|
201
516
|
};
|
|
517
|
+
const renderSupportArea = () => {
|
|
518
|
+
if (isInvalid) {
|
|
519
|
+
if (errorMessage) {
|
|
520
|
+
return /*#__PURE__*/_jsx(SupportText, {
|
|
521
|
+
label: errorMessage,
|
|
522
|
+
status: "Error",
|
|
523
|
+
modes: supportModes
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return renderStaticSupportText('Error');
|
|
527
|
+
}
|
|
528
|
+
if (resend) {
|
|
529
|
+
return /*#__PURE__*/_jsx(OTPResend, {
|
|
530
|
+
...resend,
|
|
531
|
+
modes: supportModes
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return renderStaticSupportText();
|
|
535
|
+
};
|
|
202
536
|
return /*#__PURE__*/_jsxs(Pressable, {
|
|
203
537
|
style: [containerStyle, isDisabled && {
|
|
204
538
|
opacity: 0.4
|
|
@@ -216,7 +550,16 @@ function OTP({
|
|
|
216
550
|
editable: !isDisabled,
|
|
217
551
|
onFocus: () => setIsFocused(true),
|
|
218
552
|
onBlur: () => setIsFocused(false),
|
|
219
|
-
caretHidden: true
|
|
553
|
+
caretHidden: true
|
|
554
|
+
// Cross-platform native one-time-code autofill. iOS reads
|
|
555
|
+
// `textContentType="oneTimeCode"` to surface SMS codes in
|
|
556
|
+
// the QuickType bar (no library needed). Android reads
|
|
557
|
+
// `autoComplete="one-time-code"` (the canonical RN value;
|
|
558
|
+
// also accepted as a hint by the SMS Retriever / SMS User
|
|
559
|
+
// Consent APIs that the host app wires up natively).
|
|
560
|
+
,
|
|
561
|
+
textContentType: enableSmsAutofill ? 'oneTimeCode' : 'none',
|
|
562
|
+
autoComplete: enableSmsAutofill ? 'one-time-code' : 'off',
|
|
220
563
|
style: {
|
|
221
564
|
position: 'absolute',
|
|
222
565
|
width: 1,
|
|
@@ -230,7 +573,7 @@ function OTP({
|
|
|
230
573
|
children: Array.from({
|
|
231
574
|
length
|
|
232
575
|
}, (_, i) => renderSlot(i))
|
|
233
|
-
}),
|
|
576
|
+
}), renderSupportArea()]
|
|
234
577
|
});
|
|
235
578
|
}
|
|
236
579
|
export default OTP;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { View, Text } from 'react-native';
|
|
5
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
|
|
6
|
+
import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
|
|
7
|
+
import Image from '../Image/Image';
|
|
8
|
+
import ProductLabel from '../ProductLabel/ProductLabel';
|
|
9
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
10
|
+
const DEFAULT_STATS = [{
|
|
11
|
+
value: '995',
|
|
12
|
+
label: 'Purity'
|
|
13
|
+
}, {
|
|
14
|
+
value: '3%',
|
|
15
|
+
label: 'GST'
|
|
16
|
+
}];
|
|
17
|
+
const ProductOverview = ({
|
|
18
|
+
imageSource,
|
|
19
|
+
imageRatio = 288 / 170,
|
|
20
|
+
labelImageSource,
|
|
21
|
+
label = 'Gold',
|
|
22
|
+
productName = '0.5g Gold Coin',
|
|
23
|
+
description = 'Your gold is insured from our vault to you. If lost or damaged, we’ll replace it.',
|
|
24
|
+
stats = DEFAULT_STATS,
|
|
25
|
+
modes = EMPTY_MODES,
|
|
26
|
+
style,
|
|
27
|
+
children
|
|
28
|
+
}) => {
|
|
29
|
+
const padding = getVariableByName('productOverview/padding', modes) ?? 24;
|
|
30
|
+
const gap = getVariableByName('productOverview/gap', modes) ?? 12;
|
|
31
|
+
const background = getVariableByName('productOverview/background', modes) ?? '#ffffff';
|
|
32
|
+
const productNameColor = getVariableByName('productOverview/productName/color', modes) ?? '#0d0d0f';
|
|
33
|
+
const productNameFontFamily = getVariableByName('productOverview/productName/fontFamily', modes) ?? 'JioType Var';
|
|
34
|
+
const productNameFontSize = getVariableByName('productOverview/productName/fontSize', modes) ?? 26;
|
|
35
|
+
const productNameFontWeight = getVariableByName('productOverview/productName/fontWeight', modes) ?? 900;
|
|
36
|
+
const productNameLineHeight = getVariableByName('productOverview/productName/lineHeight', modes) ?? 26;
|
|
37
|
+
const descriptionColor = getVariableByName('productOverview/description/color', modes) ?? '#1a1c1f';
|
|
38
|
+
const descriptionFontFamily = getVariableByName('productOverview/description/fontFamily', modes) ?? 'JioType Var';
|
|
39
|
+
const descriptionFontSize = getVariableByName('productOverview/description/fontSize', modes) ?? 14;
|
|
40
|
+
const descriptionFontWeight = getVariableByName('productOverview/description/fontWeight', modes) ?? 500;
|
|
41
|
+
const descriptionLineHeight = getVariableByName('productOverview/description/lineHeight', modes) ?? 18.2;
|
|
42
|
+
const statGap = getVariableByName('productOverview/stat/gap', modes) ?? 2;
|
|
43
|
+
const statValueColor = getVariableByName('productOverview/stat/value/color', modes) ?? '#141414';
|
|
44
|
+
const statValueFontFamily = getVariableByName('productOverview/stat/value/fontFamily', modes) ?? 'JioType Var';
|
|
45
|
+
const statValueFontSize = getVariableByName('productOverview/stat/value/fontSize', modes) ?? 20;
|
|
46
|
+
const statValueFontWeight = getVariableByName('productOverview/stat/value/fontWeight', modes) ?? 900;
|
|
47
|
+
const statValueLineHeight = getVariableByName('productOverview/stat/value/lineHeight', modes) ?? 20;
|
|
48
|
+
const statLabelColor = productNameColor;
|
|
49
|
+
const statLabelFontFamily = getVariableByName('productOverview/stat/label/fontFamily', modes) ?? 'JioType Var';
|
|
50
|
+
const statLabelFontSize = getVariableByName('productOverview/stat/label/fontSize', modes) ?? 12;
|
|
51
|
+
const statLabelFontWeight = getVariableByName('productOverview/stat/label/fontWeight', modes) ?? 400;
|
|
52
|
+
const statLabelLineHeight = getVariableByName('productOverview/stat/label/lineHeight', modes) ?? 15.6;
|
|
53
|
+
const productNameStyle = {
|
|
54
|
+
color: productNameColor,
|
|
55
|
+
fontFamily: productNameFontFamily,
|
|
56
|
+
fontSize: productNameFontSize,
|
|
57
|
+
fontWeight: String(productNameFontWeight),
|
|
58
|
+
lineHeight: productNameLineHeight,
|
|
59
|
+
textAlign: 'center'
|
|
60
|
+
};
|
|
61
|
+
const descriptionStyle = {
|
|
62
|
+
color: descriptionColor,
|
|
63
|
+
fontFamily: descriptionFontFamily,
|
|
64
|
+
fontSize: descriptionFontSize,
|
|
65
|
+
fontWeight: String(descriptionFontWeight),
|
|
66
|
+
lineHeight: descriptionLineHeight,
|
|
67
|
+
textAlign: 'center'
|
|
68
|
+
};
|
|
69
|
+
const statValueStyle = {
|
|
70
|
+
color: statValueColor,
|
|
71
|
+
fontFamily: statValueFontFamily,
|
|
72
|
+
fontSize: statValueFontSize,
|
|
73
|
+
fontWeight: String(statValueFontWeight),
|
|
74
|
+
lineHeight: statValueLineHeight
|
|
75
|
+
};
|
|
76
|
+
const statLabelStyle = {
|
|
77
|
+
color: statLabelColor,
|
|
78
|
+
fontFamily: statLabelFontFamily,
|
|
79
|
+
fontSize: statLabelFontSize,
|
|
80
|
+
fontWeight: String(statLabelFontWeight),
|
|
81
|
+
lineHeight: statLabelLineHeight,
|
|
82
|
+
textAlign: 'center'
|
|
83
|
+
};
|
|
84
|
+
const showStats = Array.isArray(stats) && stats.length > 0;
|
|
85
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
86
|
+
style: [{
|
|
87
|
+
backgroundColor: background,
|
|
88
|
+
padding,
|
|
89
|
+
gap,
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
width: '100%'
|
|
92
|
+
}, style],
|
|
93
|
+
children: [imageSource != null && /*#__PURE__*/_jsx(Image, {
|
|
94
|
+
imageSource: imageSource,
|
|
95
|
+
ratio: imageRatio,
|
|
96
|
+
resizeMode: "contain",
|
|
97
|
+
accessibilityElementsHidden: true,
|
|
98
|
+
importantForAccessibility: "no"
|
|
99
|
+
}), /*#__PURE__*/_jsx(ProductLabel, {
|
|
100
|
+
label: label,
|
|
101
|
+
...(labelImageSource != null && {
|
|
102
|
+
imageSource: labelImageSource
|
|
103
|
+
}),
|
|
104
|
+
modes: modes
|
|
105
|
+
}), productName ? /*#__PURE__*/_jsx(Text, {
|
|
106
|
+
style: productNameStyle,
|
|
107
|
+
accessibilityRole: "header",
|
|
108
|
+
children: productName
|
|
109
|
+
}) : null, description ? /*#__PURE__*/_jsx(Text, {
|
|
110
|
+
style: descriptionStyle,
|
|
111
|
+
children: description
|
|
112
|
+
}) : null, children ? /*#__PURE__*/_jsx(_Fragment, {
|
|
113
|
+
children: cloneChildrenWithModes(children, modes)
|
|
114
|
+
}) : null, showStats && /*#__PURE__*/_jsx(View, {
|
|
115
|
+
style: {
|
|
116
|
+
flexDirection: 'row',
|
|
117
|
+
alignItems: 'center',
|
|
118
|
+
justifyContent: 'space-between',
|
|
119
|
+
width: '100%'
|
|
120
|
+
},
|
|
121
|
+
children: stats.map((stat, index) => /*#__PURE__*/_jsxs(View, {
|
|
122
|
+
style: {
|
|
123
|
+
flex: 1,
|
|
124
|
+
minWidth: 0,
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
gap: statGap,
|
|
127
|
+
overflow: 'hidden'
|
|
128
|
+
},
|
|
129
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
130
|
+
style: statValueStyle,
|
|
131
|
+
numberOfLines: 1,
|
|
132
|
+
children: stat.value
|
|
133
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
134
|
+
style: statLabelStyle,
|
|
135
|
+
numberOfLines: 1,
|
|
136
|
+
children: stat.label
|
|
137
|
+
})]
|
|
138
|
+
}, `${stat.label}-${index}`))
|
|
139
|
+
})]
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
export default ProductOverview;
|