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