Fix bug pull request #28
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
import CartFab from "@/components/CartFab";
|
||||
import { CartFab } from "@/components/organisms/cart";
|
||||
import Footer from "@/layouts/footer";
|
||||
import Header from "@/layouts/header";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
+38
-386
@@ -9,12 +9,8 @@
|
||||
components/
|
||||
├── atoms/ # Basic building blocks (Button, Input, Text, Badge...)
|
||||
├── molecules/ # Groups of atoms (ProductCard, SearchBar...)
|
||||
├── organisms/ # Complex UI sections (CategorySidebar, CartFab, ProductGrid, ReviewModal...)
|
||||
├── templates/ # Page layout structures (MainLayout, AuthLayout...)
|
||||
├── CartProduct.tsx # Legacy — use molecules/cards/ProductCard instead
|
||||
├── Navbar.tsx # Legacy — use organisms/navigation/CategorySidebar instead
|
||||
├── CartFab.tsx # Legacy — use organisms/cart/CartFab instead
|
||||
└── ReviewModal.tsx # Legacy — use organisms/modals/ReviewModal instead
|
||||
├── organisms/ # Complex UI sections (CategorySidebar, CartFab, ProductGrid, ReviewModal, analytics charts...)
|
||||
└── templates/ # Page layout structures (MainLayout, AuthLayout...)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -23,11 +19,14 @@ 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), not the card itself.
|
||||
**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.
|
||||
|
||||
### SearchBar (`molecules/search-bar/SearchBar.tsx`)
|
||||
|
||||
**Description:** Search input with clear button. Controlled component — value and onChange come from parent.
|
||||
@@ -48,10 +47,28 @@ Width is controlled by the parent grid (w-full), not the card itself.
|
||||
|
||||
**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.
|
||||
|
||||
### ReviewModal (`organisms/modals/ReviewModal.tsx`)
|
||||
|
||||
**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.
|
||||
|
||||
| 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 |
|
||||
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
|
||||
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
|
||||
|
||||
**Usage:** Imported via `@/components/organisms/analytics` barrel index.
|
||||
|
||||
---
|
||||
|
||||
## TEMPLATES
|
||||
@@ -64,395 +81,30 @@ Width is controlled by the parent grid (w-full), not the card itself.
|
||||
|
||||
**Description:** Auth layout template — centers content in screen. Used by login/register pages.
|
||||
|
||||
---
|
||||
### FeedLayout (`templates/feed-layout/FeedLayout.tsx`)
|
||||
|
||||
## LEGACY COMPONENTS (root level — kept for backward compatibility)
|
||||
**Description:** Feed layout template. Used by the feed/discovery route group.
|
||||
|
||||
### CartProduct
|
||||
### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`)
|
||||
|
||||
**File:** components/CartProduct.tsx **Description:** Product card component.
|
||||
Displays product image, name, description, formatted price, and a Buy button.
|
||||
Width is controlled by the parent grid (w-full), not the card itself.
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ----------- | ---------------- | -------- | ------------ | ----------------------------------------------------------- |
|
||||
| image | string | yes | - | URL/path to product image (Next.js Image) |
|
||||
| imageAlt | string | no | Anh san pham | Alt text for accessibility |
|
||||
| productName | string | yes | - | Product display name |
|
||||
| price | number or string | yes | - | If number: auto-formatted to VND. If string: rendered as-is |
|
||||
| description | string | yes | - | Short description, clamped to 2 lines |
|
||||
| onBuy | () => void | no | undefined | Callback when Buy button is clicked |
|
||||
|
||||
### Internal Logic
|
||||
|
||||
- formattedPrice: number -> toLocaleString(vi-VN, { style: currency, currency:
|
||||
VND })
|
||||
- Image fallback: fa-solid fa-mug-hot icon shown behind image; if image fails
|
||||
onError hides the img element
|
||||
|
||||
### Styling (CSS variables)
|
||||
|
||||
| Element | Key classes |
|
||||
| ------------ | ------------------------------------------------------------------ |
|
||||
| Card wrapper | flex flex-col w-full rounded-2xl, shadow uses --color-shadow-sm/md |
|
||||
| Image area | relative w-full h-36, bg --color-border-light |
|
||||
| Product name | font-bold text-sm, color --color-text-primary, line-clamp-1 |
|
||||
| Description | text-xs, color --color-text-muted, line-clamp-2 |
|
||||
| Price | text-sm font-bold, color --color-primary |
|
||||
| Buy button | bg --color-primary, hover --color-primary-dark, active:scale-95 |
|
||||
|
||||
### Dependencies
|
||||
|
||||
- next/image
|
||||
- Tailwind CSS + CSS custom properties from globals.css
|
||||
- FontAwesome (fa-solid fa-mug-hot fallback, fa-cart-plus button icon)
|
||||
|
||||
### Notes
|
||||
|
||||
- Card width is w-full - controlled by parent grid in page.tsx
|
||||
- available field on Product is checked in page.tsx before rendering
|
||||
- onBuy currently logs to console - TODO: implement cart logic
|
||||
**Description:** Manager layout template — auth guard + ManagerProvider. Used by the manager route group.
|
||||
|
||||
---
|
||||
|
||||
## Navbar
|
||||
## Contexts Documentation
|
||||
|
||||
**File:** components/Navbar.tsx **Description:** Left sidebar with collapsible
|
||||
category filter. Sticky below header, full viewport height minus header.
|
||||
### AuthContext (`lib/auth-context.tsx`)
|
||||
|
||||
### Props
|
||||
Manages user authentication state including login, logout, and registration. Uses localStorage for persistence.
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ---------------- | -------------------- | -------- | --------- | ------------------------------------------------- |
|
||||
| isOpen | boolean | yes | - | true = expanded (240px), false = collapsed (64px) |
|
||||
| onToggle | () => void | yes | - | Toggle expand/collapse |
|
||||
| activeCategory | string | no | all | Currently selected category id |
|
||||
| onCategoryChange | (id: string) => void | no | undefined | Fired when user clicks a category |
|
||||
### CartContext (`lib/cart-context.tsx`)
|
||||
|
||||
### Behavior
|
||||
Manages shopping cart state with localStorage persistence. Tracks items, quantities, and totals.
|
||||
|
||||
- Collapsed: 64px wide, icon only (w-16)
|
||||
- Expanded: 240px wide, icon + label (w-60)
|
||||
- Width transition: transition-all duration-250ms
|
||||
- Active category: highlighted with --color-primary background
|
||||
- Footer shows SHOP_INFO.openHours (icon only when collapsed)
|
||||
### MenuContext (`lib/menu-context.tsx`)
|
||||
|
||||
### Styling
|
||||
Provides shared activeCategory state across components. Synchronizes Header mobile menu and CategorySidebar selection.
|
||||
|
||||
| Element | Key classes |
|
||||
| ------------- | ----------------------------------------------------------- |
|
||||
| Aside | sticky, border-r --color-border, bg --color-bg-sidebar |
|
||||
| Toggle button | w-8 h-8 rounded-lg, hover bg --color-border-light |
|
||||
| Active item | bg --color-primary text-white shadow-sm |
|
||||
| Inactive item | hover bg --color-border-light, color --color-text-secondary |
|
||||
### ManagerContext (`lib/manager-context.tsx`)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- next/link
|
||||
- lib/constants: MENU_CATEGORIES, SHOP_INFO
|
||||
- lib/types: MenuCategory
|
||||
- FontAwesome icons
|
||||
|
||||
---
|
||||
|
||||
## Header (layouts/header.tsx)
|
||||
|
||||
**File:** layouts/header.tsx **Description:** Sticky top bar. 2-column layout:
|
||||
Brand (left) + Auth button (right). Auth cycles Guest -> Manager -> Staff ->
|
||||
Guest for UI demo.
|
||||
|
||||
### Props
|
||||
|
||||
None - reads SHOP_INFO and MOCK_USERS from lib/constants directly.
|
||||
|
||||
### Internal State
|
||||
|
||||
| State | Type | Description |
|
||||
| ----- | ------------ | ------------------------------- |
|
||||
| user | User or null | Current demo user. null = guest |
|
||||
|
||||
### Auth States
|
||||
|
||||
| State | Appearance |
|
||||
| ------------ | ------------------------------------- |
|
||||
| Guest (null) | Brown primary button, Dang nhap label |
|
||||
| Manager | Gold/caramel badge with user-tie icon |
|
||||
| Staff | Avatar circle + name, bordered button |
|
||||
|
||||
### Responsive
|
||||
|
||||
- Logo + shop name: always visible
|
||||
- Tagline: hidden < md, shown md+
|
||||
- Button label: hidden < sm, shown sm+
|
||||
|
||||
### Dependencies
|
||||
|
||||
- next/image, next/link
|
||||
- lib/constants: SHOP_INFO, MOCK_USERS
|
||||
- lib/types: User
|
||||
- FontAwesome icons
|
||||
|
||||
---
|
||||
|
||||
## Footer (layouts/footer.tsx)
|
||||
|
||||
**File:** layouts/footer.tsx **Description:** Site footer with 12-column grid. 3
|
||||
sections: Brand info, Social links, WiFi card.
|
||||
|
||||
### Props
|
||||
|
||||
None - reads SHOP_INFO and SOCIAL_LINKS from lib/constants directly.
|
||||
|
||||
### Layout
|
||||
|
||||
| Section | Mobile | md | lg/xl |
|
||||
| ------------- | ----------- | ---------- | ------------ |
|
||||
| Brand info | col-span-12 | col-span-6 | col-span-8/6 |
|
||||
| Social + WiFi | col-span-12 | col-span-6 | col-span-4/6 |
|
||||
|
||||
### Sections
|
||||
|
||||
1. Brand: logo, name, tagline, address, phone, email, open hours
|
||||
2. Social: Facebook, TikTok, Website links
|
||||
3. WiFi: network name + password in monospace styled box
|
||||
4. Bottom bar: copyright + Made with heart in Vietnam
|
||||
|
||||
### Dependencies
|
||||
|
||||
- next/image, next/link
|
||||
- lib/constants: SHOP_INFO, SOCIAL_LINKS
|
||||
- FontAwesome icons
|
||||
|
||||
---
|
||||
|
||||
## CartFab
|
||||
|
||||
**File:** components/CartFab.tsx **Description:** Floating Action Button
|
||||
displaying cart item count. Shows badge with number of items and total price on
|
||||
hover.
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ------- | ---------- | -------- | --------- | ---------------------------- |
|
||||
| onClick | () => void | no | undefined | Callback when FAB is clicked |
|
||||
|
||||
### Features
|
||||
|
||||
- Displays cart icon with item count badge
|
||||
- Shows total price on hover in tooltip
|
||||
- Sticky position (bottom-right)
|
||||
- Uses cart context to get items and total
|
||||
|
||||
### Styling
|
||||
|
||||
| Element | Key classes |
|
||||
| ---------- | ----------------------------------------------- |
|
||||
| FAB button | fixed bottom-6 right-6, rounded-full, shadow-lg |
|
||||
| Badge | absolute top-0 right-0, red bg, small font |
|
||||
| Tooltip | appears on hover, shows total price |
|
||||
|
||||
### Dependencies
|
||||
|
||||
- lib/cart-context: useCart()
|
||||
- FontAwesome icons
|
||||
|
||||
---
|
||||
|
||||
## ReviewModal
|
||||
|
||||
**File:** components/ReviewModal.tsx **Description:** Modal for customer
|
||||
reviews. Shows a 5-star rating selector and a textarea for written feedback.
|
||||
After submission, displays a thank-you message. Appears on the payment page for
|
||||
customers (via a dedicated "Đánh giá" button) and also opens automatically after
|
||||
a successful payment action.
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ------- | ---------- | -------- | ------- | ----------------------------------------- |
|
||||
| isOpen | boolean | yes | - | Controls modal visibility |
|
||||
| onClose | () => void | yes | - | Callback to close the modal & reset state |
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Star rating:** 5 interactive stars; hover highlights up to hovered star,
|
||||
click locks selection. Disabled submit button until at least 1 star selected.
|
||||
- **Star labels:** 1 = Rất tệ, 2 = Tệ, 3 = Bình thường, 4 = Tốt, 5 = Xuất sắc
|
||||
- **Textarea:** Optional free-text review (placeholder in Vietnamese).
|
||||
- **Footer buttons:**
|
||||
- "Quay lại" — closes modal without submitting; resets all state.
|
||||
- "Xác nhận" — submits (UI-only); transitions to thank-you state.
|
||||
- **Thank-you state:** Replaces form with "Cảm ơn quý khách" message + close
|
||||
button.
|
||||
- **Backdrop click:** Also closes the modal (same as "Quay lại").
|
||||
- **State reset:** All state (rating, hover, review text, submitted) resets on
|
||||
close.
|
||||
|
||||
### Styling
|
||||
|
||||
| Element | Key classes |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| Backdrop | fixed inset-0, bg-black/50 backdrop-blur-sm, z-50 |
|
||||
| Modal panel | max-w-md, rounded-2xl, border --color-border-light, white bg, shadow-xl |
|
||||
| Stars | text-3xl sm:text-4xl, hover:scale-110, active:scale-95 |
|
||||
| Active star | fa-solid fa-star text-yellow-400 |
|
||||
| Inactive star | fa-regular fa-star text-(--color-border) |
|
||||
| Textarea | rounded-xl, focus:ring with --color-primary/20 |
|
||||
| Quay lại button | border --color-border, hover --color-border-light |
|
||||
| Xác nhận button | bg --color-primary, disabled:opacity-50 |
|
||||
| Thank-you icon | fa-solid fa-heart text-(--color-accent), --color-accent-light bg |
|
||||
|
||||
### Responsive
|
||||
|
||||
- Padding: `p-6 sm:p-8` — larger on sm+ screens
|
||||
- Stars: `text-3xl sm:text-4xl`
|
||||
- Modal width: `w-full max-w-md` with `p-4` page padding — works on all screen
|
||||
sizes
|
||||
|
||||
### Dependencies
|
||||
|
||||
- React useState
|
||||
- FontAwesome icons (fa-star, fa-regular fa-star, fa-heart, fa-arrow-left,
|
||||
fa-check)
|
||||
- Tailwind CSS + CSS custom properties from globals.css
|
||||
|
||||
### Usage in Payment Page
|
||||
|
||||
```tsx
|
||||
// Only shown for customers (user.role === "customer")
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
|
||||
// Button in payment aside (only visible to customers)
|
||||
<button onClick={() => setIsReviewOpen(true)}>Đánh giá</button>;
|
||||
|
||||
// Payment buttons (Tiền mặt / QR Code) also open the modal for customers
|
||||
const handlePayment = () => {
|
||||
if (isCustomer) setIsReviewOpen(true);
|
||||
};
|
||||
|
||||
<ReviewModal isOpen={isReviewOpen} onClose={() => setIsReviewOpen(false)} />;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Contexts Documentation
|
||||
|
||||
## AuthContext (lib/auth-context.tsx)
|
||||
|
||||
**File:** lib/auth-context.tsx **Description:** Manages user authentication
|
||||
state including login, logout, and registration. Uses localStorage for
|
||||
persistence.
|
||||
|
||||
### Provider Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| -------- | --------------- | -------- | ---------------- |
|
||||
| children | React.ReactNode | yes | Child components |
|
||||
|
||||
### Hook: useAuth()
|
||||
|
||||
Returns `AuthContextType` with:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------------- | ----------------------------------------------- | ------------------------------------------------- |
|
||||
| user | User \| null | Current logged-in user or null |
|
||||
| login | (username: string, password: string) => boolean | Login function; returns success status |
|
||||
| logout | () => void | Logout function; clears user and localStorage |
|
||||
| registerPhone | string \| null | Phone number during registration flow |
|
||||
| setRegisterPhone | (phone: string \| null) => void | Update registerPhone state |
|
||||
| completeRegistration | (phone: string) => void | Complete registration and create customer account |
|
||||
|
||||
### Mock Database
|
||||
|
||||
Pre-configured accounts:
|
||||
|
||||
- Manager: `admin / admin`
|
||||
- Staff: `Nguyễn Văn An / Nguyễn Văn An`, `Trần Thị Bình / Trần Thị Bình`, etc.
|
||||
- Customer: Phone number as username, `user1` as password
|
||||
|
||||
### Storage
|
||||
|
||||
- Key: `coffee-shop-user`
|
||||
- Format: JSON serialized User object
|
||||
|
||||
---
|
||||
|
||||
## CartContext (lib/cart-context.tsx)
|
||||
|
||||
**File:** lib/cart-context.tsx **Description:** Manages shopping cart state with
|
||||
localStorage persistence. Tracks items, quantities, and totals.
|
||||
|
||||
### Provider Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| -------- | --------------- | -------- | ---------------- |
|
||||
| children | React.ReactNode | yes | Child components |
|
||||
|
||||
### Hook: useCart()
|
||||
|
||||
Returns `CartContextValue` with:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------- | -------------------------------------- | --------------------------------------------------------- |
|
||||
| items | CartItem[] | Array of items in cart |
|
||||
| totalItems | number | Total quantity of items |
|
||||
| totalPrice | number | Total price in VND |
|
||||
| addToCart | (product: Product) => void | Add or increase product quantity |
|
||||
| increaseQty | (id: number) => void | Increase product quantity by 1 |
|
||||
| decreaseQty | (id: number) => void | Decrease product quantity by 1 (removes if qty reaches 0) |
|
||||
| removeFromCart | (id: number) => void | Remove product from cart |
|
||||
| setQuantity | (id: number, quantity: number) => void | Set exact quantity (removes if 0) |
|
||||
|
||||
### CartItem Interface
|
||||
|
||||
```typescript
|
||||
interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
- Key: `coffee-shop-cart`
|
||||
- Format: JSON serialized CartItem[]
|
||||
- Auto-loads on mount and auto-saves on change
|
||||
|
||||
---
|
||||
|
||||
## MenuContext (lib/menu-context.tsx)
|
||||
|
||||
**File:** lib/menu-context.tsx **Description:** Provides shared category/menu
|
||||
state across components. Synchronizes Header mobile menu and Navbar sidebar
|
||||
selection.
|
||||
|
||||
### Provider Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| -------- | --------------- | -------- | ---------------- |
|
||||
| children | React.ReactNode | yes | Child components |
|
||||
|
||||
### Hook: useMenu()
|
||||
|
||||
Returns `MenuContextType` with:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------------- | -------------------- | ----------------------------------------------- |
|
||||
| activeCategory | string | Currently selected category id (default: "all") |
|
||||
| setActiveCategory | (id: string) => void | Update active category |
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Sync Navbar sidebar and Header mobile menu category selection
|
||||
- Clear search query when category changes (implemented in main page)
|
||||
- Pass selected category to product filter logic
|
||||
|
||||
### Default Value
|
||||
|
||||
- `activeCategory: "all"` - Show all products by default
|
||||
Manages menu CRUD state (products, combos, categories) for the manager dashboard.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CartFab() {
|
||||
const { totalItems } = useCart();
|
||||
|
||||
if (totalItems <= 0) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/payment"
|
||||
aria-label="Đi đến trang thanh toán"
|
||||
className="fixed right-5 bottom-6 z-70 flex h-14 w-14 items-center justify-center rounded-full bg-(--color-primary) text-white shadow-xl transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
||||
>
|
||||
<i className="fa-solid fa-cart-shopping text-lg"></i>
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-6 min-w-6 items-center justify-center rounded-full border-2 border-white bg-(--color-accent) px-1.5 text-xs font-bold text-(--color-primary-dark)">
|
||||
{totalItems}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Caption, Text } from "@/components/atoms";
|
||||
import Image from "next/image";
|
||||
|
||||
interface CartProductProps {
|
||||
image: string;
|
||||
imageAlt?: string;
|
||||
productName: string;
|
||||
price: number | string;
|
||||
description: string;
|
||||
onBuy?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product card — fills the parent grid cell width (w-full).
|
||||
*
|
||||
* Layout (top → bottom):
|
||||
* 1. Image area (fixed height h-36) with coffee-mug fallback icon
|
||||
* 2. Name + description (flex-1, grows to fill space)
|
||||
* 3. Price + Buy button row (pinned to bottom)
|
||||
*
|
||||
* Responsive: card width is controlled by the parent grid, not the card itself.
|
||||
*/
|
||||
export default function CartProduct({
|
||||
image,
|
||||
imageAlt = "Ảnh sản phẩm",
|
||||
productName,
|
||||
price,
|
||||
description,
|
||||
onBuy,
|
||||
}: CartProductProps) {
|
||||
const formattedPrice =
|
||||
typeof price === "number"
|
||||
? price.toLocaleString("vi-VN", { style: "currency", currency: "VND" })
|
||||
: price;
|
||||
|
||||
return (
|
||||
<div className="flex w-full cursor-default flex-col overflow-hidden rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) shadow-[0_2px_8px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-0.5 hover:shadow-[0_6px_20px_var(--color-shadow-md)]">
|
||||
{/* ── Image area ── */}
|
||||
<div className="relative h-36 w-full shrink-0 overflow-hidden bg-(--color-border-light)">
|
||||
{/* Fallback icon (shown when image fails or is missing) */}
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center text-4xl text-(--color-border)">
|
||||
<i className="fa-solid fa-mug-hot"></i>
|
||||
</div>
|
||||
{/* Product image */}
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
className="z-1 object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Name + description ── */}
|
||||
<div className="flex flex-1 flex-col gap-1 px-3 pt-2.5 pb-1.5">
|
||||
<h3 className="text-foreground line-clamp-1 text-sm leading-tight font-bold">
|
||||
{productName}
|
||||
</h3>
|
||||
<Caption className="line-clamp-2">{description}</Caption>
|
||||
</div>
|
||||
|
||||
{/* ── Price + Buy button ── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--color-border-light) px-3 py-2.5">
|
||||
<Text variant="body2" className="font-bold">
|
||||
{formattedPrice}
|
||||
</Text>
|
||||
<Button onClick={onBuy} variant="primary" size="sm" icon="fa-cart-plus">
|
||||
Mua
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
|
||||
import type { MenuCategory } from "@/lib/types";
|
||||
|
||||
interface NavbarProps {
|
||||
/** Whether the sidebar is expanded (true) or icon-only (false) */
|
||||
isOpen: boolean;
|
||||
/** Toggle expand / collapse */
|
||||
onToggle: () => void;
|
||||
/** Currently selected category id */
|
||||
activeCategory?: string;
|
||||
/** Fired when user clicks a category */
|
||||
onCategoryChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left sidebar — always visible, collapsible on all screen sizes.
|
||||
*
|
||||
* Collapsed → 64 px wide, icon only
|
||||
* Expanded → 240 px wide, icon + label
|
||||
*
|
||||
* Width transition is handled by Tailwind w-16 / w-60 + transition-all.
|
||||
* Parent controls open/close state via isOpen + onToggle props.
|
||||
*/
|
||||
export default function Navbar({
|
||||
isOpen,
|
||||
onToggle,
|
||||
activeCategory = "all",
|
||||
onCategoryChange,
|
||||
}: NavbarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`sticky z-20 hidden shrink-0 flex-col overflow-x-hidden overflow-y-auto border-r border-(--color-border) bg-(--color-bg-sidebar) transition-all duration-250 ease-in-out md:flex xl:w-60 ${isOpen ? "w-60" : "w-16"} `}
|
||||
style={
|
||||
{
|
||||
top: "var(--spacing-header-height)",
|
||||
height: "calc(100vh - var(--spacing-header-height))",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* ── Sidebar header: title + toggle button ── */}
|
||||
<div
|
||||
className={`flex shrink-0 items-center border-b border-(--color-border) xl:justify-between xl:px-4 xl:py-3 ${isOpen ? "justify-between px-4 py-3" : "justify-center px-0 py-3"} `}
|
||||
>
|
||||
{/* Title — shown when expanded, always shown on xl+ */}
|
||||
<span
|
||||
className={`text-xs font-bold tracking-widest whitespace-nowrap text-(--color-text-muted) uppercase ${isOpen ? "block" : "hidden"} xl:block`}
|
||||
>
|
||||
<i className="fa-solid fa-utensils mr-2 text-(--color-primary)"></i>
|
||||
Thực Đơn
|
||||
</span>
|
||||
|
||||
{/* Toggle button — hidden on xl+ (sidebar is always expanded there) */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) transition-colors duration-150 hover:bg-(--color-border-light) hover:text-(--color-primary) xl:hidden"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid text-sm transition-transform duration-250 ${
|
||||
isOpen ? "fa-chevron-left" : "fa-chevron-right"
|
||||
}`}
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Category list ── */}
|
||||
<nav className="flex-1 py-2">
|
||||
<ul className="flex flex-col gap-0.5 px-2">
|
||||
{MENU_CATEGORIES.map((cat: MenuCategory) => {
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<li key={cat.id}>
|
||||
<button
|
||||
onClick={() => onCategoryChange?.(cat.id)}
|
||||
title={!isOpen ? cat.name : undefined}
|
||||
className={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${
|
||||
isActive
|
||||
? "bg-(--color-primary) text-white shadow-sm"
|
||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
||||
} `}
|
||||
>
|
||||
{/* Icon */}
|
||||
<i
|
||||
className={` ${cat.icon} w-5 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
||||
></i>
|
||||
|
||||
{/* Label — hidden when collapsed, always shown on xl+ */}
|
||||
<span
|
||||
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isOpen ? "block" : "hidden"} xl:block`}
|
||||
>
|
||||
{cat.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* ── Sidebar footer: opening hours ── */}
|
||||
<div
|
||||
className={`shrink-0 border-t border-(--color-border) py-3 xl:px-4 ${isOpen ? "px-4" : "flex justify-center px-0"} `}
|
||||
>
|
||||
{/* Text row — shown when expanded, always shown on xl+ */}
|
||||
<div
|
||||
className={`items-center gap-2 text-xs text-(--color-text-muted) ${isOpen ? "flex" : "hidden"} xl:flex`}
|
||||
>
|
||||
<i className="fa-solid fa-clock shrink-0 text-(--color-accent)"></i>
|
||||
<span>{SHOP_INFO.openHours}</span>
|
||||
</div>
|
||||
|
||||
{/* Icon-only — shown when collapsed, hidden on xl+ */}
|
||||
<span className="xl:hidden">
|
||||
<i
|
||||
className={`fa-solid fa-clock text-sm text-(--color-text-muted) ${isOpen ? "hidden" : "block"}`}
|
||||
title={SHOP_INFO.openHours}
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Caption, Heading, Text, Textarea } from "@/components/atoms";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ReviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hovered, setHovered] = useState(0);
|
||||
const [review, setReview] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state when closing
|
||||
setRating(0);
|
||||
setHovered(0);
|
||||
setReview("");
|
||||
setSubmitted(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="review-modal-title"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-(--color-border-light) bg-white p-6 shadow-xl sm:p-8">
|
||||
{submitted ? (
|
||||
/* Thank you state */
|
||||
<div className="flex flex-col items-center gap-4 py-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-(--color-accent-light) text-3xl">
|
||||
<i className="fa-solid fa-heart text-(--color-accent)"></i>
|
||||
</div>
|
||||
<Heading level={2} id="review-modal-title">
|
||||
Cảm ơn quý khách
|
||||
</Heading>
|
||||
<Text variant="body2" className="mt-2">
|
||||
Chúng tôi trân trọng đánh giá của bạn!
|
||||
</Text>
|
||||
<Button onClick={handleClose} variant="primary" className="mt-4">
|
||||
Đóng
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Review form */
|
||||
<>
|
||||
<h2
|
||||
id="review-modal-title"
|
||||
className="text-foreground mb-1 text-xl font-bold"
|
||||
>
|
||||
Đánh giá của bạn
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-(--color-text-muted)">
|
||||
Hãy cho chúng tôi biết trải nghiệm của bạn hôm nay
|
||||
</p>
|
||||
|
||||
{/* Star rating */}
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
|
||||
Mức độ hài lòng
|
||||
</p>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Xếp hạng sao"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isActive = star <= (hovered || rating);
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHovered(star)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
aria-label={`${star} sao`}
|
||||
aria-pressed={rating === star}
|
||||
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isActive
|
||||
? "fa-solid fa-star text-yellow-400"
|
||||
: "fa-regular fa-star text-(--color-border)"
|
||||
}
|
||||
></i>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{rating > 0 && (
|
||||
<p className="mt-1.5 text-xs text-(--color-text-muted)">
|
||||
{
|
||||
["", "Rất tệ", "Tệ", "Bình thường", "Tốt", "Xuất sắc"][
|
||||
rating
|
||||
]
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review textarea */}
|
||||
<div className="mb-6">
|
||||
<Textarea
|
||||
id="review-text"
|
||||
label="Nhận xét (tùy chọn)"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
icon="fa-arrow-left"
|
||||
>
|
||||
Quay lại
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0}
|
||||
className="flex-1"
|
||||
icon="fa-check"
|
||||
>
|
||||
Xác nhận
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { formatCurrency } from "@/lib/analytics-utils";
|
||||
import type { RevenueDataPoint } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BarChartProps {
|
||||
current: RevenueDataPoint[];
|
||||
previous: RevenueDataPoint[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-SVG grouped bar chart comparing current vs previous period revenue.
|
||||
* Hover bars show tooltip with label, revenue, and order count.
|
||||
* 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 W = 800;
|
||||
const H = height;
|
||||
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 groupW = chartW / n;
|
||||
const barW = groupW * 0.35;
|
||||
const gap = groupW * 0.05;
|
||||
|
||||
const yTicks = 5;
|
||||
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
|
||||
val: (maxVal / yTicks) * (yTicks - i),
|
||||
y: padT + (i / yTicks) * chartH,
|
||||
}));
|
||||
|
||||
const step = Math.ceil(n / 8);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ height: H, minWidth: 320 }}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{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>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{current.map((d, i) => {
|
||||
const groupX = padL + i * groupW;
|
||||
const curH = (d.revenue / maxVal) * chartH;
|
||||
const prevH = ((previous[i]?.revenue ?? 0) / maxVal) * chartH;
|
||||
const curX = groupX + gap;
|
||||
const prevX = curX + barW + gap;
|
||||
const isHovCur = hovered?.set === "cur" && hovered.idx === i;
|
||||
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"
|
||||
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"
|
||||
fill={isHovCur ? "#4A3728" : "#6F4E37"}
|
||||
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
||||
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>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{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 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);
|
||||
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">
|
||||
{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>
|
||||
</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>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { formatCurrency } from "@/lib/analytics-utils";
|
||||
import type { RevenueDataPoint } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
interface LineChartProps {
|
||||
data: RevenueDataPoint[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-SVG interactive line chart for revenue over time.
|
||||
* Hover dots show tooltip with label, revenue, and order count.
|
||||
* Tooltip auto-flips above/below the dot to stay inside the viewBox.
|
||||
*/
|
||||
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 chartW = W - padL - padR;
|
||||
const chartH = H - padT - padB;
|
||||
|
||||
const maxRev = Math.max(...data.map((d) => d.revenue));
|
||||
const range = maxRev || 1;
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: padL + (i / (data.length - 1)) * chartW,
|
||||
y: padT + chartH - (d.revenue / range) * chartH,
|
||||
data: d,
|
||||
index: i,
|
||||
}));
|
||||
|
||||
const pathD = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
|
||||
.join(" ");
|
||||
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${points[points.length - 1].x.toFixed(1)} ${(padT + chartH).toFixed(1)}` +
|
||||
` L ${points[0].x.toFixed(1)} ${(padT + chartH).toFixed(1)} Z`;
|
||||
|
||||
const yTicks = 5;
|
||||
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
|
||||
val: (range / yTicks) * (yTicks - i),
|
||||
y: padT + (i / yTicks) * chartH,
|
||||
}));
|
||||
|
||||
const step = Math.ceil(data.length / 10);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ height: H, minWidth: 320 }}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#6F4E37" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#6F4E37" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{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>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<path d={areaD} fill="url(#areaGrad)" />
|
||||
<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>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
key={p.index}
|
||||
cx={p.x} cy={p.y}
|
||||
r={hovered === p.index ? 5 : 3}
|
||||
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
|
||||
stroke="#FDF6EC" strokeWidth="2"
|
||||
style={{ cursor: "pointer", transition: "r 150ms" }}
|
||||
onMouseEnter={() => setHovered(p.index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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 aboveY = p.y - tipH - 10;
|
||||
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>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PieChartProps {
|
||||
data: PieSlice[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-SVG interactive pie chart.
|
||||
* Hover a slice or legend item to highlight it and show its percentage.
|
||||
*/
|
||||
export function PieChart({ data }: PieChartProps) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const R = 80;
|
||||
const CX = 110;
|
||||
const CY = 110;
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
||||
|
||||
const slices = useMemo(() => {
|
||||
type Acc = { items: ReturnType<typeof makeSlice>[]; angle: number };
|
||||
|
||||
const makeSlice = (d: PieSlice, i: number, startAngle: number) => {
|
||||
const angle = (d.value / total) * 2 * Math.PI;
|
||||
const endAngle = startAngle + angle;
|
||||
const midAngle = startAngle + angle / 2;
|
||||
const x1 = CX + R * Math.cos(startAngle);
|
||||
const y1 = CY + R * Math.sin(startAngle);
|
||||
const x2 = CX + R * Math.cos(endAngle);
|
||||
const y2 = CY + R * Math.sin(endAngle);
|
||||
const largeArc = angle > Math.PI ? 1 : 0;
|
||||
const pathD = `M ${CX} ${CY} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${R} ${R} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z`;
|
||||
return {
|
||||
...d,
|
||||
pathD,
|
||||
labelX: CX + R * 0.65 * Math.cos(midAngle),
|
||||
labelY: CY + R * 0.65 * Math.sin(midAngle),
|
||||
percent: (d.value / total) * 100,
|
||||
index: i,
|
||||
endAngle,
|
||||
};
|
||||
};
|
||||
|
||||
const { items } = data.reduce<Acc>(
|
||||
(acc, d, i) => {
|
||||
const slice = makeSlice(d, i, acc.angle);
|
||||
return { items: [...acc.items, slice], angle: slice.endAngle };
|
||||
},
|
||||
{ items: [], angle: -Math.PI / 2 },
|
||||
);
|
||||
return items;
|
||||
}, [data, total]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
||||
<svg
|
||||
viewBox="0 0 220 220"
|
||||
className="w-full max-w-55 shrink-0"
|
||||
style={{ height: 220 }}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<path
|
||||
key={s.index}
|
||||
d={s.pathD}
|
||||
fill={s.color}
|
||||
stroke="#FDF6EC"
|
||||
strokeWidth="2"
|
||||
style={{ cursor: "pointer", transition: "opacity 200ms" }}
|
||||
onMouseEnter={() => setHovered(s.index)}
|
||||
opacity={hovered !== null && hovered !== s.index ? 0.65 : 1}
|
||||
/>
|
||||
))}
|
||||
{hovered !== null && (
|
||||
<text x={CX} y={CY + 5} textAnchor="middle" fontSize="12" fontWeight="bold" fill="#3D2B1F">
|
||||
{slices[hovered].percent.toFixed(1)}%
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-col">
|
||||
{slices.map((s) => (
|
||||
<div
|
||||
key={s.index}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
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="max-w-35 truncate"
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { formatCurrencyFull } from "@/lib/analytics-utils";
|
||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||
import type { ProductSalesStats } from "@/lib/types";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface ProductTableProps {
|
||||
data: ProductSalesStats[];
|
||||
}
|
||||
|
||||
const categoryName = (id: string) =>
|
||||
MENU_CATEGORIES.find((c) => c.id === id)?.name ?? id;
|
||||
|
||||
/**
|
||||
* Sortable product sales table.
|
||||
* Click column headers to sort ascending/descending.
|
||||
*/
|
||||
export function ProductTable({ data }: ProductTableProps) {
|
||||
const [sortKey, setSortKey] = useState<keyof ProductSalesStats>("revenue");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...data].sort((a, b) => {
|
||||
const av = a[sortKey] as number;
|
||||
const bv = b[sortKey] as number;
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
}),
|
||||
[data, sortKey, sortDir],
|
||||
);
|
||||
|
||||
const handleSort = (key: keyof ProductSalesStats) => {
|
||||
if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
else { setSortKey(key); setSortDir("desc"); }
|
||||
};
|
||||
|
||||
const sortIcon = (col: keyof ProductSalesStats) => (
|
||||
<i className={`fa-solid ml-1 text-xs ${
|
||||
sortKey === col
|
||||
? 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 }) => (
|
||||
<th
|
||||
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
{label} {sortIcon(col)}
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-(--color-border-light)">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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="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="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"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}>
|
||||
{row.profitMargin.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { formatCurrency } from "@/lib/analytics-utils";
|
||||
|
||||
export interface SummaryCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
value: string;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
isPositive: boolean;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary metric card with period-over-period comparison indicator.
|
||||
* Used in the Financial Analytics dashboard header row.
|
||||
*/
|
||||
export function SummaryCard({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changePercent,
|
||||
isPositive,
|
||||
subtitle,
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-xs text-(--color-text-muted)">{subtitle}</p>
|
||||
)}
|
||||
<div
|
||||
className={`mt-3 flex items-center gap-1.5 text-sm font-medium ${
|
||||
isPositive ? "text-green-600" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid text-xs ${
|
||||
isPositive ? "fa-arrow-trend-up" : "fa-arrow-trend-down"
|
||||
}`}
|
||||
></i>
|
||||
<span>
|
||||
{isPositive ? "+" : ""}
|
||||
{changePercent.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs font-normal text-(--color-text-muted)">
|
||||
({isPositive ? "+" : ""}
|
||||
{formatCurrency(change)}) so với kỳ trước
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { BarChart } from "./BarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { PieChart } from "./PieChart";
|
||||
export type { PieSlice } from "./PieChart";
|
||||
export { ProductTable } from "./ProductTable";
|
||||
export { SummaryCard } from "./SummaryCard";
|
||||
export type { SummaryCardProps } from "./SummaryCard";
|
||||
@@ -0,0 +1,29 @@
|
||||
// ─── Analytics Helper Utilities ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a number as a short Vietnamese currency string.
|
||||
* e.g. 1_500_000 → "1.5 tr", 25_000 → "25 k"
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + " tỷ";
|
||||
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + " tr";
|
||||
if (value >= 1_000) return (value / 1_000).toFixed(0) + " k";
|
||||
return value.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as a full Vietnamese currency string.
|
||||
* e.g. 25_000 → "25.000đ"
|
||||
*/
|
||||
export function formatCurrencyFull(value: number): string {
|
||||
return value.toLocaleString("vi-VN") + "đ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate period-over-period change between two values.
|
||||
*/
|
||||
export function calcChange(current: number, previous: number) {
|
||||
const change = current - previous;
|
||||
const changePercent = previous === 0 ? 0 : (change / previous) * 100;
|
||||
return { change, changePercent, isPositive: change >= 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user