@baodev/mini-app-component 1.0.1
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/.storybook/main.ts +12 -0
- package/.storybook/preview.tsx +16 -0
- package/package.json +40 -0
- package/pnpm-workspace.yaml +2 -0
- package/postcss.config.js +6 -0
- package/react-shim.js +2 -0
- package/src/components/BottomSheet.tsx +113 -0
- package/src/components/Button.tsx +92 -0
- package/src/components/Card.tsx +102 -0
- package/src/components/Collapse.tsx +177 -0
- package/src/components/DatePicker.tsx +334 -0
- package/src/components/Drawer.tsx +63 -0
- package/src/components/Header.tsx +58 -0
- package/src/components/Modal.tsx +127 -0
- package/src/components/OTPInput.tsx +178 -0
- package/src/components/SearchBar.tsx +141 -0
- package/src/components/Tabs.tsx +121 -0
- package/src/components/TimePicker.tsx +271 -0
- package/src/components/Toast.tsx +133 -0
- package/src/components/VirtualList.tsx +81 -0
- package/src/index.css +3 -0
- package/src/index.ts +14 -0
- package/src/stories/Button.stories.tsx +76 -0
- package/src/stories/Card.stories.tsx +77 -0
- package/src/stories/Collapse.stories.tsx +56 -0
- package/src/stories/DatePicker.stories.tsx +69 -0
- package/src/stories/Modal.stories.tsx +73 -0
- package/src/stories/OTPInput.stories.tsx +73 -0
- package/src/stories/SearchBar.stories.tsx +78 -0
- package/src/stories/Tabs.stories.tsx +66 -0
- package/src/stories/TimePicker.stories.tsx +77 -0
- package/src/stories/VirtualList.stories.tsx +76 -0
- package/src/stories/assets/accessibility.png +0 -0
- package/src/stories/assets/accessibility.svg +1 -0
- package/src/stories/assets/addon-library.png +0 -0
- package/src/stories/assets/assets.png +0 -0
- package/src/stories/assets/avif-test-image.avif +0 -0
- package/src/stories/assets/context.png +0 -0
- package/src/stories/assets/discord.svg +1 -0
- package/src/stories/assets/docs.png +0 -0
- package/src/stories/assets/figma-plugin.png +0 -0
- package/src/stories/assets/github.svg +1 -0
- package/src/stories/assets/share.png +0 -0
- package/src/stories/assets/styling.png +0 -0
- package/src/stories/assets/testing.png +0 -0
- package/src/stories/assets/theming.png +0 -0
- package/src/stories/assets/tutorials.svg +1 -0
- package/src/stories/assets/youtube.svg +1 -0
- package/src/vite-env.d.ts +4 -0
- package/tailwind.config.js +10 -0
- package/tsconfig.json +16 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +36 -0
- package/vitest.shims.d.ts +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useRef, useState, KeyboardEvent, ClipboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
// ================================
|
|
4
|
+
// Types
|
|
5
|
+
// ================================
|
|
6
|
+
interface OTPInputProps {
|
|
7
|
+
length?: number;
|
|
8
|
+
onComplete?: (otp: string) => void;
|
|
9
|
+
onChange?: (otp: string) => void;
|
|
10
|
+
label?: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function OTPInput({
|
|
17
|
+
length = 6,
|
|
18
|
+
onComplete,
|
|
19
|
+
onChange,
|
|
20
|
+
label,
|
|
21
|
+
hint,
|
|
22
|
+
error,
|
|
23
|
+
disabled = false,
|
|
24
|
+
}: OTPInputProps) {
|
|
25
|
+
const [values, setValues] = useState<string[]>(Array(length).fill(""));
|
|
26
|
+
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
27
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
28
|
+
|
|
29
|
+
function updateValues(newValues: string[]) {
|
|
30
|
+
setValues(newValues);
|
|
31
|
+
const otp = newValues.join("");
|
|
32
|
+
onChange?.(otp);
|
|
33
|
+
if (otp.length === length && newValues.every(Boolean)) {
|
|
34
|
+
onComplete?.(otp);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleChange(index: number, val: string) {
|
|
39
|
+
// Chỉ nhận số
|
|
40
|
+
const digit = val.replace(/\D/g, "").slice(-1);
|
|
41
|
+
const newValues = [...values];
|
|
42
|
+
newValues[index] = digit;
|
|
43
|
+
updateValues(newValues);
|
|
44
|
+
|
|
45
|
+
// Auto focus next
|
|
46
|
+
if (digit && index < length - 1) {
|
|
47
|
+
inputRefs.current[index + 1]?.focus();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
|
|
52
|
+
if (e.key === "Backspace") {
|
|
53
|
+
if (values[index]) {
|
|
54
|
+
const newValues = [...values];
|
|
55
|
+
newValues[index] = "";
|
|
56
|
+
updateValues(newValues);
|
|
57
|
+
} else if (index > 0) {
|
|
58
|
+
inputRefs.current[index - 1]?.focus();
|
|
59
|
+
const newValues = [...values];
|
|
60
|
+
newValues[index - 1] = "";
|
|
61
|
+
updateValues(newValues);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (e.key === "ArrowLeft" && index > 0) {
|
|
66
|
+
inputRefs.current[index - 1]?.focus();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (e.key === "ArrowRight" && index < length - 1) {
|
|
70
|
+
inputRefs.current[index + 1]?.focus();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
const pasted = e.clipboardData
|
|
77
|
+
.getData("text")
|
|
78
|
+
.replace(/\D/g, "")
|
|
79
|
+
.slice(0, length);
|
|
80
|
+
if (!pasted) return;
|
|
81
|
+
|
|
82
|
+
const newValues = Array(length).fill("");
|
|
83
|
+
pasted.split("").forEach((char, i) => {
|
|
84
|
+
newValues[i] = char;
|
|
85
|
+
});
|
|
86
|
+
updateValues(newValues);
|
|
87
|
+
|
|
88
|
+
// Focus last filled or next empty
|
|
89
|
+
const lastIndex = Math.min(pasted.length, length - 1);
|
|
90
|
+
inputRefs.current[lastIndex]?.focus();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function handleFocus(index: number) {
|
|
94
|
+
setActiveIndex(index);
|
|
95
|
+
// Select content khi focus
|
|
96
|
+
inputRefs.current[index]?.select();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleBlur() {
|
|
100
|
+
setActiveIndex(-1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isComplete = values.every(Boolean);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex flex-col gap-3">
|
|
107
|
+
{/* Label */}
|
|
108
|
+
{label && (
|
|
109
|
+
<label className="text-sm font-medium text-gray-700">{label}</label>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* OTP inputs */}
|
|
113
|
+
<div className="flex gap-3 justify-center">
|
|
114
|
+
{Array(length)
|
|
115
|
+
.fill(null)
|
|
116
|
+
.map((_, index) => (
|
|
117
|
+
<input
|
|
118
|
+
key={index}
|
|
119
|
+
ref={(el) => {
|
|
120
|
+
inputRefs.current[index] = el;
|
|
121
|
+
}}
|
|
122
|
+
type="text"
|
|
123
|
+
inputMode="numeric"
|
|
124
|
+
maxLength={1}
|
|
125
|
+
value={values[index]}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
onChange={(e) => handleChange(index, e.target.value)}
|
|
128
|
+
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
129
|
+
onPaste={handlePaste}
|
|
130
|
+
onFocus={() => handleFocus(index)}
|
|
131
|
+
onBlur={handleBlur}
|
|
132
|
+
className={[
|
|
133
|
+
"w-12 h-14 text-center text-xl font-bold rounded-2xl border-2 outline-none",
|
|
134
|
+
"transition-all duration-150 select-none",
|
|
135
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
136
|
+
error
|
|
137
|
+
? "border-red-400 bg-red-50 text-red-600"
|
|
138
|
+
: activeIndex === index
|
|
139
|
+
? "border-indigo-500 bg-indigo-50 text-indigo-700 scale-105 shadow-md shadow-indigo-100"
|
|
140
|
+
: values[index]
|
|
141
|
+
? "border-indigo-300 bg-white text-gray-900"
|
|
142
|
+
: "border-gray-200 bg-gray-50 text-gray-900",
|
|
143
|
+
].join(" ")}
|
|
144
|
+
/>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Success state */}
|
|
149
|
+
{isComplete && !error && (
|
|
150
|
+
<div className="flex items-center justify-center gap-1.5">
|
|
151
|
+
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
|
|
152
|
+
<svg
|
|
153
|
+
width="10"
|
|
154
|
+
height="10"
|
|
155
|
+
viewBox="0 0 24 24"
|
|
156
|
+
fill="none"
|
|
157
|
+
stroke="white"
|
|
158
|
+
strokeWidth="3"
|
|
159
|
+
>
|
|
160
|
+
<polyline points="20 6 9 17 4 12" />
|
|
161
|
+
</svg>
|
|
162
|
+
</div>
|
|
163
|
+
<span className="text-sm text-green-600 font-medium">
|
|
164
|
+
Xác nhận thành công
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{/* Error */}
|
|
170
|
+
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
|
171
|
+
|
|
172
|
+
{/* Hint */}
|
|
173
|
+
{hint && !error && (
|
|
174
|
+
<p className="text-xs text-gray-400 text-center">{hint}</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useRef, useState, InputHTMLAttributes, KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
// ================================
|
|
4
|
+
// Types
|
|
5
|
+
// ================================
|
|
6
|
+
interface SearchBarProps extends Omit<
|
|
7
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
8
|
+
"onChange"
|
|
9
|
+
> {
|
|
10
|
+
value?: string;
|
|
11
|
+
onChange?: (val: string) => void;
|
|
12
|
+
onSearch?: (val: string) => void;
|
|
13
|
+
onClear?: () => void;
|
|
14
|
+
onCancel?: () => void;
|
|
15
|
+
showCancel?: boolean;
|
|
16
|
+
cancelText?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SearchBar({
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
onSearch,
|
|
23
|
+
onClear,
|
|
24
|
+
onCancel,
|
|
25
|
+
showCancel = false,
|
|
26
|
+
cancelText = "Hủy",
|
|
27
|
+
placeholder = "Tìm kiếm...",
|
|
28
|
+
className = "",
|
|
29
|
+
disabled = false,
|
|
30
|
+
...props
|
|
31
|
+
}: SearchBarProps) {
|
|
32
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
33
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
34
|
+
|
|
35
|
+
// Fallback internal state nếu không truyền value từ ngoài vào
|
|
36
|
+
const [internalValue, setInternalValue] = useState("");
|
|
37
|
+
const displayValue = value !== undefined ? value : internalValue;
|
|
38
|
+
|
|
39
|
+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
40
|
+
const val = e.target.value;
|
|
41
|
+
setInternalValue(val);
|
|
42
|
+
onChange?.(val);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleClear() {
|
|
46
|
+
setInternalValue("");
|
|
47
|
+
onChange?.("");
|
|
48
|
+
onClear?.();
|
|
49
|
+
// Giữ focus lại vào input sau khi clear để bàn phím không bị thụt xuống
|
|
50
|
+
inputRef.current?.focus();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
54
|
+
if (e.key === "Enter") {
|
|
55
|
+
// Ẩn bàn phím khi submit search trên mobile
|
|
56
|
+
inputRef.current?.blur();
|
|
57
|
+
onSearch?.(displayValue);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isShowCancel = showCancel || (isFocused && onCancel);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className={`flex items-center gap-3 w-full ${className}`}>
|
|
65
|
+
{/* Khung Search */}
|
|
66
|
+
<div
|
|
67
|
+
className={[
|
|
68
|
+
"flex-1 flex items-center h-10 px-3 rounded-xl transition-colors duration-200",
|
|
69
|
+
disabled
|
|
70
|
+
? "bg-gray-100 opacity-60"
|
|
71
|
+
: "bg-gray-100 focus-within:bg-gray-200/60",
|
|
72
|
+
].join(" ")}
|
|
73
|
+
>
|
|
74
|
+
{/* Icon Search */}
|
|
75
|
+
<svg
|
|
76
|
+
width="18"
|
|
77
|
+
height="18"
|
|
78
|
+
viewBox="0 0 24 24"
|
|
79
|
+
fill="none"
|
|
80
|
+
stroke="currentColor"
|
|
81
|
+
strokeWidth="2.5"
|
|
82
|
+
className="text-gray-400 flex-shrink-0"
|
|
83
|
+
>
|
|
84
|
+
<circle cx="11" cy="11" r="8" />
|
|
85
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
86
|
+
</svg>
|
|
87
|
+
|
|
88
|
+
{/* Input */}
|
|
89
|
+
<input
|
|
90
|
+
ref={inputRef}
|
|
91
|
+
type="search"
|
|
92
|
+
enterKeyHint="search"
|
|
93
|
+
value={displayValue}
|
|
94
|
+
onChange={handleChange}
|
|
95
|
+
onKeyDown={handleKeyDown}
|
|
96
|
+
onFocus={() => setIsFocused(true)}
|
|
97
|
+
onBlur={() => setIsFocused(false)}
|
|
98
|
+
placeholder={placeholder}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
className={[
|
|
101
|
+
"flex-1 w-full bg-transparent border-none outline-none px-2 text-[15px] text-gray-900 placeholder:text-gray-500",
|
|
102
|
+
// Ẩn nút X mặc định cực xấu của Webkit
|
|
103
|
+
"[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden",
|
|
104
|
+
].join(" ")}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
{/* Nút Clear (x) */}
|
|
109
|
+
{displayValue.length > 0 && !disabled && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={handleClear}
|
|
113
|
+
className="w-5 h-5 flex items-center justify-center rounded-full bg-gray-300 hover:bg-gray-400 active:bg-gray-500 text-white transition-colors flex-shrink-0"
|
|
114
|
+
>
|
|
115
|
+
<svg
|
|
116
|
+
width="12"
|
|
117
|
+
height="12"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
fill="none"
|
|
120
|
+
stroke="currentColor"
|
|
121
|
+
strokeWidth="3"
|
|
122
|
+
>
|
|
123
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
124
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
125
|
+
</svg>
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Nút Cancel (Thường dùng kiểu iOS) */}
|
|
131
|
+
{isShowCancel && (
|
|
132
|
+
<button
|
|
133
|
+
onClick={onCancel}
|
|
134
|
+
className="text-[15px] font-medium text-indigo-600 hover:text-indigo-700 active:text-indigo-800 transition-colors animate-in slide-in-from-right-4 fade-in duration-200"
|
|
135
|
+
>
|
|
136
|
+
{cancelText}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ReactNode, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// ================================
|
|
4
|
+
// Types
|
|
5
|
+
// ================================
|
|
6
|
+
interface TabItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
badge?: number;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TabsProps {
|
|
15
|
+
items: TabItem[];
|
|
16
|
+
defaultActive?: string;
|
|
17
|
+
onChange?: (id: string) => void;
|
|
18
|
+
variant?: "line" | "pill" | "card";
|
|
19
|
+
children?: (activeId: string) => ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ================================
|
|
23
|
+
// Tabs
|
|
24
|
+
// ================================
|
|
25
|
+
export function Tabs({
|
|
26
|
+
items,
|
|
27
|
+
defaultActive,
|
|
28
|
+
onChange,
|
|
29
|
+
variant = "line",
|
|
30
|
+
children,
|
|
31
|
+
}: TabsProps) {
|
|
32
|
+
const [activeId, setActiveId] = useState(defaultActive ?? items[0]?.id);
|
|
33
|
+
|
|
34
|
+
function handleChange(id: string) {
|
|
35
|
+
setActiveId(id);
|
|
36
|
+
onChange?.(id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex flex-col w-full">
|
|
41
|
+
{/* Tab bar */}
|
|
42
|
+
<div
|
|
43
|
+
className={[
|
|
44
|
+
"flex w-full",
|
|
45
|
+
variant === "line" ? "border-b border-gray-200" : "",
|
|
46
|
+
variant === "pill" ? "bg-gray-100 rounded-xl p-1 gap-1" : "",
|
|
47
|
+
variant === "card" ? "gap-2" : "",
|
|
48
|
+
].join(" ")}
|
|
49
|
+
>
|
|
50
|
+
{items.map((item) => (
|
|
51
|
+
<TabButton
|
|
52
|
+
key={item.id}
|
|
53
|
+
item={item}
|
|
54
|
+
isActive={activeId === item.id}
|
|
55
|
+
variant={variant}
|
|
56
|
+
onClick={() => !item.disabled && handleChange(item.id)}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Content */}
|
|
62
|
+
{children && <div className="flex-1 mt-4">{children(activeId)}</div>}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ================================
|
|
68
|
+
// Tab Button
|
|
69
|
+
// ================================
|
|
70
|
+
interface TabButtonProps {
|
|
71
|
+
item: TabItem;
|
|
72
|
+
isActive: boolean;
|
|
73
|
+
variant: "line" | "pill" | "card";
|
|
74
|
+
onClick: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function TabButton({ item, isActive, variant, onClick }: TabButtonProps) {
|
|
78
|
+
const base =
|
|
79
|
+
"flex items-center justify-center gap-1.5 transition-all duration-200 select-none relative";
|
|
80
|
+
|
|
81
|
+
const styles = {
|
|
82
|
+
line: [
|
|
83
|
+
base,
|
|
84
|
+
"flex-1 py-3 text-sm font-medium border-b-2 -mb-px",
|
|
85
|
+
isActive
|
|
86
|
+
? "border-indigo-600 text-indigo-600"
|
|
87
|
+
: "border-transparent text-gray-500 hover:text-gray-700",
|
|
88
|
+
item.disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
|
|
89
|
+
].join(" "),
|
|
90
|
+
|
|
91
|
+
pill: [
|
|
92
|
+
base,
|
|
93
|
+
"flex-1 py-2 px-3 text-sm font-medium rounded-lg",
|
|
94
|
+
isActive
|
|
95
|
+
? "bg-white text-indigo-600 shadow-sm"
|
|
96
|
+
: "text-gray-500 hover:text-gray-700",
|
|
97
|
+
item.disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
|
|
98
|
+
].join(" "),
|
|
99
|
+
|
|
100
|
+
card: [
|
|
101
|
+
base,
|
|
102
|
+
"flex-1 py-3 px-4 text-sm font-medium rounded-xl border-2",
|
|
103
|
+
isActive
|
|
104
|
+
? "border-indigo-600 bg-indigo-50 text-indigo-600"
|
|
105
|
+
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300",
|
|
106
|
+
item.disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
|
|
107
|
+
].join(" "),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<button className={styles[variant]} onClick={onClick}>
|
|
112
|
+
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
|
113
|
+
<span>{item.label}</span>
|
|
114
|
+
{item.badge !== undefined && item.badge > 0 && (
|
|
115
|
+
<span className="ml-1 min-w-[18px] h-[18px] flex items-center justify-center bg-red-500 text-white text-xs font-bold rounded-full px-1">
|
|
116
|
+
{item.badge > 99 ? "99+" : item.badge}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
</button>
|
|
120
|
+
);
|
|
121
|
+
}
|