@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.
Files changed (54) hide show
  1. package/.storybook/main.ts +12 -0
  2. package/.storybook/preview.tsx +16 -0
  3. package/package.json +40 -0
  4. package/pnpm-workspace.yaml +2 -0
  5. package/postcss.config.js +6 -0
  6. package/react-shim.js +2 -0
  7. package/src/components/BottomSheet.tsx +113 -0
  8. package/src/components/Button.tsx +92 -0
  9. package/src/components/Card.tsx +102 -0
  10. package/src/components/Collapse.tsx +177 -0
  11. package/src/components/DatePicker.tsx +334 -0
  12. package/src/components/Drawer.tsx +63 -0
  13. package/src/components/Header.tsx +58 -0
  14. package/src/components/Modal.tsx +127 -0
  15. package/src/components/OTPInput.tsx +178 -0
  16. package/src/components/SearchBar.tsx +141 -0
  17. package/src/components/Tabs.tsx +121 -0
  18. package/src/components/TimePicker.tsx +271 -0
  19. package/src/components/Toast.tsx +133 -0
  20. package/src/components/VirtualList.tsx +81 -0
  21. package/src/index.css +3 -0
  22. package/src/index.ts +14 -0
  23. package/src/stories/Button.stories.tsx +76 -0
  24. package/src/stories/Card.stories.tsx +77 -0
  25. package/src/stories/Collapse.stories.tsx +56 -0
  26. package/src/stories/DatePicker.stories.tsx +69 -0
  27. package/src/stories/Modal.stories.tsx +73 -0
  28. package/src/stories/OTPInput.stories.tsx +73 -0
  29. package/src/stories/SearchBar.stories.tsx +78 -0
  30. package/src/stories/Tabs.stories.tsx +66 -0
  31. package/src/stories/TimePicker.stories.tsx +77 -0
  32. package/src/stories/VirtualList.stories.tsx +76 -0
  33. package/src/stories/assets/accessibility.png +0 -0
  34. package/src/stories/assets/accessibility.svg +1 -0
  35. package/src/stories/assets/addon-library.png +0 -0
  36. package/src/stories/assets/assets.png +0 -0
  37. package/src/stories/assets/avif-test-image.avif +0 -0
  38. package/src/stories/assets/context.png +0 -0
  39. package/src/stories/assets/discord.svg +1 -0
  40. package/src/stories/assets/docs.png +0 -0
  41. package/src/stories/assets/figma-plugin.png +0 -0
  42. package/src/stories/assets/github.svg +1 -0
  43. package/src/stories/assets/share.png +0 -0
  44. package/src/stories/assets/styling.png +0 -0
  45. package/src/stories/assets/testing.png +0 -0
  46. package/src/stories/assets/theming.png +0 -0
  47. package/src/stories/assets/tutorials.svg +1 -0
  48. package/src/stories/assets/youtube.svg +1 -0
  49. package/src/vite-env.d.ts +4 -0
  50. package/tailwind.config.js +10 -0
  51. package/tsconfig.json +16 -0
  52. package/tsup.config.ts +15 -0
  53. package/vitest.config.ts +36 -0
  54. package/vitest.shims.d.ts +1 -0
@@ -0,0 +1,271 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { BottomSheet } from "./BottomSheet";
3
+
4
+ // ================================
5
+ // Types
6
+ // ================================
7
+ interface TimePickerProps {
8
+ value?: { hour: number; minute: number };
9
+ onChange?: (time: { hour: number; minute: number }) => void;
10
+ minuteStep?: 1 | 5 | 10 | 15 | 30;
11
+ locale?: "vi" | "en";
12
+ label?: string;
13
+ placeholder?: string;
14
+ minHour?: number;
15
+ maxHour?: number;
16
+ }
17
+
18
+ interface WheelColumnProps {
19
+ items: { label: string; value: number }[];
20
+ selectedIndex: number;
21
+ onChange: (index: number) => void;
22
+ align?: "left" | "right" | "center";
23
+ }
24
+
25
+ // ================================
26
+ // Wheel Column (Giao diện chuẩn iOS)
27
+ // ================================
28
+ function WheelColumn({
29
+ items,
30
+ selectedIndex,
31
+ onChange,
32
+ align = "center",
33
+ }: WheelColumnProps) {
34
+ const containerRef = useRef<HTMLDivElement>(null);
35
+ const ITEM_HEIGHT = 44; // Chiều cao chuẩn xác để tạo độ khít như iOS
36
+
37
+ useEffect(() => {
38
+ const container = containerRef.current;
39
+ if (!container) return;
40
+ container.scrollTop = selectedIndex * ITEM_HEIGHT;
41
+ }, [selectedIndex]);
42
+
43
+ const handleScroll = useCallback(() => {
44
+ const container = containerRef.current;
45
+ if (!container) return;
46
+ const index = Math.round(container.scrollTop / ITEM_HEIGHT);
47
+ const clamped = Math.max(0, Math.min(index, items.length - 1));
48
+ if (clamped !== selectedIndex) onChange(clamped);
49
+ }, [items.length, selectedIndex, onChange]);
50
+
51
+ const alignStyles = {
52
+ left: "justify-start pl-8",
53
+ right: "justify-end pr-8",
54
+ center: "justify-center",
55
+ };
56
+
57
+ return (
58
+ <div
59
+ className="relative flex-1 h-full z-10"
60
+ onTouchStart={(e) => e.stopPropagation()}
61
+ onTouchMove={(e) => e.stopPropagation()}
62
+ onTouchEnd={(e) => e.stopPropagation()}
63
+ >
64
+ <div
65
+ ref={containerRef}
66
+ onScroll={handleScroll}
67
+ className="h-full w-full overflow-y-scroll snap-y snap-mandatory overscroll-contain [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] scrollbar-none"
68
+ style={{ touchAction: "pan-y" }}
69
+ >
70
+ {/* Khoảng trống để có thể cuộn item đầu/cuối vào giữa */}
71
+ <div style={{ height: ITEM_HEIGHT * 2 }} />
72
+
73
+ {items.map((item, i) => {
74
+ // Tính toán hiệu ứng 3D giả lập dựa trên khoảng cách tới trung tâm
75
+ const distance = Math.abs(i - selectedIndex);
76
+ const isSelected = distance === 0;
77
+ const opacity = isSelected ? 1 : Math.max(0.3, 1 - distance * 0.25);
78
+ const scale = isSelected ? 1 : Math.max(0.85, 1 - distance * 0.08);
79
+
80
+ return (
81
+ <div
82
+ key={item.value}
83
+ className={`flex items-center snap-center cursor-pointer select-none transition-all duration-150 ${alignStyles[align]}`}
84
+ style={{
85
+ height: ITEM_HEIGHT,
86
+ opacity,
87
+ transform: `scale(${scale})`,
88
+ transformOrigin:
89
+ align === "right"
90
+ ? "right center"
91
+ : align === "left"
92
+ ? "left center"
93
+ : "center center",
94
+ }}
95
+ onClick={() => {
96
+ onChange(i);
97
+ containerRef.current!.scrollTo({
98
+ top: i * ITEM_HEIGHT,
99
+ behavior: "smooth",
100
+ });
101
+ }}
102
+ >
103
+ <span
104
+ className={`${isSelected ? "text-gray-900 font-semibold" : "text-gray-600 font-medium"}`}
105
+ style={{ fontSize: 24 }}
106
+ >
107
+ {item.label}
108
+ </span>
109
+ </div>
110
+ );
111
+ })}
112
+
113
+ <div style={{ height: ITEM_HEIGHT * 2 }} />
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ // ================================
120
+ // Helpers
121
+ // ================================
122
+ function generateHours(min = 0, max = 23) {
123
+ return Array.from({ length: max - min + 1 }, (_, i) => ({
124
+ label: String(min + i).padStart(2, "0"),
125
+ value: min + i,
126
+ }));
127
+ }
128
+
129
+ function generateMinutes(step: number) {
130
+ const items = [];
131
+ for (let m = 0; m < 60; m += step) {
132
+ items.push({ label: String(m).padStart(2, "0"), value: m });
133
+ }
134
+ return items;
135
+ }
136
+
137
+ function formatTime(hour: number, minute: number, locale: "vi" | "en") {
138
+ if (locale === "en") {
139
+ const period = hour >= 12 ? "PM" : "AM";
140
+ const h = hour % 12 || 12;
141
+ return `${String(h).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${period}`;
142
+ }
143
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
144
+ }
145
+
146
+ // ================================
147
+ // TimePicker
148
+ // ================================
149
+ export function TimePicker({
150
+ value,
151
+ onChange,
152
+ minuteStep = 1, // Để step=1 cho giống báo thức thực tế
153
+ locale = "vi",
154
+ label,
155
+ placeholder,
156
+ minHour = 0,
157
+ maxHour = 23,
158
+ }: TimePickerProps) {
159
+ const [isOpen, setIsOpen] = useState(false);
160
+
161
+ const hours = generateHours(minHour, maxHour);
162
+ const minutes = generateMinutes(minuteStep);
163
+
164
+ const defaultHour = value?.hour ?? new Date().getHours();
165
+ const defaultMinute = value?.minute ?? 0;
166
+
167
+ const [hourIndex, setHourIndex] = useState(
168
+ Math.max(
169
+ 0,
170
+ hours.findIndex((h) => h.value === defaultHour),
171
+ ),
172
+ );
173
+ const [minuteIndex, setMinuteIndex] = useState(
174
+ Math.max(
175
+ 0,
176
+ minutes.findIndex((m) => m.value === defaultMinute),
177
+ ),
178
+ );
179
+
180
+ const selectedHour = hours[hourIndex]?.value ?? defaultHour;
181
+ const selectedMinute = minutes[minuteIndex]?.value ?? 0;
182
+
183
+ function handleConfirm() {
184
+ onChange?.({ hour: selectedHour, minute: selectedMinute });
185
+ setIsOpen(false);
186
+ }
187
+
188
+ return (
189
+ <>
190
+ <div className="flex flex-col gap-1.5">
191
+ {label && (
192
+ <label className="text-sm font-medium text-gray-700">{label}</label>
193
+ )}
194
+ <button
195
+ onClick={() => setIsOpen(true)}
196
+ className={[
197
+ "flex items-center justify-between w-full px-4 py-3 rounded-xl border transition-all bg-white text-left",
198
+ value
199
+ ? "border-indigo-300 text-gray-900"
200
+ : "border-gray-200 text-gray-400",
201
+ "hover:border-indigo-400 focus:outline-none focus:border-indigo-500",
202
+ ].join(" ")}
203
+ >
204
+ <span className="text-sm">
205
+ {value
206
+ ? formatTime(value.hour, value.minute, locale)
207
+ : (placeholder ?? (locale === "vi" ? "Chọn giờ" : "Select time"))}
208
+ </span>
209
+ <svg
210
+ width="18"
211
+ height="18"
212
+ viewBox="0 0 24 24"
213
+ fill="none"
214
+ stroke="currentColor"
215
+ strokeWidth="2"
216
+ className="text-gray-400 shrink-0"
217
+ >
218
+ <circle cx="12" cy="12" r="10" />
219
+ <polyline points="12 6 12 12 16 14" />
220
+ </svg>
221
+ </button>
222
+ </div>
223
+
224
+ <BottomSheet
225
+ isOpen={isOpen}
226
+ onClose={() => setIsOpen(false)}
227
+ title={locale === "vi" ? "Chọn giờ" : "Select time"}
228
+ >
229
+ <div className="flex flex-col gap-6 pt-2">
230
+ {/* ĐÃ SỬA: Thay h-55 thành h-[220px] hoặc h-56 */}
231
+ <div className="relative h-[220px] mx-4 rounded-xl overflow-hidden bg-white">
232
+ <div className="absolute top-1/2 left-0 right-0 -translate-y-1/2 h-11 bg-gray-100/80 rounded-lg pointer-events-none" />
233
+
234
+ <div className="absolute inset-0 flex items-center justify-center">
235
+ <WheelColumn
236
+ items={hours}
237
+ selectedIndex={hourIndex}
238
+ onChange={setHourIndex}
239
+ align="right"
240
+ />
241
+
242
+ <div className="w-10 flex items-center justify-center pb-1 z-10 select-none pointer-events-none">
243
+ <span className="text-2xl font-bold text-gray-900 animate-pulse">
244
+ :
245
+ </span>
246
+ </div>
247
+
248
+ <WheelColumn
249
+ items={minutes}
250
+ selectedIndex={minuteIndex}
251
+ onChange={setMinuteIndex}
252
+ align="left"
253
+ />
254
+ </div>
255
+
256
+ {/* ĐÃ SỬA: Thay h-22 thành h-[88px] hoặc h-24 */}
257
+ <div className="absolute top-0 left-0 right-0 h-[88px] bg-gradient-to-b from-white via-white/80 to-transparent pointer-events-none z-20" />
258
+ <div className="absolute bottom-0 left-0 right-0 h-[88px] bg-gradient-to-t from-white via-white/80 to-transparent pointer-events-none z-20" />
259
+ </div>
260
+
261
+ <button
262
+ onClick={handleConfirm}
263
+ className="mx-4 mb-2 py-4 bg-indigo-600 text-white text-[17px] font-semibold rounded-2xl hover:bg-indigo-700 active:bg-indigo-800 active:scale-95 transition-all"
264
+ >
265
+ {locale === "vi" ? "Xong" : "Done"}
266
+ </button>
267
+ </div>
268
+ </BottomSheet>
269
+ </>
270
+ );
271
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ useEffect,
3
+ useState,
4
+ createContext,
5
+ useContext,
6
+ ReactNode,
7
+ useCallback,
8
+ } from "react";
9
+ import { motion, AnimatePresence } from "framer-motion";
10
+
11
+ // ================================
12
+ // Types & Context (Giữ nguyên của bạn)
13
+ // ================================
14
+ type ToastType = "success" | "error" | "warning" | "info";
15
+
16
+ interface ToastItem {
17
+ id: string;
18
+ message: string;
19
+ type: ToastType;
20
+ duration?: number;
21
+ }
22
+
23
+ interface ToastContextType {
24
+ show: (message: string, type?: ToastType, duration?: number) => void;
25
+ success: (message: string) => void;
26
+ error: (message: string) => void;
27
+ warning: (message: string) => void;
28
+ info: (message: string) => void;
29
+ }
30
+
31
+ const ToastContext = createContext<ToastContextType | null>(null);
32
+
33
+ export function useToast() {
34
+ const ctx = useContext(ToastContext);
35
+ if (!ctx) throw new Error("useToast phải dùng trong ToastProvider!");
36
+ return ctx;
37
+ }
38
+
39
+ const toastStyles: Record<ToastType, { bg: string; icon: string }> = {
40
+ success: { bg: "bg-green-500", icon: "✅" },
41
+ error: { bg: "bg-red-500", icon: "❌" },
42
+ warning: { bg: "bg-amber-500", icon: "⚠️" },
43
+ info: { bg: "bg-blue-500", icon: "ℹ️" },
44
+ };
45
+
46
+ // ================================
47
+ // Single Toast component (Đã gỡ logic dư thừa)
48
+ // ================================
49
+ function ToastMessage({
50
+ toast,
51
+ onRemove,
52
+ }: {
53
+ toast: ToastItem;
54
+ onRemove: (id: string) => void;
55
+ }) {
56
+ const { bg, icon } = toastStyles[toast.type];
57
+
58
+ // Chỉ lo việc đếm ngược thời gian, animation để Framer lo
59
+ useEffect(() => {
60
+ const timer = setTimeout(() => {
61
+ onRemove(toast.id);
62
+ }, toast.duration ?? 3000);
63
+ return () => clearTimeout(timer);
64
+ }, [toast, onRemove]);
65
+
66
+ return (
67
+ <motion.div
68
+ layout // Giúp các Toast tự động trượt lên lấp chỗ trống khi có 1 cái biến mất
69
+ initial={{ opacity: 0, y: 30, scale: 0.9 }}
70
+ animate={{ opacity: 1, y: 0, scale: 1 }}
71
+ exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.2 } }}
72
+ transition={{ type: "spring", damping: 20, stiffness: 300 }}
73
+ className={`flex items-center gap-3 px-4 py-3 rounded-2xl shadow-lg text-white text-sm font-medium ${bg}`}
74
+ style={{ minWidth: 240, maxWidth: 320 }}
75
+ >
76
+ <span>{icon}</span>
77
+ <span className="flex-1">{toast.message}</span>
78
+ <button
79
+ onClick={() => onRemove(toast.id)}
80
+ className="opacity-70 hover:opacity-100 transition-opacity"
81
+ >
82
+
83
+ </button>
84
+ </motion.div>
85
+ );
86
+ }
87
+
88
+ // ================================
89
+ // Toast Provider
90
+ // ================================
91
+ export function ToastProvider({ children }: { children: ReactNode }) {
92
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
93
+
94
+ const remove = useCallback((id: string) => {
95
+ setToasts((prev) => prev.filter((t) => t.id !== id));
96
+ }, []);
97
+
98
+ const show = useCallback(
99
+ (message: string, type: ToastType = "info", duration = 3000) => {
100
+ const id = Math.random().toString(36).slice(2);
101
+ setToasts((prev) => [...prev, { id, message, type, duration }]);
102
+ },
103
+ [],
104
+ );
105
+
106
+ const success = useCallback((msg: string) => show(msg, "success"), [show]);
107
+ const error = useCallback((msg: string) => show(msg, "error"), [show]);
108
+ const warning = useCallback((msg: string) => show(msg, "warning"), [show]);
109
+ const info = useCallback((msg: string) => show(msg, "info"), [show]);
110
+
111
+ return (
112
+ <ToastContext.Provider value={{ show, success, error, warning, info }}>
113
+ {children}
114
+
115
+ <div
116
+ className="fixed left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 items-center pointer-events-none"
117
+ style={{
118
+ bottom:
119
+ "calc(var(--ejsc-safe-bottom, env(safe-area-inset-bottom, 0px)) + 16px)",
120
+ }}
121
+ >
122
+ <AnimatePresence mode="popLayout">
123
+ {toasts.map((toast) => (
124
+ // Thêm pointer-events-auto vào từng toast để bắt click được nút ✕
125
+ <div key={toast.id} className="pointer-events-auto">
126
+ <ToastMessage toast={toast} onRemove={remove} />
127
+ </div>
128
+ ))}
129
+ </AnimatePresence>
130
+ </div>
131
+ </ToastContext.Provider>
132
+ );
133
+ }
@@ -0,0 +1,81 @@
1
+ import { useRef, useState, useMemo, ReactNode, UIEvent } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ interface VirtualListProps<T> {
7
+ items: T[];
8
+ itemHeight: number;
9
+ renderItem: (item: T, index: number) => ReactNode;
10
+ containerHeight?: number | string;
11
+ overscan?: number;
12
+ className?: string;
13
+ }
14
+
15
+ export function VirtualList<T>({
16
+ items,
17
+ itemHeight,
18
+ renderItem,
19
+ containerHeight = "100%",
20
+ overscan = 3, // Render dư ra 3 item phía trên và dưới để chống chớp trắng khi scroll nhanh
21
+ className = "",
22
+ }: VirtualListProps<T>) {
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+ const [scrollTop, setScrollTop] = useState(0);
25
+
26
+ // Tổng chiều cao giả lập của toàn bộ list (VD: 5000 items * 80px = 400,000px)
27
+ const totalHeight = items.length * itemHeight;
28
+
29
+ // Cập nhật vị trí cuộn mỗi khi user vuốt
30
+ const handleScroll = (e: UIEvent<HTMLDivElement>) => {
31
+ // Dùng requestAnimationFrame nếu muốn tối ưu cực độ, nhưng với React 18+ auto-batching thì setState thẳng ở đây vẫn rất mượt
32
+ setScrollTop(e.currentTarget.scrollTop);
33
+ };
34
+
35
+ // Tính toán chỉ số của các item cần render
36
+ const { startIndex, endIndex } = useMemo(() => {
37
+ const start = Math.floor(scrollTop / itemHeight);
38
+
39
+ // Nếu chưa render DOM (clientHeight = 0), lấy tạm một giá trị ước lượng cho màn hình mobile (VD: 800px)
40
+ const clientHeight = containerRef.current?.clientHeight || 800;
41
+ const visibleNodeCount = Math.ceil(clientHeight / itemHeight);
42
+
43
+ const end = Math.min(items.length - 1, start + visibleNodeCount);
44
+
45
+ return { startIndex: start, endIndex: end };
46
+ }, [scrollTop, itemHeight, items.length]);
47
+
48
+ // Cộng trừ thêm vùng overscan an toàn
49
+ const renderStart = Math.max(0, startIndex - overscan);
50
+ const renderEnd = Math.min(items.length - 1, endIndex + overscan);
51
+
52
+ // Cắt mảng data gốc lấy đúng đoạn cần thiết
53
+ const visibleItems = items.slice(renderStart, renderEnd + 1);
54
+
55
+ // Tính độ dời (offset) để đẩy đoạn DOM này xuống đúng vị trí trên thanh cuộn
56
+ const offsetY = renderStart * itemHeight;
57
+
58
+ return (
59
+ <div
60
+ ref={containerRef}
61
+ onScroll={handleScroll}
62
+ className={`overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] [&::-webkit-scrollbar]:hidden ${className}`}
63
+ style={{ height: containerHeight }}
64
+ >
65
+ {/* Container giả lập chiều cao tổng */}
66
+ <div style={{ height: totalHeight, position: "relative" }}>
67
+ {/* Khối chứa các phần tử thực sự được render, dùng translateY đẩy xuống để tăng tốc độ GPU */}
68
+ <div style={{ transform: `translateY(${offsetY}px)` }}>
69
+ {visibleItems.map((item, index) => {
70
+ const actualIndex = renderStart + index;
71
+ return (
72
+ <div key={actualIndex} style={{ height: itemHeight }}>
73
+ {renderItem(item, actualIndex)}
74
+ </div>
75
+ );
76
+ })}
77
+ </div>
78
+ </div>
79
+ </div>
80
+ );
81
+ }
package/src/index.css ADDED
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { Button } from "./components/Button";
2
+ export { Header } from "./components/Header";
3
+ export { BottomSheet } from "./components/BottomSheet";
4
+ export { ToastProvider, useToast } from "./components/Toast";
5
+ export { DatePicker } from "./components/DatePicker";
6
+ export { TimePicker } from "./components/TimePicker";
7
+ export { OTPInput } from "./components/OTPInput";
8
+ export { Card } from "./components/Card";
9
+ export { VirtualList } from "./components/VirtualList";
10
+ export { SearchBar } from "./components/SearchBar";
11
+ export { Modal } from './components/Modal'
12
+ export { Tabs } from './components/Tabs'
13
+ export { Collapse } from './components/Collapse'
14
+ export { Drawer } from './components/Drawer'
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "../components/Button";
3
+
4
+ const meta: Meta<typeof Button> = {
5
+ title: "mini-app-component/Button",
6
+ component: Button,
7
+ tags: ["autodocs"],
8
+ parameters: { layout: "padded" },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof Button>;
13
+
14
+ export const Variants: Story = {
15
+ render: () => (
16
+ <div
17
+ style={{ width: 320, display: "flex", flexDirection: "column", gap: 12 }}
18
+ >
19
+ <Button variant="primary" onPress={() => alert("Primary tapped")}>
20
+ Primary Button
21
+ </Button>
22
+ <Button variant="secondary" onPress={() => alert("Secondary tapped")}>
23
+ Secondary Button
24
+ </Button>
25
+ <Button variant="danger" onPress={() => alert("Danger tapped")}>
26
+ Danger Button
27
+ </Button>
28
+ <Button variant="ghost" onPress={() => alert("Ghost tapped")}>
29
+ Ghost Button
30
+ </Button>
31
+ </div>
32
+ ),
33
+ };
34
+
35
+ export const Sizes: Story = {
36
+ render: () => (
37
+ <div
38
+ style={{
39
+ width: 320,
40
+ display: "flex",
41
+ alignItems: "center",
42
+ gap: 12,
43
+ flexWrap: "wrap",
44
+ }}
45
+ >
46
+ <Button size="sm">Small (32px)</Button>
47
+ <Button size="md">Medium (44px)</Button>
48
+ <Button size="lg">Large (52px)</Button>
49
+ </div>
50
+ ),
51
+ };
52
+
53
+ export const States: Story = {
54
+ render: () => (
55
+ <div
56
+ style={{ width: 320, display: "flex", flexDirection: "column", gap: 12 }}
57
+ >
58
+ <Button loading variant="primary">
59
+ Đang tải dữ liệu
60
+ </Button>
61
+ <Button disabled variant="primary">
62
+ Không thể tương tác
63
+ </Button>
64
+ </div>
65
+ ),
66
+ };
67
+
68
+ export const FullWidth: Story = {
69
+ render: () => (
70
+ <div style={{ width: 320 }}>
71
+ <Button fullWidth variant="primary" size="lg">
72
+ Thanh toán ngay
73
+ </Button>
74
+ </div>
75
+ ),
76
+ };
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Card } from "../components/Card";
3
+
4
+ const meta: Meta<typeof Card> = {
5
+ title: "mini-app-component/Card",
6
+ component: Card,
7
+ tags: ["autodocs"],
8
+ parameters: { layout: "padded" },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof Card>;
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <div style={{ width: 320 }}>
17
+ <Card>
18
+ <Card.Header>
19
+ <h3 className="font-semibold text-gray-900">Chi tiết đơn hàng</h3>
20
+ <span className="text-xs text-indigo-600 bg-indigo-50 px-2 py-1 rounded-md">
21
+ Mới
22
+ </span>
23
+ </Card.Header>
24
+ <Card.Body>
25
+ Đơn hàng #12345 đã được xác nhận và đang chờ giao cho đơn vị vận
26
+ chuyển.
27
+ </Card.Body>
28
+ <Card.Footer>
29
+ <span className="text-xs text-gray-500">Cập nhật 2 phút trước</span>
30
+ </Card.Footer>
31
+ </Card>
32
+ </div>
33
+ ),
34
+ };
35
+
36
+ export const Pressable: Story = {
37
+ render: () => (
38
+ <div
39
+ style={{ width: 320, display: "flex", flexDirection: "column", gap: 12 }}
40
+ >
41
+ <Card isPressable onClick={() => alert("Đã chọn thanh toán MoMo!")}>
42
+ <Card.Body className="flex items-center gap-3">
43
+ <div className="w-8 h-8 bg-pink-500 rounded-lg flex items-center justify-center text-white font-bold text-xs">
44
+ M
45
+ </div>
46
+ <span className="font-medium text-gray-900">Thanh toán qua MoMo</span>
47
+ </Card.Body>
48
+ </Card>
49
+
50
+ <Card isPressable onClick={() => alert("Đã chọn thanh toán ZaloPay!")}>
51
+ <Card.Body className="flex items-center gap-3">
52
+ <div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-xs">
53
+ Z
54
+ </div>
55
+ <span className="font-medium text-gray-900">
56
+ Thanh toán qua ZaloPay
57
+ </span>
58
+ </Card.Body>
59
+ </Card>
60
+ </div>
61
+ ),
62
+ };
63
+
64
+ export const Disabled: Story = {
65
+ render: () => (
66
+ <div style={{ width: 320 }}>
67
+ <Card disabled isPressable>
68
+ <Card.Body className="flex items-center justify-between">
69
+ <span className="font-medium text-gray-900">Voucher giảm 50K</span>
70
+ <span className="text-xs text-red-500 bg-red-50 px-2 py-1 rounded-md">
71
+ Đã hết hạn
72
+ </span>
73
+ </Card.Body>
74
+ </Card>
75
+ </div>
76
+ ),
77
+ };