chore: release [ci skip]

This commit is contained in:
gitea-actions
2026-04-06 15:19:02 +00:00
parent fd57a35820
commit 78f3d6bbe9
27 changed files with 811 additions and 367 deletions
+31 -12
View File
@@ -1,9 +1,7 @@
# Phase 1 Implementation Summary: Atomic Design Foundation # Phase 1 Implementation Summary: Atomic Design Foundation
**Status:** ✅ COMPLETE **Status:** ✅ COMPLETE **Date:** 2026-04-03 **Commit:** eca619b **Branch:**
**Date:** 2026-04-03 atomic_design
**Commit:** eca619b
**Branch:** atomic_design
--- ---
@@ -12,6 +10,7 @@
### ✨ Created 21 Atom Components ### ✨ Created 21 Atom Components
#### Buttons (2 components) #### Buttons (2 components)
``` ```
components/atoms/buttons/ components/atoms/buttons/
├── Button.tsx (4 variants: primary, secondary, danger, ghost) ├── Button.tsx (4 variants: primary, secondary, danger, ghost)
@@ -20,12 +19,14 @@ components/atoms/buttons/
``` ```
**Key Features:** **Key Features:**
- Multiple size options (sm, md, lg) - Multiple size options (sm, md, lg)
- Icon support with positioning (left/right) - Icon support with positioning (left/right)
- Full HTML button attribute support - Full HTML button attribute support
- Active/disabled states with visual feedback - Active/disabled states with visual feedback
#### Inputs (3 components) #### Inputs (3 components)
``` ```
components/atoms/inputs/ components/atoms/inputs/
├── TextInput.tsx (with label, error, icon support) ├── TextInput.tsx (with label, error, icon support)
@@ -35,12 +36,14 @@ components/atoms/inputs/
``` ```
**Key Features:** **Key Features:**
- Error state handling with red border - Error state handling with red border
- Icon integration with callback support - Icon integration with callback support
- Accessible form field structure - Accessible form field structure
- Controlled/uncontrolled patterns - Controlled/uncontrolled patterns
#### Typography (3 components) #### Typography (3 components)
``` ```
components/atoms/typography/ components/atoms/typography/
├── Heading.tsx (h1-h6 with semantic sizing) ├── Heading.tsx (h1-h6 with semantic sizing)
@@ -50,11 +53,13 @@ components/atoms/typography/
``` ```
**Key Features:** **Key Features:**
- Semantic HTML (`<h1>` through `<h6>`) - Semantic HTML (`<h1>` through `<h6>`)
- Consistent font sizing and weights - Consistent font sizing and weights
- CSS variable color application - CSS variable color application
#### Badges (2 components) #### Badges (2 components)
``` ```
components/atoms/badges/ components/atoms/badges/
├── Badge.tsx (5 variants: primary, secondary, success, danger, warning) ├── Badge.tsx (5 variants: primary, secondary, success, danger, warning)
@@ -63,11 +68,13 @@ components/atoms/badges/
``` ```
**Key Features:** **Key Features:**
- Multiple size options (sm, md) - Multiple size options (sm, md)
- Formatted price output (VND/USD) - Formatted price output (VND/USD)
- Color-coded variants - Color-coded variants
#### Dividers (1 component) #### Dividers (1 component)
``` ```
components/atoms/dividers/ components/atoms/dividers/
├── Divider.tsx (horizontal/vertical separators) ├── Divider.tsx (horizontal/vertical separators)
@@ -108,12 +115,14 @@ components/atoms/dividers/
### 🔧 Updated Existing Components ### 🔧 Updated Existing Components
#### CartProduct.tsx #### CartProduct.tsx
- ✅ Replaced inline button with `<Button>` atom - ✅ Replaced inline button with `<Button>` atom
- ✅ Replaced inline text with `<Text>` atom - ✅ Replaced inline text with `<Text>` atom
- ✅ Replaced inline caption with `<Caption>` atom - ✅ Replaced inline caption with `<Caption>` atom
- **Result:** Cleaner component, more maintainable - **Result:** Cleaner component, more maintainable
#### ReviewModal.tsx #### ReviewModal.tsx
- ✅ Replaced modal buttons with `<Button>` atoms - ✅ Replaced modal buttons with `<Button>` atoms
- ✅ Replaced heading with `<Heading>` atom - ✅ Replaced heading with `<Heading>` atom
- ✅ Replaced text with `<Text>` atom - ✅ Replaced text with `<Text>` atom
@@ -125,7 +134,7 @@ components/atoms/dividers/
## 📊 Statistics ## 📊 Statistics
| Metric | Count | | Metric | Count |
|--------|-------| | ------------------------- | --------------------- |
| **New Atoms Created** | 21 | | **New Atoms Created** | 21 |
| **Type Files** | 5 | | **Type Files** | 5 |
| **Total Component Files** | 26 | | **Total Component Files** | 26 |
@@ -174,6 +183,7 @@ components/atoms/
## ✅ Quality Assurance ## ✅ Quality Assurance
### Build ### Build
```bash ```bash
✓ Compiled successfully in 3.7s ✓ Compiled successfully in 3.7s
✓ npm run build: PASS ✓ npm run build: PASS
@@ -182,18 +192,21 @@ components/atoms/
``` ```
### Type Safety ### Type Safety
- ✅ Full TypeScript coverage - ✅ Full TypeScript coverage
- ✅ All props typed - ✅ All props typed
- ✅ No `any` types - ✅ No `any` types
- ✅ Interface exports for reuse - ✅ Interface exports for reuse
### Accessibility ### Accessibility
- ✅ Semantic HTML throughout - ✅ Semantic HTML throughout
- ✅ ARIA labels where needed - ✅ ARIA labels where needed
- ✅ Focus visible states - ✅ Focus visible states
- ✅ Keyboard navigation support - ✅ Keyboard navigation support
### Consistency ### Consistency
- ✅ CSS variables for theming - ✅ CSS variables for theming
- ✅ Consistent naming conventions - ✅ Consistent naming conventions
- ✅ Barrel exports for clean imports - ✅ Barrel exports for clean imports
@@ -204,27 +217,30 @@ components/atoms/
## 📖 Usage Examples ## 📖 Usage Examples
### Basic Button ### Basic Button
```tsx ```tsx
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
<Button variant="primary" size="sm" icon="fa-cart-plus"> <Button variant="primary" size="sm" icon="fa-cart-plus">
Mua Mua
</Button> </Button>;
``` ```
### Form Field ### Form Field
```tsx ```tsx
import { TextInput, Button } from "@/components/atoms"; import { Button, TextInput } from "@/components/atoms";
<TextInput <TextInput
label="Email" label="Email"
type="email" type="email"
placeholder="user@example.com" placeholder="user@example.com"
error={emailError} error={emailError}
/> />;
``` ```
### Typography ### Typography
```tsx ```tsx
import { Heading, Text, Caption } from "@/components/atoms"; import { Heading, Text, Caption } from "@/components/atoms";
@@ -234,6 +250,7 @@ import { Heading, Text, Caption } from "@/components/atoms";
``` ```
### Price Display ### Price Display
```tsx ```tsx
import { PriceBadge } from "@/components/atoms"; import { PriceBadge } from "@/components/atoms";
@@ -275,6 +292,7 @@ import { PriceBadge } from "@/components/atoms";
## 🚀 Next Steps (Phase 2) ## 🚀 Next Steps (Phase 2)
### Molecules to Create ### Molecules to Create
1. **ProductCard** - Image + Text + Badge + Button 1. **ProductCard** - Image + Text + Badge + Button
2. **FormField** - Label + Input + Error + Validation 2. **FormField** - Label + Input + Error + Validation
3. **SearchBar** - SearchInput + Button 3. **SearchBar** - SearchInput + Button
@@ -288,6 +306,7 @@ import { PriceBadge } from "@/components/atoms";
## 💡 Design Decisions ## 💡 Design Decisions
### Why Barrel Exports? ### Why Barrel Exports?
```tsx ```tsx
// ❌ Bad: Scattered imports // ❌ Bad: Scattered imports
import Button from "@/components/atoms/buttons/Button"; import Button from "@/components/atoms/buttons/Button";
@@ -298,12 +317,14 @@ import { Button, Text } from "@/components/atoms";
``` ```
### Why TypeScript Types in Separate Files? ### Why TypeScript Types in Separate Files?
- Clear separation of concerns - Clear separation of concerns
- Easier to maintain interfaces - Easier to maintain interfaces
- Reusable types across modules - Reusable types across modules
- Better for large components - Better for large components
### Why CSS Variables? ### Why CSS Variables?
- Single point for theme changes - Single point for theme changes
- Easy dark mode switching - Easy dark mode switching
- Consistent across all atoms - Consistent across all atoms
@@ -357,7 +378,5 @@ feat: Implement Atomic Design Phase 1 - Atoms Foundation
--- ---
**Status:** Ready for Phase 2 🚀 **Status:** Ready for Phase 2 🚀 **Last Updated:** 2026-04-03 **Contributor:**
**Last Updated:** 2026-04-03 Claude Code + Anthropic AI
**Contributor:** Claude Code + Anthropic AI
+14 -8
View File
@@ -67,13 +67,15 @@ Dành cho khách hàng:
Dành cho quản lý: Dành cho quản lý:
- Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới Analytics - Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới
Analytics
- CRUD sản phẩm, combo, danh mục qua modals - CRUD sản phẩm, combo, danh mục qua modals
- Auth guard: tự động redirect non-manager về `/` - Auth guard: tự động redirect non-manager về `/`
#### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`) #### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`)
- Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh kỳ trước) - Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh
kỳ trước)
- Bộ chọn kỳ: Ngày / Tuần / Tháng / Năm - Bộ chọn kỳ: Ngày / Tuần / Tháng / Năm
- Biểu đồ SVG thuần: Line, Bar, Pie — hover tooltips tương tác - Biểu đồ SVG thuần: Line, Bar, Pie — hover tooltips tương tác
- Bảng top 5 sản phẩm và bảng chi tiết có thể sắp xếp - Bảng top 5 sản phẩm và bảng chi tiết có thể sắp xếp
@@ -197,7 +199,7 @@ frondend/
## Công Nghệ Sử Dụng ## Công Nghệ Sử Dụng
| Công nghệ | Phiên bản | Mục đích | | Công nghệ | Phiên bản | Mục đích |
| ------------ | --------- | ------------------------------------- | | ---------------- | --------- | ------------------------------------- |
| Next.js | 16.1.7 | React Framework (App Router) | | Next.js | 16.1.7 | React Framework (App Router) |
| React | 19.2.3 | Thư viện UI | | React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh | | TypeScript | ^5 | Kiểu dữ liệu tĩnh |
@@ -226,7 +228,7 @@ frondend/
### Tài Khoản Demo ### Tài Khoản Demo
| Loại tài khoản | Tên đăng nhập | Mật khẩu | | Loại tài khoản | Tên đăng nhập | Mật khẩu |
| -------------- | -------------- | -------------- | | -------------- | ------------- | ------------- |
| Manager | admin | admin | | Manager | admin | admin |
| Staff | Nguyễn Văn An | Nguyễn Văn An | | Staff | Nguyễn Văn An | Nguyễn Văn An |
| Customer | 0987654321 | user1 | | Customer | 0987654321 | user1 |
@@ -243,14 +245,18 @@ frondend/
Dự án tuân theo **Atomic Design** pattern — chi tiết tại `Atomic.md`: Dự án tuân theo **Atomic Design** pattern — chi tiết tại `Atomic.md`:
- **Atoms** - Nguyên tố cơ bản: Button, Input, Badge, Text, Heading, Divider - **Atoms** - Nguyên tố cơ bản: Button, Input, Badge, Text, Heading, Divider
- **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar, PaymentSummaryCard - **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar,
- **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid, ShopGrid, ReviewModal, analytics charts, manager tabs/modals PaymentSummaryCard
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout, ManagerLayout - **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid,
ShopGrid, ReviewModal, analytics charts, manager tabs/modals
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout,
ManagerLayout
### Data & Integration ### Data & Integration
- Mock data nằm trong `lib/constants.ts` - Mock data nằm trong `lib/constants.ts`
- Context providers trong `app/providers.tsx`: AuthProvider, MenuProvider, CartProvider - Context providers trong `app/providers.tsx`: AuthProvider, MenuProvider,
CartProvider
- ManagerProvider được thêm bởi `app/(manager)/layout.tsx` - ManagerProvider được thêm bởi `app/(manager)/layout.tsx`
- Thay bằng API calls khi backend sẵn sàng - Thay bằng API calls khi backend sẵn sàng
- Ảnh sản phẩm: thêm vào `public/imgs/products/` - Ảnh sản phẩm: thêm vào `public/imgs/products/`
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { SHOP_INFO } from "@/lib/constants";
import LoginForm from "@/components/organisms/forms/LoginForm"; import LoginForm from "@/components/organisms/forms/LoginForm";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image"; import Image from "next/image";
export default function LoginPage() { export default function LoginPage() {
+3 -6
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation"; import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid"; import { ProductGrid } from "@/components/organisms/product-grid";
import { SearchBar } from "@/components/molecules/search-bar";
import { useMenu } from "@/lib/menu-context";
import { MENU_CATEGORIES } from "@/lib/constants"; import { MENU_CATEGORIES } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/** /**
@@ -72,10 +72,7 @@ export default function Home() {
</div> </div>
{/* ── Product grid (organism handles mobile category menu + grid) ── */} {/* ── Product grid (organism handles mobile category menu + grid) ── */}
<ProductGrid <ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
searchQuery={searchQuery}
isSidebarOpen={isSidebarOpen}
/>
</main> </main>
</div> </div>
); );
+2 -2
View File
@@ -75,7 +75,7 @@ export default function PaymentPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => decreaseQty(item.id)} onClick={() => decreaseQty(item.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Giảm số lượng ${item.name}`} aria-label={`Giảm số lượng ${item.name}`}
> >
- -
@@ -92,7 +92,7 @@ export default function PaymentPage() {
/> />
<button <button
onClick={() => increaseQty(item.id)} onClick={() => increaseQty(item.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Tăng số lượng ${item.name}`} aria-label={`Tăng số lượng ${item.name}`}
> >
+ +
+19 -4
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import Button from "@/components/atoms/buttons/Button";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants"; import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import Button from "@/components/atoms/buttons/Button";
// Static OTP for demo (in production, this would be sent via SMS) // Static OTP for demo (in production, this would be sent via SMS)
const DEMO_OTP = "123456"; const DEMO_OTP = "123456";
@@ -161,7 +161,12 @@ export default function RegisterPage() {
{/* Buttons */} {/* Buttons */}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{/* Submit Button */} {/* Submit Button */}
<Button variant="primaryNoBorder" type="submit" style="login" size="lg"> <Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
>
Tiếp tục Tiếp tục
</Button> </Button>
@@ -227,12 +232,22 @@ export default function RegisterPage() {
{/* Buttons */} {/* Buttons */}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{/* Submit Button */} {/* Submit Button */}
<Button variant="primaryNoBorder" type="submit" style="login" size="lg"> <Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
>
Hoàn tất đăng Hoàn tất đăng
</Button> </Button>
{/* Back Button */} {/* Back Button */}
<Button variant="bgWhite" onClick={handleBackToPhone} size="lg" style="login"> <Button
variant="bgWhite"
onClick={handleBackToPhone}
size="lg"
style="login"
>
Thay đi số điện thoại Thay đi số điện thoại
</Button> </Button>
</div> </div>
+133 -42
View File
@@ -8,7 +8,11 @@ import {
SummaryCard, SummaryCard,
} from "@/components/organisms/analytics"; } from "@/components/organisms/analytics";
import type { PieSlice } from "@/components/organisms/analytics"; import type { PieSlice } from "@/components/organisms/analytics";
import { calcChange, formatCurrency, formatCurrencyFull } from "@/lib/analytics-utils"; import {
calcChange,
formatCurrency,
formatCurrencyFull,
} from "@/lib/analytics-utils";
import { import {
MENU_CATEGORIES, MENU_CATEGORIES,
MOCK_PRODUCT_SALES, MOCK_PRODUCT_SALES,
@@ -31,8 +35,15 @@ const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
}; };
const CATEGORY_COLORS = [ const CATEGORY_COLORS = [
"#6F4E37", "#C8973A", "#A0785A", "#8B6914", "#D4A96A", "#6F4E37",
"#4A3728", "#F0D9A8", "#A08060", "#3D2B1F", "#C8973A",
"#A0785A",
"#8B6914",
"#D4A96A",
"#4A3728",
"#F0D9A8",
"#A08060",
"#3D2B1F",
]; ];
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = { const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
@@ -74,7 +85,9 @@ function CategorySelect({
> >
<option value="all">Tất cả</option> <option value="all">Tất cả</option>
{categories.map((c) => ( {categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>
{c.name}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -98,7 +111,8 @@ export default function AnalyticsPage() {
// Filtered product sales // Filtered product sales
const filteredSales = useMemo( const filteredSales = useMemo(
() => categoryFilter === "all" () =>
categoryFilter === "all"
? MOCK_PRODUCT_SALES ? MOCK_PRODUCT_SALES
: MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter), : MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter),
[categoryFilter], [categoryFilter],
@@ -144,8 +158,10 @@ export default function AnalyticsPage() {
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0); const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0); const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0); const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
const avgMargin = filteredSales.length > 0 const avgMargin =
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) / filteredSales.length filteredSales.length > 0
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
filteredSales.length
: 0; : 0;
return ( return (
@@ -167,7 +183,9 @@ export default function AnalyticsPage() {
<h1 className="text-foreground text-lg leading-tight font-bold"> <h1 className="text-foreground text-lg leading-tight font-bold">
Thống & Phân tích tài chính Thống & Phân tích tài chính
</h1> </h1>
<p className="text-xs text-(--color-text-muted)">Financial Analytics Dashboard</p> <p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard
</p>
</div> </div>
</div> </div>
@@ -192,8 +210,12 @@ export default function AnalyticsPage() {
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)} onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden" className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
> >
{(Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]).map(([k, v]) => ( {(
<option key={k} value={k}>{v}</option> Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]
).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -207,18 +229,42 @@ export default function AnalyticsPage() {
Tổng quan Tổng quan
</h2> </h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard icon="fa-solid fa-sack-dollar" title="Tổng doanh thu" <SummaryCard
value={formatCurrency(totalRevenue)} subtitle={PERIOD_LABELS[period]} icon="fa-solid fa-sack-dollar"
change={revComp.change} changePercent={revComp.changePercent} isPositive={revComp.isPositive} /> title="Tổng doanh thu"
<SummaryCard icon="fa-solid fa-receipt" title="Số đơn hàng" value={formatCurrency(totalRevenue)}
value={totalOrders.toLocaleString()} subtitle="Tổng đơn trong kỳ" subtitle={PERIOD_LABELS[period]}
change={ordComp.change} changePercent={ordComp.changePercent} isPositive={ordComp.isPositive} /> change={revComp.change}
<SummaryCard icon="fa-solid fa-circle-dollar-to-slot" title="Tổng lợi nhuận" changePercent={revComp.changePercent}
value={formatCurrency(totalProfit)} subtitle="Ước tính từ dữ liệu bán hàng" isPositive={revComp.isPositive}
change={proComp.change} changePercent={proComp.changePercent} isPositive={proComp.isPositive} /> />
<SummaryCard icon="fa-solid fa-basket-shopping" title="Giá trị đơn TB" <SummaryCard
value={formatCurrency(avgOrderValue)} subtitle="Doanh thu / số đơn hàng" icon="fa-solid fa-receipt"
change={0} changePercent={0} isPositive={true} /> title="Số đơn hàng"
value={totalOrders.toLocaleString()}
subtitle="Tổng đơn trong kỳ"
change={ordComp.change}
changePercent={ordComp.changePercent}
isPositive={ordComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-circle-dollar-to-slot"
title="Tổng lợi nhuận"
value={formatCurrency(totalProfit)}
subtitle="Ước tính từ dữ liệu bán hàng"
change={proComp.change}
changePercent={proComp.changePercent}
isPositive={proComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-basket-shopping"
title="Giá trị đơn TB"
value={formatCurrency(avgOrderValue)}
subtitle="Doanh thu / số đơn hàng"
change={0}
changePercent={0}
isPositive={true}
/>
</div> </div>
</section> </section>
@@ -241,7 +287,9 @@ export default function AnalyticsPage() {
}`} }`}
> >
<i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i> <i className={`fa-solid text-xs ${CHART_META[t].icon}`}></i>
<span className="hidden sm:inline">{CHART_META[t].label}</span> <span className="hidden sm:inline">
{CHART_META[t].label}
</span>
</button> </button>
))} ))}
</div> </div>
@@ -249,19 +297,29 @@ export default function AnalyticsPage() {
{activeChart === "line" && ( {activeChart === "line" && (
<> <>
<p className="mb-3 text-xs text-(--color-text-muted)">Doanh thu theo thời gian {PERIOD_LABELS[period]}</p> <p className="mb-3 text-xs text-(--color-text-muted)">
Doanh thu theo thời gian {PERIOD_LABELS[period]}
</p>
<LineChart data={revenueData} height={220} /> <LineChart data={revenueData} height={220} />
</> </>
)} )}
{activeChart === "bar" && ( {activeChart === "bar" && (
<> <>
<p className="mb-3 text-xs text-(--color-text-muted)">So sánh doanh thu nửa đu nửa sau kỳ hiện tại</p> <p className="mb-3 text-xs text-(--color-text-muted)">
<BarChart current={barCurrent} previous={barPrevious} height={220} /> So sánh doanh thu nửa đu nửa sau kỳ hiện tại
</p>
<BarChart
current={barCurrent}
previous={barPrevious}
height={220}
/>
</> </>
)} )}
{activeChart === "pie" && ( {activeChart === "pie" && (
<> <>
<p className="mb-3 text-xs text-(--color-text-muted)">Tỷ trọng doanh thu theo danh mục sản phẩm</p> <p className="mb-3 text-xs text-(--color-text-muted)">
Tỷ trọng doanh thu theo danh mục sản phẩm
</p>
<PieChart data={pieData} /> <PieChart data={pieData} />
</> </>
)} )}
@@ -274,7 +332,10 @@ export default function AnalyticsPage() {
<i className="fa-solid fa-fire mr-2 text-orange-500"></i> <i className="fa-solid fa-fire mr-2 text-orange-500"></i>
Top sản phẩm bán chạy Top sản phẩm bán chạy
</h2> </h2>
<CategorySelect value={categoryFilter} onChange={setCategoryFilter} /> <CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
/>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{top5.map((p, i) => { {top5.map((p, i) => {
@@ -286,15 +347,24 @@ export default function AnalyticsPage() {
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)">
{i + 1} {i + 1}
</span> </span>
<span className="text-foreground truncate text-sm font-medium">{p.name}</span> <span className="text-foreground truncate text-sm font-medium">
{p.name}
</span>
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs"> <div className="flex shrink-0 items-center gap-3 text-xs">
<span className="text-(--color-text-muted) tabular-nums">{p.unitsSold} ly</span> <span className="text-(--color-text-muted) tabular-nums">
<span className="font-semibold text-(--color-primary) tabular-nums">{formatCurrency(p.revenue)}</span> {p.unitsSold} ly
</span>
<span className="font-semibold text-(--color-primary) tabular-nums">
{formatCurrency(p.revenue)}
</span>
</div> </div>
</div> </div>
<div className="bg-background h-2 overflow-hidden rounded-full"> <div className="bg-background h-2 overflow-hidden rounded-full">
<div className="h-full rounded-full bg-(--color-primary) transition-all duration-500" style={{ width: `${pct}%` }} /> <div
className="h-full rounded-full bg-(--color-primary) transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div> </div>
</div> </div>
); );
@@ -309,30 +379,51 @@ export default function AnalyticsPage() {
<i className="fa-solid fa-table text-foreground mr-2"></i> <i className="fa-solid fa-table text-foreground mr-2"></i>
Phân tích chi tiết sản phẩm Phân tích chi tiết sản phẩm
</h2> </h2>
<CategorySelect value={categoryFilter} onChange={setCategoryFilter} label="Lọc danh mục:" /> <CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
label="Lọc danh mục:"
/>
</div> </div>
<p className="mb-3 text-xs text-(--color-text-muted)"> <p className="mb-3 text-xs text-(--color-text-muted)">
Click vào tiêu đ cột đ sắp xếp. Hiển thị {filteredSales.length} sản phẩm. Click vào tiêu đ cột đ sắp xếp. Hiển thị {filteredSales.length}{" "}
sản phẩm.
</p> </p>
<ProductTable data={filteredSales} /> <ProductTable data={filteredSales} />
{/* Summary row */} {/* Summary row */}
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm"> <div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
<div> <div>
<span className="text-(--color-text-muted)">Tổng doanh thu: </span> <span className="text-(--color-text-muted)">
<span className="font-semibold text-(--color-primary)">{formatCurrencyFull(filteredRevenue)}</span> Tổng doanh thu:{" "}
</span>
<span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)}
</span>
</div> </div>
<div> <div>
<span className="text-(--color-text-muted)">Tổng lợi nhuận: </span> <span className="text-(--color-text-muted)">
<span className="font-semibold text-green-600">{formatCurrencyFull(filteredProfit)}</span> Tổng lợi nhuận:{" "}
</span>
<span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)}
</span>
</div> </div>
<div> <div>
<span className="text-(--color-text-muted)">Tổng sản lượng: </span> <span className="text-(--color-text-muted)">
<span className="text-foreground font-semibold">{filteredUnits.toLocaleString()} ly</span> Tổng sản lượng:{" "}
</span>
<span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} ly
</span>
</div> </div>
<div> <div>
<span className="text-(--color-text-muted)">Biên LN trung bình: </span> <span className="text-(--color-text-muted)">
<span className="font-semibold text-yellow-700">{avgMargin.toFixed(1)}%</span> Biên LN trung bình:{" "}
</span>
<span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}%
</span>
</div> </div>
</div> </div>
</section> </section>
+36 -19
View File
@@ -1,7 +1,7 @@
# Components Documentation # Components Documentation
> This project follows **Atomic Design** pattern. > This project follows **Atomic Design** pattern. See `Atomic.md` at project
> See `Atomic.md` at project root for full structure guide. > root for full structure guide.
## Directory Structure ## Directory Structure
@@ -19,17 +19,21 @@ components/
### ProductCard (`molecules/cards/ProductCard.tsx`) ### ProductCard (`molecules/cards/ProductCard.tsx`)
**Description:** Product card molecule. Displays product image, name, description, formatted price, and a Buy button. Width is controlled by the parent grid (w-full). **Description:** Product card molecule. Displays product image, name,
description, formatted price, and a Buy button. Width is controlled by the
parent grid (w-full).
**Atomic level:** Molecule (composed of Button, Caption, Text atoms) **Atomic level:** Molecule (composed of Button, Caption, Text atoms)
### ShopCard (`molecules/cards/ShopCard.tsx`) ### ShopCard (`molecules/cards/ShopCard.tsx`)
**Description:** Shop discovery card. Displays shop image, name, address, and a link to the menu. **Description:** Shop discovery card. Displays shop image, name, address, and a
link to the menu.
### SearchBar (`molecules/search-bar/SearchBar.tsx`) ### SearchBar (`molecules/search-bar/SearchBar.tsx`)
**Description:** Search input with clear button. Controlled component — value and onChange come from parent. **Description:** Search input with clear button. Controlled component — value
and onChange come from parent.
--- ---
@@ -37,30 +41,36 @@ components/
### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`) ### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`)
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header. **Description:** Left sidebar with collapsible category filter. Sticky below
header, full viewport height minus header.
### CartFab (`organisms/cart/CartFab.tsx`) ### CartFab (`organisms/cart/CartFab.tsx`)
**Description:** Floating Action Button displaying cart item count. Links to /payment page. **Description:** Floating Action Button displaying cart item count. Links to
/payment page.
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`) ### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
**Description:** Full product grid organism with mobile category tabs, filtering, and empty state. Uses useCart and useMenu contexts. **Description:** Full product grid organism with mobile category tabs,
filtering, and empty state. Uses useCart and useMenu contexts.
### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`) ### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`)
**Description:** Responsive grid of ShopCard molecules for the feed/discovery page. **Description:** Responsive grid of ShopCard molecules for the feed/discovery
page.
### ReviewModal (`organisms/modals/ReviewModal.tsx`) ### ReviewModal (`organisms/modals/ReviewModal.tsx`)
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message. **Description:** Modal for customer reviews. Shows 5-star rating and textarea.
After submission, displays thank-you message.
### Analytics Charts (`organisms/analytics/`) ### Analytics Charts (`organisms/analytics/`)
**Description:** Pure-SVG chart and table components for the Financial Analytics dashboard. All components are interactive with hover tooltips. **Description:** Pure-SVG chart and table components for the Financial Analytics
dashboard. All components are interactive with hover tooltips.
| Component | File | Description | | Component | File | Description |
|-----------|------|-------------| | ------------ | ---------------- | ---------------------------------------------------------- |
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips | | LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period | | BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown | | PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
@@ -75,11 +85,13 @@ components/
### MainLayout (`templates/main-layout/MainLayout.tsx`) ### MainLayout (`templates/main-layout/MainLayout.tsx`)
**Description:** Main layout template — wraps content with Header, Footer, and CartFab. Used by (main) route group. **Description:** Main layout template — wraps content with Header, Footer, and
CartFab. Used by (main) route group.
### AuthLayout (`templates/auth-layout/AuthLayout.tsx`) ### AuthLayout (`templates/auth-layout/AuthLayout.tsx`)
**Description:** Auth layout template — centers content in screen. Used by login/register pages. **Description:** Auth layout template — centers content in screen. Used by
login/register pages.
### FeedLayout (`templates/feed-layout/FeedLayout.tsx`) ### FeedLayout (`templates/feed-layout/FeedLayout.tsx`)
@@ -87,7 +99,8 @@ components/
### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`) ### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`)
**Description:** Manager layout template — auth guard + ManagerProvider. Used by the manager route group. **Description:** Manager layout template — auth guard + ManagerProvider. Used by
the manager route group.
--- ---
@@ -95,16 +108,20 @@ components/
### AuthContext (`lib/auth-context.tsx`) ### AuthContext (`lib/auth-context.tsx`)
Manages user authentication state including login, logout, and registration. Uses localStorage for persistence. Manages user authentication state including login, logout, and registration.
Uses localStorage for persistence.
### CartContext (`lib/cart-context.tsx`) ### CartContext (`lib/cart-context.tsx`)
Manages shopping cart state with localStorage persistence. Tracks items, quantities, and totals. Manages shopping cart state with localStorage persistence. Tracks items,
quantities, and totals.
### MenuContext (`lib/menu-context.tsx`) ### MenuContext (`lib/menu-context.tsx`)
Provides shared activeCategory state across components. Synchronizes Header mobile menu and CategorySidebar selection. Provides shared activeCategory state across components. Synchronizes Header
mobile menu and CategorySidebar selection.
### ManagerContext (`lib/manager-context.tsx`) ### ManagerContext (`lib/manager-context.tsx`)
Manages menu CRUD state (products, combos, categories) for the manager dashboard. Manages menu CRUD state (products, combos, categories) for the manager
dashboard.
+79 -29
View File
@@ -1,16 +1,18 @@
# Atoms Components Library # Atoms Components Library
**Status:** ✅ Phase 1 Implementation Complete **Status:** ✅ Phase 1 Implementation Complete **Created:** 2026-04-03
**Created:** 2026-04-03
**Foundation:** Atomic Design Pattern **Foundation:** Atomic Design Pattern
--- ---
## 📚 Overview ## 📚 Overview
Atoms are the basic building blocks of your UI. They are small, focused, reusable components that have no dependencies on other components (except potentially other atoms). Atoms are the basic building blocks of your UI. They are small, focused,
reusable components that have no dependencies on other components (except
potentially other atoms).
**Key Principles:** **Key Principles:**
- ✅ No business logic - ✅ No business logic
- ✅ No context/hooks (useAuth, useCart, etc.) - ✅ No context/hooks (useAuth, useCart, etc.)
- ✅ Pure props-based - ✅ Pure props-based
@@ -25,9 +27,11 @@ Atoms are the basic building blocks of your UI. They are small, focused, reusabl
### Buttons (`buttons/`) ### Buttons (`buttons/`)
#### **Button.tsx** #### **Button.tsx**
Main interactive button component with multiple variants and sizes. Main interactive button component with multiple variants and sizes.
**Props:** **Props:**
- `variant` - "primary" | "secondary" | "danger" | "ghost" (default: "primary") - `variant` - "primary" | "secondary" | "danger" | "ghost" (default: "primary")
- `size` - "sm" | "md" | "lg" (default: "md") - `size` - "sm" | "md" | "lg" (default: "md")
- `icon` - FontAwesome icon class (optional) - `icon` - FontAwesome icon class (optional)
@@ -38,6 +42,7 @@ Main interactive button component with multiple variants and sizes.
- All standard HTML button attributes - All standard HTML button attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
@@ -55,6 +60,7 @@ import { Button } from "@/components/atoms";
``` ```
**Styling:** **Styling:**
- Primary: Branded color with dark hover - Primary: Branded color with dark hover
- Secondary: Border style with light background on hover - Secondary: Border style with light background on hover
- Danger: Red for destructive actions - Danger: Red for destructive actions
@@ -63,15 +69,18 @@ import { Button } from "@/components/atoms";
--- ---
#### **IconButton.tsx** #### **IconButton.tsx**
Button designed specifically for icon-only interactions. Button designed specifically for icon-only interactions.
**Props:** **Props:**
- `variant` - "primary" | "secondary" | "danger" | "ghost" - `variant` - "primary" | "secondary" | "danger" | "ghost"
- `size` - "sm" (8x8) | "md" (10x10) | "lg" (12x12) - `size` - "sm" (8x8) | "md" (10x10) | "lg" (12x12)
- `icon` - FontAwesome icon class (required) - `icon` - FontAwesome icon class (required)
- All standard HTML button attributes - All standard HTML button attributes
**Usage:** **Usage:**
```tsx ```tsx
import { IconButton } from "@/components/atoms"; import { IconButton } from "@/components/atoms";
@@ -84,9 +93,11 @@ import { IconButton } from "@/components/atoms";
### Inputs (`inputs/`) ### Inputs (`inputs/`)
#### **TextInput.tsx** #### **TextInput.tsx**
General text input field with optional label, error, and icon. General text input field with optional label, error, and icon.
**Props:** **Props:**
- `label` - string (optional) - `label` - string (optional)
- `error` - string (optional, shows red border and error text) - `error` - string (optional, shows red border and error text)
- `icon` - FontAwesome icon class (optional) - `icon` - FontAwesome icon class (optional)
@@ -94,6 +105,7 @@ General text input field with optional label, error, and icon.
- All standard HTML input attributes - All standard HTML input attributes
**Usage:** **Usage:**
```tsx ```tsx
import { TextInput } from "@/components/atoms"; import { TextInput } from "@/components/atoms";
@@ -115,9 +127,11 @@ import { TextInput } from "@/components/atoms";
--- ---
#### **SearchInput.tsx** #### **SearchInput.tsx**
Search input with built-in search icon and clear button. Search input with built-in search icon and clear button.
**Props:** **Props:**
- `value` - string (controlled input) - `value` - string (controlled input)
- `onChange` - callback when text changes - `onChange` - callback when text changes
- `onClear` - callback for clear button (required for button to show) - `onClear` - callback for clear button (required for button to show)
@@ -125,6 +139,7 @@ Search input with built-in search icon and clear button.
- All standard HTML input attributes - All standard HTML input attributes
**Usage:** **Usage:**
```tsx ```tsx
import { SearchInput } from "@/components/atoms"; import { SearchInput } from "@/components/atoms";
@@ -135,20 +150,23 @@ const [query, setQuery] = useState("");
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onClear={() => setQuery("")} onClear={() => setQuery("")}
placeholder="Search products..." placeholder="Search products..."
/> />;
``` ```
--- ---
#### **Textarea.tsx** #### **Textarea.tsx**
Multi-line text input with optional label and error. Multi-line text input with optional label and error.
**Props:** **Props:**
- `label` - string (optional) - `label` - string (optional)
- `error` - string (optional) - `error` - string (optional)
- All standard HTML textarea attributes - All standard HTML textarea attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Textarea } from "@/components/atoms"; import { Textarea } from "@/components/atoms";
@@ -158,7 +176,7 @@ import { Textarea } from "@/components/atoms";
rows={4} rows={4}
value={review} value={review}
onChange={(e) => setReview(e.target.value)} onChange={(e) => setReview(e.target.value)}
/> />;
``` ```
--- ---
@@ -166,14 +184,17 @@ import { Textarea } from "@/components/atoms";
### Typography (`typography/`) ### Typography (`typography/`)
#### **Heading.tsx** #### **Heading.tsx**
Semantic heading component with level-based sizing. Semantic heading component with level-based sizing.
**Props:** **Props:**
- `level` - 1 | 2 | 3 | 4 | 5 | 6 (default: 2) - `level` - 1 | 2 | 3 | 4 | 5 | 6 (default: 2)
- `children` - ReactNode - `children` - ReactNode
- All standard HTML heading attributes - All standard HTML heading attributes
**Sizing:** **Sizing:**
- Level 1: text-3xl font-bold - Level 1: text-3xl font-bold
- Level 2: text-2xl font-bold - Level 2: text-2xl font-bold
- Level 3: text-xl font-bold - Level 3: text-xl font-bold
@@ -182,6 +203,7 @@ Semantic heading component with level-based sizing.
- Level 6: text-sm font-semibold - Level 6: text-sm font-semibold
**Usage:** **Usage:**
```tsx ```tsx
import { Heading } from "@/components/atoms"; import { Heading } from "@/components/atoms";
@@ -193,20 +215,24 @@ import { Heading } from "@/components/atoms";
--- ---
#### **Text.tsx** #### **Text.tsx**
Paragraph text with semantic variants. Paragraph text with semantic variants.
**Props:** **Props:**
- `variant` - "body1" | "body2" | "caption" | "label" (default: "body1") - `variant` - "body1" | "body2" | "caption" | "label" (default: "body1")
- `children` - ReactNode - `children` - ReactNode
- All standard HTML paragraph attributes - All standard HTML paragraph attributes
**Variants:** **Variants:**
- body1: text-base (main content) - body1: text-base (main content)
- body2: text-sm (secondary content) - body2: text-sm (secondary content)
- caption: text-xs (smallest text) - caption: text-xs (smallest text)
- label: text-sm font-medium (form labels) - label: text-sm font-medium (form labels)
**Usage:** **Usage:**
```tsx ```tsx
import { Text } from "@/components/atoms"; import { Text } from "@/components/atoms";
@@ -219,13 +245,16 @@ import { Text } from "@/components/atoms";
--- ---
#### **Caption.tsx** #### **Caption.tsx**
Small caption/note text component. Small caption/note text component.
**Props:** **Props:**
- `children` - ReactNode - `children` - ReactNode
- All standard HTML span attributes - All standard HTML span attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Caption } from "@/components/atoms"; import { Caption } from "@/components/atoms";
@@ -238,15 +267,19 @@ import { Caption } from "@/components/atoms";
### Badges (`badges/`) ### Badges (`badges/`)
#### **Badge.tsx** #### **Badge.tsx**
Labeled badge component for highlighting information. Labeled badge component for highlighting information.
**Props:** **Props:**
- `variant` - "primary" | "secondary" | "success" | "danger" | "warning" (default: "primary")
- `variant` - "primary" | "secondary" | "success" | "danger" | "warning"
(default: "primary")
- `size` - "sm" | "md" (default: "md") - `size` - "sm" | "md" (default: "md")
- `children` - ReactNode - `children` - ReactNode
- All standard HTML span attributes - All standard HTML span attributes
**Variants:** **Variants:**
- primary: Branded color - primary: Branded color
- secondary: Light gray - secondary: Light gray
- success: Green - success: Green
@@ -254,6 +287,7 @@ Labeled badge component for highlighting information.
- warning: Yellow - warning: Yellow
**Usage:** **Usage:**
```tsx ```tsx
import { Badge } from "@/components/atoms"; import { Badge } from "@/components/atoms";
@@ -265,14 +299,17 @@ import { Badge } from "@/components/atoms";
--- ---
#### **PriceBadge.tsx** #### **PriceBadge.tsx**
Specialized badge for displaying formatted prices. Specialized badge for displaying formatted prices.
**Props:** **Props:**
- `price` - number - `price` - number
- `currency` - string (default: "VND") - `currency` - string (default: "VND")
- All standard HTML span attributes - All standard HTML span attributes
**Usage:** **Usage:**
```tsx ```tsx
import { PriceBadge } from "@/components/atoms"; import { PriceBadge } from "@/components/atoms";
@@ -281,6 +318,7 @@ import { PriceBadge } from "@/components/atoms";
``` ```
**Output:** **Output:**
- VND: "50.000 ₫" - VND: "50.000 ₫"
- USD: "$99.99" - USD: "$99.99"
@@ -289,13 +327,16 @@ import { PriceBadge } from "@/components/atoms";
### Dividers (`dividers/`) ### Dividers (`dividers/`)
#### **Divider.tsx** #### **Divider.tsx**
Visual separator element. Visual separator element.
**Props:** **Props:**
- `orientation` - "horizontal" | "vertical" (default: "horizontal") - `orientation` - "horizontal" | "vertical" (default: "horizontal")
- All standard HTML hr attributes - All standard HTML hr attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Divider } from "@/components/atoms"; import { Divider } from "@/components/atoms";
@@ -332,34 +373,41 @@ Change one variable in `globals.css` to update all atoms across the app.
## 🔄 Import Patterns ## 🔄 Import Patterns
### Individual Imports ### Individual Imports
```tsx ```tsx
import { Button } from "@/components/atoms/buttons";
import { TextInput, SearchInput } from "@/components/atoms/inputs";
import { Heading, Text, Caption } from "@/components/atoms/typography";
import { Badge, PriceBadge } from "@/components/atoms/badges"; import { Badge, PriceBadge } from "@/components/atoms/badges";
import { Button } from "@/components/atoms/buttons";
import { Divider } from "@/components/atoms/dividers"; import { Divider } from "@/components/atoms/dividers";
import { SearchInput, TextInput } from "@/components/atoms/inputs";
import { Caption, Heading, Text } from "@/components/atoms/typography";
``` ```
### Barrel Exports (Recommended) ### Barrel Exports (Recommended)
```tsx ```tsx
import { import {
Button,
IconButton,
TextInput,
SearchInput,
Textarea,
Heading,
Text,
Caption,
Badge, Badge,
PriceBadge, Button,
Caption,
Divider, Divider,
Heading,
IconButton,
PriceBadge,
SearchInput,
Text,
TextInput,
Textarea,
} from "@/components/atoms"; } from "@/components/atoms";
``` ```
### Type Imports ### Type Imports
```tsx ```tsx
import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/atoms"; import type {
ButtonProps,
HeadingProps,
TextInputProps,
} from "@/components/atoms";
``` ```
--- ---
@@ -367,13 +415,10 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
## ✅ Usage Examples ## ✅ Usage Examples
### Form Field ### Form Field
```tsx ```tsx
<div className="space-y-4"> <div className="space-y-4">
<TextInput <TextInput label="Name" placeholder="Enter your name" type="text" />
label="Name"
placeholder="Enter your name"
type="text"
/>
<TextInput <TextInput
label="Email" label="Email"
placeholder="user@example.com" placeholder="user@example.com"
@@ -385,11 +430,12 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
``` ```
### Product Card ### Product Card
```tsx ```tsx
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<Heading level={3}>Product Name</Heading> <Heading level={3}>Product Name</Heading>
<Text variant="body2">Product description</Text> <Text variant="body2">Product description</Text>
<div className="flex items-center justify-between mt-4"> <div className="mt-4 flex items-center justify-between">
<PriceBadge price={50000} /> <PriceBadge price={50000} />
<Button icon="fa-cart-plus">Add to Cart</Button> <Button icon="fa-cart-plus">Add to Cart</Button>
</div> </div>
@@ -397,6 +443,7 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
``` ```
### Rating Display ### Rating Display
```tsx ```tsx
<div> <div>
<Heading level={4}>Reviews</Heading> <Heading level={4}>Reviews</Heading>
@@ -410,6 +457,7 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
## 🧪 Testing Atoms ## 🧪 Testing Atoms
### Props Validation ### Props Validation
```tsx ```tsx
// ✅ Valid // ✅ Valid
<Button variant="primary" size="sm">Click</Button> <Button variant="primary" size="sm">Click</Button>
@@ -420,7 +468,9 @@ import type { ButtonProps, TextInputProps, HeadingProps } from "@/components/ato
``` ```
### Accessibility ### Accessibility
All atoms include: All atoms include:
- Semantic HTML - Semantic HTML
- ARIA labels where appropriate - ARIA labels where appropriate
- Keyboard navigation support - Keyboard navigation support
@@ -466,7 +516,8 @@ components/atoms/
## 🚀 Next Steps ## 🚀 Next Steps
Once atoms are comfortable, the next phase is creating **Molecules** - combinations of atoms: Once atoms are comfortable, the next phase is creating **Molecules** -
combinations of atoms:
- **ProductCard** - Image + Text + Badge + Button - **ProductCard** - Image + Text + Badge + Button
- **FormField** - Label + Input + Error Text - **FormField** - Label + Input + Error Text
@@ -489,6 +540,5 @@ See `OPTIMIZATION_PLAN.md` for the full roadmap.
--- ---
**Status:** ✅ Complete & Ready for Use **Status:** ✅ Complete & Ready for Use **Last Updated:** 2026-04-03 **Phase:**
**Last Updated:** 2026-04-03 1 of 5
**Phase:** 1 of 5
+9 -5
View File
@@ -14,9 +14,11 @@ export default function Button({
}: ButtonProps) { }: ButtonProps) {
const styles = { const styles = {
base: "font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5", base: "font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5",
payment: "inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl transition-colors", payment:
login: "w-full cursor-pointer rounded-xl py-3 font-semibold transition-all duration-150", "inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl transition-colors",
} login:
"w-full cursor-pointer rounded-xl py-3 font-semibold transition-all duration-150",
};
const variants = { const variants = {
primary: primary:
@@ -25,8 +27,10 @@ export default function Button({
"border border-(--color-border) hover:bg-(--color-border-light) active:scale-95", "border border-(--color-border) hover:bg-(--color-border-light) active:scale-95",
danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95", danger: "bg-red-500 text-white hover:bg-red-600 active:scale-95",
ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95", ghost: "bg-transparent hover:bg-(--color-border-light) active:scale-95",
primaryNoBorder: "border-none bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-98", primaryNoBorder:
bgWhite: "border-2 border-(--color-primary) bg-white text-(--color-primary) hover:bg-(--color-primary) hover:text-white active:scale-98", "border-none bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-98",
bgWhite:
"border-2 border-(--color-primary) bg-white text-(--color-primary) hover:bg-(--color-primary) hover:text-white active:scale-98",
}; };
const sizes = { const sizes = {
+11 -2
View File
@@ -1,8 +1,17 @@
import { ButtonHTMLAttributes } from "react"; import { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style'> { export interface ButtonProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"style"
> {
style?: "base" | "payment" | "login"; style?: "base" | "payment" | "login";
variant?: "primary" | "secondary" | "danger" | "ghost" | "primaryNoBorder" | "bgWhite"; variant?:
| "primary"
| "secondary"
| "danger"
| "ghost"
| "primaryNoBorder"
| "bgWhite";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus" icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
iconPosition?: "left" | "right"; iconPosition?: "left" | "right";
@@ -26,7 +26,5 @@ export default function ErrorMessageLogin({
); );
} }
return ( return type === "primary" ? primaryType() : secondaryType();
type === "primary" ? primaryType() : secondaryType()
);
} }
+1 -3
View File
@@ -1,4 +1,2 @@
export { default as ErrorMessageLogin } from "./ErrorMessageLogin"; export { default as ErrorMessageLogin } from "./ErrorMessageLogin";
export type { export type { ErrorMessageLoginProps } from "./Error.types";
ErrorMessageLoginProps,
} from "./Error.types";
+5 -1
View File
@@ -46,7 +46,11 @@ export default function LoginInput({
type={showPassword ? "text" : type} type={showPassword ? "text" : type}
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder= {type === "password" ? "Mật khẩu" : "admin / số điện thoại / tên nhân viên"} placeholder={
type === "password"
? "Mật khẩu"
: "admin / số điện thoại / tên nhân viên"
}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors ? "border-red-400" : "border-(--color-border)"} `} className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors ? "border-red-400" : "border-(--color-border)"} `}
/> />
{isPassword()} {isPassword()}
@@ -1,7 +1,6 @@
import Button from "@/components/atoms/buttons/Button"; import Button from "@/components/atoms/buttons/Button";
import Link from "next/link";
import { ReviewModal } from "@/components/organisms/modals"; import { ReviewModal } from "@/components/organisms/modals";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import type { PaymentSummaryCardProps } from "./Card.types"; import type { PaymentSummaryCardProps } from "./Card.types";
@@ -14,7 +13,6 @@ export default function PaymentSummaryCard({
isCustomer = false, isCustomer = false,
backHref, backHref,
}: PaymentSummaryCardProps) { }: PaymentSummaryCardProps) {
const [isReviewOpen, setIsReviewOpen] = useState(false); const [isReviewOpen, setIsReviewOpen] = useState(false);
const handlePayment = () => { const handlePayment = () => {
// UI-only: open review modal after "payment" // UI-only: open review modal after "payment"
@@ -67,7 +65,10 @@ export default function PaymentSummaryCard({
</Button> </Button>
)} )}
<Link href={backHref || "/"} className={isCustomer ? "" : "col-span-2"}> <Link
href={backHref || "/"}
className={isCustomer ? "" : "col-span-2"}
>
<Button <Button
style="payment" style="payment"
onClick={() => setIsReviewOpen(false)} onClick={() => setIsReviewOpen(false)}
+121 -22
View File
@@ -16,15 +16,25 @@ interface BarChartProps {
* Tooltip auto-flips above/below bar top to stay inside the viewBox. * Tooltip auto-flips above/below bar top to stay inside the viewBox.
*/ */
export function BarChart({ current, previous, height = 200 }: BarChartProps) { export function BarChart({ current, previous, height = 200 }: BarChartProps) {
const [hovered, setHovered] = useState<{ set: "cur" | "prev"; idx: number } | null>(null); const [hovered, setHovered] = useState<{
set: "cur" | "prev";
idx: number;
} | null>(null);
const W = 800; const W = 800;
const H = height; const H = height;
const padL = 56, padR = 16, padT = 16, padB = 40; const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR; const chartW = W - padL - padR;
const chartH = H - padT - padB; const chartH = H - padT - padB;
const n = current.length; const n = current.length;
const maxVal = Math.max(...current.map((d) => d.revenue), ...previous.map((d) => d.revenue)) || 1; const maxVal =
Math.max(
...current.map((d) => d.revenue),
...previous.map((d) => d.revenue),
) || 1;
const groupW = chartW / n; const groupW = chartW / n;
const barW = groupW * 0.35; const barW = groupW * 0.35;
const gap = groupW * 0.05; const gap = groupW * 0.05;
@@ -47,8 +57,24 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
> >
{gridLines.map((g, i) => ( {gridLines.map((g, i) => (
<g key={i}> <g key={i}>
<line x1={padL} y1={g.y} x2={W - padR} y2={g.y} stroke="#E2C9A8" strokeWidth="1" strokeDasharray={i === yTicks ? "0" : "4 3"} /> <line
<text x={padL - 6} y={g.y + 4} textAnchor="end" fontSize="10" fill="#A08060">{formatCurrency(g.val)}</text> x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g> </g>
))} ))}
@@ -62,48 +88,121 @@ export function BarChart({ current, previous, height = 200 }: BarChartProps) {
const isHovPrev = hovered?.set === "prev" && hovered.idx === i; const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
return ( return (
<g key={i}> <g key={i}>
<rect x={prevX} y={padT + chartH - prevH} width={barW} height={prevH} rx="3" <rect
x={prevX}
y={padT + chartH - prevH}
width={barW}
height={prevH}
rx="3"
fill={isHovPrev ? "#A0785A" : "#E2C9A8"} fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
style={{ cursor: "pointer", transition: "fill 150ms" }} style={{ cursor: "pointer", transition: "fill 150ms" }}
onMouseEnter={() => setHovered({ set: "prev", idx: i })} /> onMouseEnter={() => setHovered({ set: "prev", idx: i })}
<rect x={curX} y={padT + chartH - curH} width={barW} height={curH} rx="3" />
<rect
x={curX}
y={padT + chartH - curH}
width={barW}
height={curH}
rx="3"
fill={isHovCur ? "#4A3728" : "#6F4E37"} fill={isHovCur ? "#4A3728" : "#6F4E37"}
style={{ cursor: "pointer", transition: "fill 150ms" }} style={{ cursor: "pointer", transition: "fill 150ms" }}
onMouseEnter={() => setHovered({ set: "cur", idx: i })} /> onMouseEnter={() => setHovered({ set: "cur", idx: i })}
/>
{i % step === 0 && ( {i % step === 0 && (
<text x={groupX + groupW / 2} y={H - 8} textAnchor="middle" fontSize="10" fill="#A08060">{d.label}</text> <text
x={groupX + groupW / 2}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{d.label}
</text>
)} )}
</g> </g>
); );
})} })}
{hovered !== null && (() => { {hovered !== null &&
const d = hovered.set === "cur" ? current[hovered.idx] : previous[hovered.idx]; (() => {
const d =
hovered.set === "cur"
? current[hovered.idx]
: previous[hovered.idx];
if (!d) return null; if (!d) return null;
const groupX = padL + hovered.idx * groupW; const groupX = padL + hovered.idx * groupW;
const tipW = 130, tipH = 50; const tipW = 130,
const tipX = Math.min(Math.max(groupX - tipW / 2, padL), W - padR - tipW); tipH = 50;
const tipX = Math.min(
Math.max(groupX - tipW / 2, padL),
W - padR - tipW,
);
const barH = (d.revenue / maxVal) * chartH; const barH = (d.revenue / maxVal) * chartH;
const barTopY = padT + chartH - barH; const barTopY = padT + chartH - barH;
const aboveY = barTopY - tipH - 8; const aboveY = barTopY - tipH - 8;
const tipY = Math.min(Math.max(aboveY >= padT ? aboveY : barTopY + 8, padT), padT + chartH - tipH); const tipY = Math.min(
Math.max(aboveY >= padT ? aboveY : barTopY + 8, padT),
padT + chartH - tipH,
);
return ( return (
<g> <g>
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="6" fill="#3D2B1F" opacity="0.92" /> <rect
<text x={tipX + tipW / 2} y={tipY + 15} textAnchor="middle" fontSize="10" fill="#F0D9A8"> x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 15}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"}) {d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
</text> </text>
<text x={tipX + tipW / 2} y={tipY + 30} textAnchor="middle" fontSize="11" fontWeight="600" fill="#C8973A">{formatCurrency(d.revenue)}</text> <text
<text x={tipX + tipW / 2} y={tipY + 44} textAnchor="middle" fontSize="10" fill="#A08060">{d.orders} đơn hàng</text> x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(d.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{d.orders} đơn hàng
</text>
</g> </g>
); );
})()} })()}
{/* Legend */} {/* Legend */}
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" /> <rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">Hiện tại</text> <text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
<rect x={padL + 65} y={4} width={10} height={10} rx="2" fill="#E2C9A8" /> Hiện tại
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">Kỳ trước</text> </text>
<rect
x={padL + 65}
y={4}
width={10}
height={10}
rx="2"
fill="#E2C9A8"
/>
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
Kỳ trước
</text>
</svg> </svg>
</div> </div>
); );
+93 -15
View File
@@ -18,7 +18,10 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
const [hovered, setHovered] = useState<number | null>(null); const [hovered, setHovered] = useState<number | null>(null);
const W = 800; const W = 800;
const H = height; const H = height;
const padL = 56, padR = 16, padT = 16, padB = 40; const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR; const chartW = W - padL - padR;
const chartH = H - padT - padB; const chartH = H - padT - padB;
@@ -66,44 +69,119 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
{gridLines.map((g, i) => ( {gridLines.map((g, i) => (
<g key={i}> <g key={i}>
<line x1={padL} y1={g.y} x2={W - padR} y2={g.y} stroke="#E2C9A8" strokeWidth="1" strokeDasharray={i === yTicks ? "0" : "4 3"} /> <line
<text x={padL - 6} y={g.y + 4} textAnchor="end" fontSize="10" fill="#A08060">{formatCurrency(g.val)}</text> x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g> </g>
))} ))}
<path d={areaD} fill="url(#areaGrad)" /> <path d={areaD} fill="url(#areaGrad)" />
<path d={pathD} fill="none" stroke="#6F4E37" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" /> <path
d={pathD}
fill="none"
stroke="#6F4E37"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map((p, i) => {points.map((p, i) =>
i % step === 0 ? ( i % step === 0 ? (
<text key={i} x={p.x} y={H - 8} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.label}</text> <text
key={i}
x={p.x}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.label}
</text>
) : null, ) : null,
)} )}
{points.map((p) => ( {points.map((p) => (
<circle <circle
key={p.index} key={p.index}
cx={p.x} cy={p.y} cx={p.x}
cy={p.y}
r={hovered === p.index ? 5 : 3} r={hovered === p.index ? 5 : 3}
fill={hovered === p.index ? "#C8973A" : "#6F4E37"} fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
stroke="#FDF6EC" strokeWidth="2" stroke="#FDF6EC"
strokeWidth="2"
style={{ cursor: "pointer", transition: "r 150ms" }} style={{ cursor: "pointer", transition: "r 150ms" }}
onMouseEnter={() => setHovered(p.index)} onMouseEnter={() => setHovered(p.index)}
/> />
))} ))}
{hovered !== null && (() => { {hovered !== null &&
(() => {
const p = points[hovered]; const p = points[hovered];
const tipW = 120, tipH = 48; const tipW = 120,
const tipX = Math.min(Math.max(p.x - tipW / 2, padL), W - padR - tipW); tipH = 48;
const tipX = Math.min(
Math.max(p.x - tipW / 2, padL),
W - padR - tipW,
);
const aboveY = p.y - tipH - 10; const aboveY = p.y - tipH - 10;
const tipY = Math.min(Math.max(aboveY >= padT ? aboveY : p.y + 10, padT), padT + chartH - tipH); const tipY = Math.min(
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
padT + chartH - tipH,
);
return ( return (
<g> <g>
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="6" fill="#3D2B1F" opacity="0.92" /> <rect
<text x={tipX + tipW / 2} y={tipY + 16} textAnchor="middle" fontSize="10" fill="#F0D9A8">{p.data.label}</text> x={tipX}
<text x={tipX + tipW / 2} y={tipY + 30} textAnchor="middle" fontSize="11" fontWeight="600" fill="#C8973A">{formatCurrency(p.data.revenue)}</text> y={tipY}
<text x={tipX + tipW / 2} y={tipY + 44} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.orders} đơn</text> width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 16}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{p.data.label}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(p.data.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.orders} đơn
</text>
</g> </g>
); );
})()} })()}
+20 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
export interface PieSlice { export interface PieSlice {
label: string; label: string;
@@ -78,7 +78,14 @@ export function PieChart({ data }: PieChartProps) {
/> />
))} ))}
{hovered !== null && ( {hovered !== null && (
<text x={CX} y={CY + 5} textAnchor="middle" fontSize="12" fontWeight="bold" fill="#3D2B1F"> <text
x={CX}
y={CY + 5}
textAnchor="middle"
fontSize="12"
fontWeight="bold"
fill="#3D2B1F"
>
{slices[hovered].percent.toFixed(1)}% {slices[hovered].percent.toFixed(1)}%
</text> </text>
)} )}
@@ -93,14 +100,22 @@ export function PieChart({ data }: PieChartProps) {
onMouseEnter={() => setHovered(s.index)} onMouseEnter={() => setHovered(s.index)}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
> >
<span className="inline-block h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: s.color }} /> <span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: s.color }}
/>
<span <span
className="max-w-35 truncate" className="max-w-35 truncate"
style={{ color: hovered === s.index ? "#3D2B1F" : "#6F4E37", fontWeight: hovered === s.index ? 600 : 400 }} style={{
color: hovered === s.index ? "#3D2B1F" : "#6F4E37",
fontWeight: hovered === s.index ? 600 : 400,
}}
> >
{s.label} {s.label}
</span> </span>
<span className="text-xs text-(--color-text-muted)">{s.percent.toFixed(1)}%</span> <span className="text-xs text-(--color-text-muted)">
{s.percent.toFixed(1)}%
</span>
</div> </div>
))} ))}
</div> </div>
+65 -21
View File
@@ -32,18 +32,33 @@ export function ProductTable({ data }: ProductTableProps) {
const handleSort = (key: keyof ProductSalesStats) => { const handleSort = (key: keyof ProductSalesStats) => {
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc")); if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
else { setSortKey(key); setSortDir("desc"); } else {
setSortKey(key);
setSortDir("desc");
}
}; };
const sortIcon = (col: keyof ProductSalesStats) => ( const sortIcon = (col: keyof ProductSalesStats) => (
<i className={`fa-solid ml-1 text-xs ${ <i
className={`fa-solid ml-1 text-xs ${
sortKey === col sortKey === col
? sortDir === "desc" ? "fa-sort-down text-(--color-primary)" : "fa-sort-up text-(--color-primary)" ? sortDir === "desc"
? "fa-sort-down text-(--color-primary)"
: "fa-sort-up text-(--color-primary)"
: "fa-sort text-(--color-text-muted)" : "fa-sort text-(--color-text-muted)"
}`} /> }`}
/>
); );
const SortTh = ({ col, label, className = "" }: { col: keyof ProductSalesStats; label: string; className?: string }) => ( const SortTh = ({
col,
label,
className = "",
}: {
col: keyof ProductSalesStats;
label: string;
className?: string;
}) => (
<th <th
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`} className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
onClick={() => handleSort(col)} onClick={() => handleSort(col)}
@@ -57,38 +72,67 @@ export function ProductTable({ data }: ProductTableProps) {
<table className="w-full min-w-175 text-sm"> <table className="w-full min-w-175 text-sm">
<thead> <thead>
<tr className="bg-background border-b border-(--color-border-light)"> <tr className="bg-background border-b border-(--color-border-light)">
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">#</th> <th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">Sản phẩm</th> #
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">Danh mục</th> </th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Sản phẩm
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Danh mục
</th>
<SortTh col="unitsSold" label="Số lượng" className="text-right" /> <SortTh col="unitsSold" label="Số lượng" className="text-right" />
<SortTh col="revenue" label="Doanh thu" className="text-right" /> <SortTh col="revenue" label="Doanh thu" className="text-right" />
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">Giá nhập</th> <th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">Giá bán</th> Giá nhập
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Giá bán
</th>
<SortTh col="profit" label="Lợi nhuận" className="text-right" /> <SortTh col="profit" label="Lợi nhuận" className="text-right" />
<SortTh col="profitMargin" label="Biên LN" className="text-right" /> <SortTh col="profitMargin" label="Biên LN" className="text-right" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map((row, i) => ( {sorted.map((row, i) => (
<tr key={row.productId} className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30"> <tr
key={row.productId}
className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30"
>
<td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td> <td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td>
<td className="text-foreground px-4 py-3 font-medium">{row.name}</td> <td className="text-foreground px-4 py-3 font-medium">
{row.name}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)"> <span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
{categoryName(row.category)} {categoryName(row.category)}
</span> </span>
</td> </td>
<td className="text-foreground px-4 py-3 text-right tabular-nums">{row.unitsSold.toLocaleString()}</td> <td className="text-foreground px-4 py-3 text-right tabular-nums">
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">{formatCurrencyFull(row.revenue)}</td> {row.unitsSold.toLocaleString()}
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">{formatCurrencyFull(row.costPrice)}</td> </td>
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">{formatCurrencyFull(row.sellingPrice)}</td> <td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">{formatCurrencyFull(row.profit)}</td> {formatCurrencyFull(row.revenue)}
</td>
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">
{formatCurrencyFull(row.costPrice)}
</td>
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">
{formatCurrencyFull(row.sellingPrice)}
</td>
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">
{formatCurrencyFull(row.profit)}
</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${ <span
row.profitMargin >= 70 ? "bg-green-100 text-green-700" className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
: row.profitMargin >= 60 ? "bg-yellow-100 text-yellow-700" row.profitMargin >= 70
? "bg-green-100 text-green-700"
: row.profitMargin >= 60
? "bg-yellow-100 text-yellow-700"
: "bg-red-100 text-red-600" : "bg-red-100 text-red-600"
}`}> }`}
>
{row.profitMargin.toFixed(1)}% {row.profitMargin.toFixed(1)}%
</span> </span>
</td> </td>
@@ -29,7 +29,9 @@ export function SummaryCard({
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)"> <span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)">
<i className={icon}></i> <i className={icon}></i>
</span> </span>
<span className="text-sm font-medium text-(--color-text-muted)">{title}</span> <span className="text-sm font-medium text-(--color-text-muted)">
{title}
</span>
</div> </div>
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p> <p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
{subtitle && ( {subtitle && (
+7 -2
View File
@@ -1,10 +1,10 @@
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin"; import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import LoginInput from "@/components/atoms/inputs/LoginInput"; import LoginInput from "@/components/atoms/inputs/LoginInput";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import Button from "@/components/atoms/buttons/Button";
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
@@ -102,7 +102,12 @@ export default function LoginForm() {
{/* Buttons */} {/* Buttons */}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{/* Login Button */} {/* Login Button */}
<Button variant="primaryNoBorder" type="submit" style="login" size="lg"> <Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
>
Đăng nhập Đăng nhập
</Button> </Button>
+4 -1
View File
@@ -152,7 +152,10 @@ export default function ProductsTab() {
</tr> </tr>
) : ( ) : (
filtered.map((p) => ( filtered.map((p) => (
<tr key={p.id} className="hover:bg-background transition-colors"> <tr
key={p.id}
className="hover:bg-background transition-colors"
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div> <div>
<p className="text-foreground font-medium">{p.name}</p> <p className="text-foreground font-medium">{p.name}</p>
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: frontend-container - name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.0.10 image: git.demonkernel.io.vn/foodsurf/frontend:1.1.0
ports: ports:
- containerPort: 3000 - containerPort: 3000
resources: resources:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "temp", "name": "temp",
"version": "1.0.10", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "temp", "name": "temp",
"version": "1.0.10", "version": "1.1.0",
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.37", "@types/node": "^20.19.37",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp", "name": "temp",
"version": "1.0.10", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.37", "@types/node": "^20.19.39",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"next": "16.1.7", "next": "16.1.7",
"react": "19.2.3", "react": "19.2.3",
+73 -84
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
'@types/node': '@types/node':
specifier: ^20.19.37 specifier: ^20.19.39
version: 20.19.37 version: 20.19.39
'@types/react': '@types/react':
specifier: ^19.2.14 specifier: ^19.2.14
version: 19.2.14 version: 19.2.14
@@ -171,14 +171,14 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'} engines: {node: '>=0.1.90'}
'@emnapi/core@1.9.1': '@emnapi/core@1.9.2':
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
'@emnapi/runtime@1.9.1': '@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
'@emnapi/wasi-threads@1.2.0': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@eslint-community/eslint-utils@4.9.1': '@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
@@ -749,8 +749,8 @@ packages:
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
'@types/node@20.19.37': '@types/node@20.19.39':
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==}
'@types/normalize-package-data@2.4.4': '@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -1092,8 +1092,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.10.12: baseline-browser-mapping@2.10.15:
resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} resolution: {integrity: sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -1171,8 +1171,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
caniuse-lite@1.0.30001782: caniuse-lite@1.0.30001786:
resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} resolution: {integrity: sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==}
chalk@2.4.1: chalk@2.4.1:
resolution: {integrity: sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==} resolution: {integrity: sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==}
@@ -1463,8 +1463,8 @@ packages:
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.329: electron-to-chromium@1.5.331:
resolution: {integrity: sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==} resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==}
emoji-regex@10.6.0: emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -1567,8 +1567,8 @@ packages:
typescript: typescript:
optional: true optional: true
eslint-import-resolver-node@0.3.9: eslint-import-resolver-node@0.3.10:
resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
eslint-import-resolver-typescript@3.10.1: eslint-import-resolver-typescript@3.10.1:
resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
@@ -2424,8 +2424,8 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash-es@4.17.23: lodash-es@4.18.1:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.capitalize@4.2.1: lodash.capitalize@4.2.1:
resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==}
@@ -2463,12 +2463,12 @@ packages:
lodash@4.17.11: lodash@4.17.11:
resolution: {integrity: sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==} resolution: {integrity: sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
lodash@4.17.5: lodash@4.17.5:
resolution: {integrity: sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==} resolution: {integrity: sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
loose-envify@1.4.0: loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
@@ -2480,8 +2480,8 @@ packages:
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.7: lru-cache@11.3.0:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} resolution: {integrity: sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lru-cache@5.1.1: lru-cache@5.1.1:
@@ -2660,8 +2660,8 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
node-releases@2.0.36: node-releases@2.0.37:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
node-rsa@0.4.2: node-rsa@0.4.2:
resolution: {integrity: sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==} resolution: {integrity: sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==}
@@ -3192,11 +3192,6 @@ packages:
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
resolve@2.0.0-next.6: resolve@2.0.0-next.6:
resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3643,8 +3638,8 @@ packages:
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
undici@7.24.6: undici@7.24.7:
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
unicode-emoji-modifier-base@1.0.0: unicode-emoji-modifier-base@1.0.0:
@@ -3971,18 +3966,18 @@ snapshots:
'@colors/colors@1.5.0': '@colors/colors@1.5.0':
optional: true optional: true
'@emnapi/core@1.9.1': '@emnapi/core@1.9.2':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.0 '@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.9.1': '@emnapi/runtime@1.9.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/wasi-threads@1.2.0': '@emnapi/wasi-threads@1.2.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
@@ -4129,7 +4124,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.9.1 '@emnapi/runtime': 1.9.2
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -4162,8 +4157,8 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
dependencies: dependencies:
'@emnapi/core': 1.9.1 '@emnapi/core': 1.9.2
'@emnapi/runtime': 1.9.1 '@emnapi/runtime': 1.9.2
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
@@ -4295,7 +4290,7 @@ snapshots:
fs-extra: 8.1.0 fs-extra: 8.1.0
globby: 10.0.2 globby: 10.0.2
got: 10.7.0 got: 10.7.0
lodash: 4.17.23 lodash: 4.18.1
querystring: 0.2.1 querystring: 0.2.1
url-join: 4.0.1 url-join: 4.0.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -4311,7 +4306,7 @@ snapshots:
conventional-commits-parser: 6.4.0 conventional-commits-parser: 6.4.0
debug: 4.4.3 debug: 4.4.3
import-from-esm: 2.0.0 import-from-esm: 2.0.0
lodash-es: 4.17.23 lodash-es: 4.18.1
micromatch: 4.0.8 micromatch: 4.0.8
semantic-release: 25.0.3(typescript@5.9.3) semantic-release: 25.0.3(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
@@ -4327,7 +4322,7 @@ snapshots:
aggregate-error: 3.1.0 aggregate-error: 3.1.0
debug: 4.4.3 debug: 4.4.3
execa: 9.6.1 execa: 9.6.1
lodash-es: 4.17.23 lodash-es: 4.18.1
parse-json: 8.3.0 parse-json: 8.3.0
semantic-release: 25.0.3(typescript@5.9.3) semantic-release: 25.0.3(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
@@ -4346,12 +4341,12 @@ snapshots:
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
issue-parser: 7.0.1 issue-parser: 7.0.1
lodash-es: 4.17.23 lodash-es: 4.18.1
mime: 4.1.0 mime: 4.1.0
p-filter: 4.1.0 p-filter: 4.1.0
semantic-release: 25.0.3(typescript@5.9.3) semantic-release: 25.0.3(typescript@5.9.3)
tinyglobby: 0.2.15 tinyglobby: 0.2.15
undici: 7.24.6 undici: 7.24.7
url-join: 5.0.0 url-join: 5.0.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -4364,7 +4359,7 @@ snapshots:
env-ci: 11.2.0 env-ci: 11.2.0
execa: 9.6.1 execa: 9.6.1
fs-extra: 11.3.4 fs-extra: 11.3.4
lodash-es: 4.17.23 lodash-es: 4.18.1
nerf-dart: 1.0.0 nerf-dart: 1.0.0
normalize-url: 9.0.0 normalize-url: 9.0.0
npm: 11.12.1 npm: 11.12.1
@@ -4385,7 +4380,7 @@ snapshots:
get-stream: 7.0.1 get-stream: 7.0.1
import-from-esm: 2.0.0 import-from-esm: 2.0.0
into-stream: 7.0.0 into-stream: 7.0.0
lodash-es: 4.17.23 lodash-es: 4.18.1
read-package-up: 11.0.0 read-package-up: 11.0.0
semantic-release: 25.0.3(typescript@5.9.3) semantic-release: 25.0.3(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
@@ -4483,7 +4478,7 @@ snapshots:
'@babel/traverse': 7.29.0 '@babel/traverse': 7.29.0
'@babel/types': 7.29.0 '@babel/types': 7.29.0
javascript-natural-sort: 0.7.1 javascript-natural-sort: 0.7.1
lodash-es: 4.17.23 lodash-es: 4.18.1
minimatch: 9.0.9 minimatch: 9.0.9
parse-imports-exports: 0.2.4 parse-imports-exports: 0.2.4
prettier: 3.8.1 prettier: 3.8.1
@@ -4499,7 +4494,7 @@ snapshots:
dependencies: dependencies:
'@types/http-cache-semantics': 4.2.0 '@types/http-cache-semantics': 4.2.0
'@types/keyv': 3.1.4 '@types/keyv': 3.1.4
'@types/node': 20.19.37 '@types/node': 20.19.39
'@types/responselike': 1.0.3 '@types/responselike': 1.0.3
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
@@ -4507,7 +4502,7 @@ snapshots:
'@types/glob@7.2.0': '@types/glob@7.2.0':
dependencies: dependencies:
'@types/minimatch': 6.0.0 '@types/minimatch': 6.0.0
'@types/node': 20.19.37 '@types/node': 20.19.39
'@types/http-cache-semantics@4.2.0': {} '@types/http-cache-semantics@4.2.0': {}
@@ -4517,13 +4512,13 @@ snapshots:
'@types/keyv@3.1.4': '@types/keyv@3.1.4':
dependencies: dependencies:
'@types/node': 20.19.37 '@types/node': 20.19.39
'@types/minimatch@6.0.0': '@types/minimatch@6.0.0':
dependencies: dependencies:
minimatch: 10.2.5 minimatch: 10.2.5
'@types/node@20.19.37': '@types/node@20.19.39':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -4539,7 +4534,7 @@ snapshots:
'@types/responselike@1.0.3': '@types/responselike@1.0.3':
dependencies: dependencies:
'@types/node': 20.19.37 '@types/node': 20.19.39
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
@@ -4879,7 +4874,7 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.12: {} baseline-browser-mapping@2.10.15: {}
basic-auth@2.0.1: basic-auth@2.0.1:
dependencies: dependencies:
@@ -4929,10 +4924,10 @@ snapshots:
browserslist@4.28.2: browserslist@4.28.2:
dependencies: dependencies:
baseline-browser-mapping: 2.10.12 baseline-browser-mapping: 2.10.15
caniuse-lite: 1.0.30001782 caniuse-lite: 1.0.30001786
electron-to-chromium: 1.5.329 electron-to-chromium: 1.5.331
node-releases: 2.0.36 node-releases: 2.0.37
update-browserslist-db: 1.2.3(browserslist@4.28.2) update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-equal-constant-time@1.0.1: {} buffer-equal-constant-time@1.0.1: {}
@@ -4975,7 +4970,7 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
caniuse-lite@1.0.30001782: {} caniuse-lite@1.0.30001786: {}
chalk@2.4.1: chalk@2.4.1:
dependencies: dependencies:
@@ -5260,7 +5255,7 @@ snapshots:
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.329: {} electron-to-chromium@1.5.331: {}
emoji-regex@10.6.0: {} emoji-regex@10.6.0: {}
@@ -5412,7 +5407,7 @@ snapshots:
dependencies: dependencies:
'@next/eslint-plugin-next': 16.1.7 '@next/eslint-plugin-next': 16.1.7
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
@@ -5428,11 +5423,11 @@ snapshots:
- eslint-plugin-import-x - eslint-plugin-import-x
- supports-color - supports-color
eslint-import-resolver-node@0.3.9: eslint-import-resolver-node@0.3.10:
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
is-core-module: 2.16.1 is-core-module: 2.16.1
resolve: 1.22.11 resolve: 2.0.0-next.6
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5451,13 +5446,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5472,8 +5467,8 @@ snapshots:
debug: 3.2.7 debug: 3.2.7
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -6018,7 +6013,7 @@ snapshots:
hosted-git-info@9.0.2: hosted-git-info@9.0.2:
dependencies: dependencies:
lru-cache: 11.2.7 lru-cache: 11.3.0
http-cache-semantics@4.2.0: {} http-cache-semantics@4.2.0: {}
@@ -6420,7 +6415,7 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash-es@4.17.23: {} lodash-es@4.18.1: {}
lodash.capitalize@4.2.1: {} lodash.capitalize@4.2.1: {}
@@ -6446,10 +6441,10 @@ snapshots:
lodash@4.17.11: {} lodash@4.17.11: {}
lodash@4.17.23: {}
lodash@4.17.5: {} lodash@4.17.5: {}
lodash@4.18.1: {}
loose-envify@1.4.0: loose-envify@1.4.0:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
@@ -6458,7 +6453,7 @@ snapshots:
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@11.2.7: {} lru-cache@11.3.0: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
@@ -6585,8 +6580,8 @@ snapshots:
dependencies: dependencies:
'@next/env': 16.1.7 '@next/env': 16.1.7
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.12 baseline-browser-mapping: 2.10.15
caniuse-lite: 1.0.30001782 caniuse-lite: 1.0.30001786
postcss: 8.4.31 postcss: 8.4.31
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
@@ -6621,7 +6616,7 @@ snapshots:
object.entries: 1.1.9 object.entries: 1.1.9
semver: 6.3.1 semver: 6.3.1
node-releases@2.0.36: {} node-releases@2.0.37: {}
node-rsa@0.4.2: node-rsa@0.4.2:
dependencies: dependencies:
@@ -7047,12 +7042,6 @@ snapshots:
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@2.0.0-next.6: resolve@2.0.0-next.6:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -7120,7 +7109,7 @@ snapshots:
hook-std: 4.0.0 hook-std: 4.0.0
hosted-git-info: 9.0.2 hosted-git-info: 9.0.2
import-from-esm: 2.0.0 import-from-esm: 2.0.0
lodash-es: 4.17.23 lodash-es: 4.18.1
marked: 15.0.12 marked: 15.0.12
marked-terminal: 7.3.0(marked@15.0.12) marked-terminal: 7.3.0(marked@15.0.12)
micromatch: 4.0.8 micromatch: 4.0.8
@@ -7631,7 +7620,7 @@ snapshots:
undici@6.24.1: {} undici@6.24.1: {}
undici@7.24.6: {} undici@7.24.7: {}
unicode-emoji-modifier-base@1.0.0: {} unicode-emoji-modifier-base@1.0.0: {}