Tái cấu trúc code

This commit is contained in:
Thanh Quy - wolf
2026-04-03 20:09:05 +07:00
parent 4f3d807c95
commit 50473d799d
60 changed files with 2136 additions and 2709 deletions
+12 -1
View File
@@ -3,7 +3,18 @@
"allow": [ "allow": [
"Bash(npx next build)", "Bash(npx next build)",
"Bash(npx tsc --noEmit)", "Bash(npx tsc --noEmit)",
"Bash(npm run lint)" "Bash(npm run lint)",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(feed\\)/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/payment/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/login/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/register/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(feed\\)/feed/\")",
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/\")",
"Bash(npm run build)",
"Bash(cat \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")"
] ]
} }
} }
-295
View File
@@ -1,295 +0,0 @@
# Atomic Design Documentation Index
**Updated:** 2026-04-03 **Status:** Ready for Implementation **Branch:**
atomic_design
---
## 📚 Documentation Files
### 1. **ATOMIC_REDESIGN_SUMMARY.md** 📋
**What:** High-level executive summary **Length:** ~300 lines **Best for:**
Quick overview, time estimates, benefits **Read time:** 10-15 minutes
✅ Current strengths analysis ✅ Key issues breakdown ✅ Implementation timeline
✅ File structure overview ✅ Next steps
---
### 2. **OPTIMIZATION_PLAN.md** 📘 (MAIN DOCUMENT)
**What:** Comprehensive implementation guide **Length:** ~400+ lines **Best
for:** Detailed planning, technical specs, migration strategy **Read time:**
30-45 minutes
✅ Full current state analysis ✅ Atomic design mapping (all 5 levels) ✅
Phase-by-phase breakdown ✅ Code quality recommendations ✅ Testing strategies
✅ Migration checklist ✅ Success criteria ✅ File structure details
**👉 START HERE for full understanding**
---
### 3. **QUICK_START_ATOMIC.md** ⚡
**What:** Step-by-step implementation guide **Length:** ~200 lines **Best for:**
Getting hands-on immediately **Read time:** 20-30 minutes (implementation 2-3
hours)
✅ Step 1: Button Atom (30 min) ✅ Step 2: TextInput Atom (20 min) ✅ Step 3:
Typography Atoms (45 min) ✅ Step 4: Badge Atom (15 min) ✅ Step 5: Divider Atom
(5 min) ✅ Step 6: Atoms Index (10 min) ✅ Step 7: Replace existing code ✅ Step
8: Verify everything works
**👉 FOLLOW THIS to start building today**
---
### 4. **Atomic.md** (Original)
**What:** Atomic Design pattern specification **Length:** ~600 lines **Best
for:** Understanding the pattern, design principles **Read time:** 45-60 minutes
(reference material)
✅ Pattern overview ✅ Detailed examples ✅ Best practices ✅ Import patterns ✅
Testing guidelines ✅ Accessibility notes
---
## 🗺️ Recommended Reading Order
### For Decision Makers / Team Leads
1. Start: **ATOMIC_REDESIGN_SUMMARY.md** (15 min) → Understand scope and
benefits
2. Reference: **Atomic.md** sections 8-10 (15 min) → Understand best practices
3. Planning: **OPTIMIZATION_PLAN.md** sections "Implementation Priority" (10
min) → Review timeline
**Total: 40 minutes to make informed decision**
---
### For Developers Implementing Now
1. Start: **QUICK_START_ATOMIC.md** (20 min reading + 2-3 hours implementing) →
Build foundation atoms
2. Reference: **OPTIMIZATION_PLAN.md** sections 1-5 (20 min) → Understand full
scope
3. Checklist: **OPTIMIZATION_PLAN.md** section "Migration Checklist" (5 min) →
Track progress
**Total: 1-2 hours reading, ~2-3 hours hands-on, repeat 5x for full
implementation**
---
### For Architects / Code Reviewers
1. Deep dive: **OPTIMIZATION_PLAN.md** (40 min) → Full technical specification
2. Reference: **Atomic.md** (30 min) → Design patterns and standards
3. Implementation: **QUICK_START_ATOMIC.md** (20 min) → Code examples and
structure
**Total: 90 minutes for full understanding**
---
## 📊 Document Comparison
| Document | Length | Audience | Purpose | Depth |
| ------------------------------ | ------ | ---------- | ------------------ | --------- |
| **ATOMIC_REDESIGN_SUMMARY.md** | 300 L | Everyone | Quick overview | Shallow |
| **QUICK_START_ATOMIC.md** | 200 L | Developers | Start implementing | Medium |
| **OPTIMIZATION_PLAN.md** | 400+ L | Architects | Full strategy | Deep |
| **Atomic.md** | 600 L | Designers | Pattern spec | Reference |
---
## 🎯 Quick Links by Question
### "How long will this take?"
**ATOMIC_REDESIGN_SUMMARY.md** → Section "Time Estimates" →
**OPTIMIZATION_PLAN.md** → Section "Implementation Priority"
### "What's wrong with our current code?"
**ATOMIC_REDESIGN_SUMMARY.md** → Section "Key Issues to Fix" →
**OPTIMIZATION_PLAN.md** → Section "Current State Analysis"
### "How do I start?"
**QUICK_START_ATOMIC.md** → Just follow steps 1-8
### "What will the file structure look like?"
**ATOMIC_REDESIGN_SUMMARY.md** → Section "File Structure After Implementation"
**OPTIMIZATION_PLAN.md** → Section "File Structure After Implementation"
### "What are the benefits?"
**ATOMIC_REDESIGN_SUMMARY.md** → Section "Benefits You'll Get" →
**OPTIMIZATION_PLAN.md** → Introduction
### "What design principles should I follow?"
**Atomic.md** → Section "Best Practices" + "Common Patterns"
### "How do I test this?"
**OPTIMIZATION_PLAN.md** → Section "Testing Strategy per Atomic Level"
### "Will this break existing code?"
**OPTIMIZATION_PLAN.md** → Section "Migration Guide" →
**QUICK_START_ATOMIC.md** → Step 7 "Replace Existing Code (Optional, Gradual)"
---
## 📋 Current Project State
**Components to migrate:**
-`components/CartProduct.tsx``molecules/cards/ProductCard.tsx`
-`components/CartFab.tsx``organisms/cart/CartFab.tsx`
-`components/Navbar.tsx``organisms/navigation/Navbar.tsx`
-`components/ReviewModal.tsx``organisms/modals/ReviewModal.tsx`
**Logic to extract:**
- ✅ Search bar → `molecules/search-bar/SearchInput.tsx`
- ✅ Category menu → `organisms/navigation/CategoryMenu.tsx`
- ✅ Product grid → `organisms/product-grid/ProductGrid.tsx`
- ✅ Rating logic → `molecules/ratings/RatingInput.tsx`
**New atoms needed:** 20+ **New molecules needed:** 10+ **New organisms
needed:** 8+ **Templates needed:** 5
---
## 🔄 Implementation Phases
### Phase 1: Atoms (2-3 days)
Foundation library - everything depends on this. **See:** QUICK_START_ATOMIC.md
for hands-on
### Phase 2: Molecules (2-3 days)
Combine atoms into reusable UI components. **See:** OPTIMIZATION_PLAN.md Section
2
### Phase 3: Organisms (2-3 days)
Extract existing logic into organized sections. **See:** OPTIMIZATION_PLAN.md
Section 3
### Phase 4: Templates (1-2 days)
Create reusable page layouts. **See:** OPTIMIZATION_PLAN.md Section 4
### Phase 5: Pages (1 day)
Update pages to use new hierarchy. **See:** OPTIMIZATION_PLAN.md Section 5
**Total:** 8-10 days for full implementation **Quick wins:** 2.5 hours to get
foundation
---
## ✅ Success Metrics
After implementation, verify:
- [ ] 0 components in `components/` root
- [ ] All atoms properly organized
- [ ] All molecules use atoms
- [ ] All organisms use molecules
- [ ] All pages use templates + organisms
- [ ] No lint errors: `npm run lint`
- [ ] Properly formatted: `npm run format`
- [ ] All interactive components work
- [ ] Responsive on all breakpoints
- [ ] Accessibility requirements met
---
## 🚀 Getting Started Today
### Minimum (2.5 hours)
1. Read: **QUICK_START_ATOMIC.md** (20 min)
2. Implement: Steps 1-6 (2+ hours)
3. Verify: Step 8 (10 min)
**Result:** Solid atom foundation
### Recommended (4-5 hours)
1. Read: **ATOMIC_REDESIGN_SUMMARY.md** (15 min)
2. Skim: **OPTIMIZATION_PLAN.md** first sections (15 min)
3. Implement: **QUICK_START_ATOMIC.md** (2+ hours)
4. Plan: Next phases using checklist (30 min)
**Result:** Foundation + clear roadmap
### Comprehensive (90 min + implementation)
1. Read: **ATOMIC_REDESIGN_SUMMARY.md** (15 min)
2. Full read: **OPTIMIZATION_PLAN.md** (40 min)
3. Reference: **Atomic.md** sections 8-10 (15 min)
4. Implement: **QUICK_START_ATOMIC.md** (2+ hours)
5. Plan: All 5 phases (20 min)
**Result:** Full understanding + ready for team
---
## 💬 FAQ
**Q: Do I have to do all 5 phases?** A: No, start with Phase 1 (atoms). Then
decide if you want molecules, organisms, etc.
**Q: Can I do this gradually?** A: Yes! Add atoms/molecules as you create new
components. No deadline.
**Q: Will this break production?** A: No, you can refactor gradually. Existing
code stays unchanged until updated.
**Q: Do I need tests?** A: Not required, but highly recommended. See
OPTIMIZATION_PLAN.md section on testing.
**Q: Should the whole team do this?** A: Yes, once atoms are set up, everyone
should use them. Share the docs!
---
## 📞 Questions?
All answers are in these documents:
- **Timeline questions:** ATOMIC_REDESIGN_SUMMARY.md
- **Technical questions:** OPTIMIZATION_PLAN.md
- **How-to questions:** QUICK_START_ATOMIC.md
- **Pattern questions:** Atomic.md
---
## 🎓 Learning Path
1. **Understand the problem** (15 min) → ATOMIC_REDESIGN_SUMMARY.md
2. **Learn the pattern** (30 min) → Atomic.md sections 1-7
3. **See the full plan** (40 min) → OPTIMIZATION_PLAN.md
4. **Get hands-on** (2-3 hours) → QUICK_START_ATOMIC.md
5. **Execute the rest** (5-7 days) → Follow OPTIMIZATION_PLAN.md phases
---
**Status:** ✅ Complete Analysis & Documentation **Last Updated:** 2026-04-03
**Branch:** atomic_design **Next Action:** Start with QUICK_START_ATOMIC.md
-406
View File
@@ -1,406 +0,0 @@
# Atomic Design Restructuring - Executive Summary
## 📋 What I've Done
I've analyzed your project against the **Atomic Design pattern** defined in
`Atomic.md` and created a comprehensive optimization plan. Here's what you need
to know:
---
## ✅ Current Strengths
Your project has **excellent foundations:**
| Area | Grade | Notes |
| ------------------------ | ---------- | ------------------------------------- |
| **Context API Setup** | ⭐⭐⭐⭐⭐ | Well-organized, proper TypeScript |
| **Responsive Design** | ⭐⭐⭐⭐ | Mobile-first, good breakpoints |
| **Documentation** | ⭐⭐⭐⭐ | Detailed COMPONENTS.md, good comments |
| **App Router Structure** | ⭐⭐⭐⭐ | Perfect route group organization |
| **Component Quality** | ⭐⭐⭐ | Good, but missing atomic structure |
---
## 🔴 Key Issues to Fix
### 1. **No Atomic Hierarchy** (CRITICAL)
```
Current:
components/
├── CartProduct.tsx
├── CartFab.tsx
├── Navbar.tsx
├── ReviewModal.tsx
└── COMPONENTS.md
Target:
components/
├── atoms/ ← Reusable UI blocks (Button, Input, Text, etc.)
├── molecules/ ← Small combinations (ProductCard, SearchBar, etc.)
├── organisms/ ← Complex sections (ProductGrid, Navbar, ReviewModal)
├── templates/ ← Page layouts (MainLayout, AuthLayout, etc.)
└── COMPONENTS.md
```
**Impact:** Makes reusability impossible, increases maintenance burden
---
### 2. **Inline Component Code** (HIGH)
Currently, UI logic is scattered throughout pages:
**app/(main)/page.tsx** (192 lines)
- Lines 106-127: Search input with clear button → should be `SearchInput`
molecule
- Lines 131-153: Category menu logic → should be `CategoryMenu` organism
- Lines 156-188: Product grid rendering → should be `ProductGrid` organism
**Should be:**
```tsx
<ProductGrid searchQuery={searchQuery} onSearchChange={setSearchQuery} />
```
**Impact:** Pages are bloated, hard to test, hard to reuse
---
### 3. **Missing Atom Library** (HIGH)
You're creating buttons/inputs inline everywhere:
**Inline button** (CartProduct.tsx:73-79)
```tsx
<button className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-(--color-primary) px-3 py-1.5 ...">
Mua
</button>
```
**Another button** (ReviewModal.tsx:147-154)
```tsx
<button className="text-foreground inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-(--color-border) ...">
Quay lại
</button>
```
**Search input** (app/(main)/page.tsx:108-127)
```tsx
<div className="relative w-full sm:max-w-xs">
<i className="fa-solid fa-magnifying-glass ..."></i>
<input type="text" ... />
{searchQuery && <button>...</button>}
</div>
```
**Should be atoms:**
```tsx
<Button variant="primary" size="sm">Mua</Button>
<Button variant="secondary">Quay lại</Button>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
```
**Impact:** Inconsistent styling, 100+ lines of duplicate code, hard to update
theme
---
### 4. **No Component Hierarchy** (MEDIUM)
Components should be composable:
**Current:** CartProduct is standalone ✅ **Should be:** ProductCard molecule
using atoms (Text, Badge, Button, Image)
**Current:** ReviewModal contains all form logic ✅ **Should be:**
ReviewModal organism containing ReviewForm molecule with FormField atoms
---
## 📊 Numbers
| Metric | Current | Target | Benefit |
| ---------------------- | --------- | ---------- | ----------------------- |
| **Components in root** | 5 | 0 | Better organization |
| **Total components** | ~5 | ~40+ | More reusable |
| **Atoms** | 0 | ~20 | Foundation library |
| **Molecules** | 0 | ~10 | Mid-level composability |
| **Organisms** | 0 | ~8 | Complex sections |
| **Templates** | 0 | 5 | Layout reuse |
| **Avg page size** | 192 lines | <100 lines | More maintainable |
| **Button duplication** | 5+ places | 1 atom | DRY principle |
---
## 🎯 Implementation Plan
### **Phase 1: Atoms** (Foundation - 2-3 days)
Create reusable UI blocks that everything else depends on:
- Button, IconButton
- TextInput, SearchInput, Textarea
- Heading, Text, Caption (typography)
- Badge, PriceBadge
- Icon components
- Divider, Spinner, Skeleton
**First step:** All other components will use these
---
### **Phase 2: Molecules** (Combinations - 2-3 days)
Combine atoms into small, reusable UI units:
- **ProductCard** (rename CartProduct)
- **SearchBar** (search input + icon + clear button)
- **FormField** (label + input + error)
- **RatingInput** (5-star interactive rating)
- **PriceTag** (formatted price display)
- Breadcrumb, Tabs, etc.
**These use atoms from Phase 1**
---
### **Phase 3: Organisms** (Complex Sections - 2-3 days)
Extract existing logic into organized components:
- **ProductGrid** (extract from page, use ProductCard)
- **CategoryMenu** (extract from page)
- **Navbar** (move existing)
- **CartFab** (move existing)
- **ReviewModal** (move existing)
- Forms (LoginForm, RegisterForm, etc.)
- ShopGrid, FeaturedSection, etc.
**These use molecules + atoms**
---
### **Phase 4: Templates** (Layouts - 1-2 days)
Create page layout structures:
- MainLayout (header + sidebar + content + footer)
- FeedLayout
- AuthLayout
- CheckoutLayout
- ManagerLayout
**These wrap pages, use organisms**
---
### **Phase 5: Update Pages** (Integration - 1 day)
Update pages to use new hierarchy:
- Replace inline logic with component calls
- Reduce page file sizes
- Update imports
**Pages become <100 lines of clean composition**
---
## ⏱️ Time Estimates
| Phase | Scope | Time | Blocker |
| ------------ | ---------------------- | -------------- | ------------ |
| 1️⃣ Atoms | 7 component groups | 2-3 days | None |
| 2️⃣ Molecules | 8 component groups | 2-3 days | Phase 1 done |
| 3️⃣ Organisms | 8 component groups | 2-3 days | Phase 2 done |
| 4️⃣ Templates | 5 component groups | 1-2 days | Phase 3 done |
| 5️⃣ Pages | Page updates + testing | 1 day | Phase 4 done |
| **Total** | **All 40+ components** | **~8-10 days** | - |
### Quick Win Path (~2.5 hours)
If you want to start immediately:
1. Create Button atom (30 min) → unblocks everything
2. Create TextInput atom (20 min)
3. Create Typography atoms (45 min)
4. Move existing components (30 min)
5. Rename CartProduct → ProductCard (15 min)
This gives you a solid **foundation to build on gradually**.
---
## 📁 File Structure After Implementation
```
components/
├── atoms/ (20 atomic components)
│ ├── buttons/
│ ├── inputs/
│ ├── typography/
│ ├── badges/
│ ├── icons/
│ ├── dividers/
│ ├── loaders/
│ └── index.ts
├── molecules/ (10 molecular combinations)
│ ├── cards/
│ ├── form-groups/
│ ├── ratings/
│ ├── price-display/
│ ├── search-bar/
│ ├── breadcrumb/
│ ├── tabs/
│ └── index.ts
├── organisms/ (8 complex sections)
│ ├── navigation/
│ ├── cart/
│ ├── product-grid/
│ ├── forms/
│ ├── modals/
│ ├── shop-grid/
│ ├── hero-section/
│ ├── featured-section/
│ └── index.ts
├── templates/ (5 layout templates)
│ ├── main-layout/
│ ├── feed-layout/
│ ├── manager-layout/
│ ├── checkout-layout/
│ ├── auth-layout/
│ └── index.ts
└── COMPONENTS.md (updated)
```
---
## 🎁 Benefits You'll Get
### Immediate
- ✅ Consistent button styling across entire app
- ✅ Centralized color/spacing changes
- ✅ Better code organization
- ✅ Clear component boundaries
### Short-term (1-2 weeks)
- ✅ 100+ lines of code removed (no duplication)
- ✅ Easier feature additions
- ✅ Pages drop from 192 → <100 lines
- ✅ Team can follow clear patterns
### Long-term (1-3 months)
- ✅ 40% faster development
- ✅ 50% easier testing
- ✅ Better for new team members
- ✅ Ready for design system evolution
- ✅ Can add Storybook with confidence
---
## 📖 Documents Created
### 1. **OPTIMIZATION_PLAN.md** (THIS IS THE MAIN DOCUMENT)
- Complete analysis of current state
- Detailed breakdown of each phase
- Component mapping (old → new)
- Code quality recommendations
- Testing strategies
- Migration checklist
- Success criteria
**👉 READ THIS FIRST** - It's 400+ lines but super detailed
### 2. **This file** (ATOMIC_REDESIGN_SUMMARY.md)
- High-level overview
- Quick reference guide
- Time estimates
- Benefits summary
---
## 🚀 Next Steps
### Option A: Start Immediately (Recommended)
1. Read `OPTIMIZATION_PLAN.md` fully
2. Implement **Quick Wins** section (2.5 hours)
3. Get feedback from team
4. Continue with Phases 1-5
### Option B: Plan First
1. Share `OPTIMIZATION_PLAN.md` with team
2. Discuss timeline and priorities
3. Decide which phases to implement
4. Create sprint backlog
### Option C: Gradual Refactoring
1. Start with atoms only
2. Add them as new components needed
3. Gradually move existing components
4. No hard deadline - organic growth
---
## ❓ Questions to Answer
Before starting, clarify:
1. **Scope:** Do you want all 5 phases or just atoms/molecules first?
2. **Timeline:** Rush (1 week) or steady (2-3 weeks)?
3. **Testing:** Should we add unit tests for new components?
4. **Breaking changes:** OK to update imports everywhere?
5. **Team:** Will others be working on this too?
---
## 📚 Resources
- **Main guide:** `OPTIMIZATION_PLAN.md` (comprehensive)
- **Atomic Design reference:** `Atomic.md` (your design spec)
- **Current docs:** `COMPONENTS.md`, `app/APP.md`, `lib/LIB.md`
- **Implementation examples:** Sections 1-5 in OPTIMIZATION_PLAN.md
---
## 💡 Key Insight
Your code is **good quality** - but it's not following the atomic structure you
defined in `Atomic.md`. Think of it like:
- ✅ You have **good ingredients** (contexts, hooks, styling)
- ✅ You have a **recipe** (Atomic.md)
- ❌ But you haven't **followed the recipe**
This document shows you exactly how to follow the recipe step-by-step.
---
## 📞 Support
When implementing, you'll have detailed guidance in:
1. **OPTIMIZATION_PLAN.md** - Strategy & structure
2. **Atomic.md** - Design patterns & examples
3. **COMPONENTS.md** - Current component docs (will expand)
All the pieces are in place. It's just about organizing them correctly.
---
**Status:** ✅ Analysis Complete - Ready for Implementation **Last Updated:**
2026-04-03 **Document:** ATOMIC_REDESIGN_SUMMARY.md
-578
View File
@@ -1,578 +0,0 @@
# Quick Start: Atomic Design Implementation
**TL;DR:** Follow this to start building components the atomic way. Do **Step
1** first (creates foundation), then everything else uses it.
---
## Step 1: Create Button Atom ⚡ (30 minutes)
This is the **most important** component. Everything else will use it.
### Create directory
```bash
mkdir -p components/atoms/buttons
```
### Create `components/atoms/buttons/Button.types.ts`
```typescript
import { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
iconPosition?: "left" | "right";
disabled?: boolean;
children: React.ReactNode;
className?: string;
}
```
### Create `components/atoms/buttons/Button.tsx`
```typescript
import type { ButtonProps } from "./Button.types";
export default function Button({
variant = "primary",
size = "md",
icon,
iconPosition = "left",
disabled = false,
children,
className = "",
...props
}: ButtonProps) {
const baseStyles =
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5";
const variants = {
primary: "bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
secondary: "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",
};
const sizes = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
const iconClasses = `text-sm shrink-0`;
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{icon && iconPosition === "left" && <i className={`fa-solid ${icon} ${iconClasses}`}></i>}
{children}
{icon && iconPosition === "right" && <i className={`fa-solid ${icon} ${iconClasses}`}></i>}
</button>
);
}
```
### Create `components/atoms/buttons/index.ts`
```typescript
export { default as Button } from "./Button";
export type { ButtonProps } from "./Button.types";
```
### Test it
```tsx
// Try in any page
import { Button } from "@/components/atoms/buttons";
<Button variant="primary" size="sm" icon="fa-cart-plus">Mua</Button>
<Button variant="secondary">Quay lại</Button>
<Button variant="danger" size="lg">Xóa</Button>
```
---
## Step 2: Create TextInput Atom (20 minutes)
### Create directory
```bash
mkdir -p components/atoms/inputs
```
### Create `components/atoms/inputs/Input.types.ts`
```typescript
import { InputHTMLAttributes } from "react";
export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
icon?: string; // FontAwesome class
onIconClick?: () => void;
className?: string;
}
```
### Create `components/atoms/inputs/TextInput.tsx`
```typescript
import type { TextInputProps } from "./Input.types";
export default function TextInput({
label,
error,
icon,
onIconClick,
className = "",
...props
}: TextInputProps) {
return (
<div className="w-full">
{label && (
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
{label}
</label>
)}
<div className="relative">
<input
className={`w-full rounded-lg border border-(--color-border) bg-transparent px-3 py-2 text-sm transition-colors placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20 focus:outline-none ${error ? "border-red-500" : ""} ${className}`}
{...props}
/>
{icon && (
<button
type="button"
onClick={onIconClick}
className="absolute right-3 top-1/2 -translate-y-1/2 text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className={`fa-solid ${icon}`}></i>
</button>
)}
</div>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
```
### Create `components/atoms/inputs/SearchInput.tsx`
```typescript
import { InputHTMLAttributes } from "react";
export default function SearchInput({
value,
onChange,
onClear,
...props
}: InputHTMLAttributes<HTMLInputElement> & {
onClear?: () => void;
}) {
return (
<div className="relative w-full">
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
<input
type="text"
value={value}
onChange={onChange}
className="w-full rounded-lg border border-(--color-border) bg-transparent py-2 pr-9 pl-9 text-sm transition-all duration-150 placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20 focus:outline-none"
{...props}
/>
{value && onClear && (
<button
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
);
}
```
### Create `components/atoms/inputs/index.ts`
```typescript
export { default as TextInput } from "./TextInput";
export { default as SearchInput } from "./SearchInput";
export type { TextInputProps } from "./Input.types";
```
---
## Step 3: Create Typography Atoms (45 minutes)
### Create directory
```bash
mkdir -p components/atoms/typography
```
### Create `components/atoms/typography/Typography.types.ts`
```typescript
import { HTMLAttributes } from "react";
export interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
level?: 1 | 2 | 3 | 4 | 5 | 6;
children: React.ReactNode;
}
export interface TextProps extends HTMLAttributes<HTMLParagraphElement> {
variant?: "body1" | "body2" | "caption" | "label";
children: React.ReactNode;
}
export interface CaptionProps extends HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
}
```
### Create `components/atoms/typography/Heading.tsx`
```typescript
import type { HeadingProps } from "./Typography.types";
export default function Heading({
level = 2,
children,
className = "",
...props
}: HeadingProps) {
const sizes = {
1: "text-3xl font-bold",
2: "text-2xl font-bold",
3: "text-xl font-bold",
4: "text-lg font-semibold",
5: "text-base font-semibold",
6: "text-sm font-semibold",
};
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Tag
className={`text-foreground ${sizes[level]} ${className}`}
{...props}
>
{children}
</Tag>
);
}
```
### Create `components/atoms/typography/Text.tsx`
```typescript
import type { TextProps } from "./Typography.types";
export default function Text({
variant = "body1",
children,
className = "",
...props
}: TextProps) {
const variants = {
body1: "text-base text-(--color-text-primary)",
body2: "text-sm text-(--color-text-secondary)",
caption: "text-xs text-(--color-text-muted)",
label: "text-sm font-medium text-(--color-text-secondary)",
};
return (
<p className={`${variants[variant]} ${className}`} {...props}>
{children}
</p>
);
}
```
### Create `components/atoms/typography/Caption.tsx`
```typescript
import type { CaptionProps } from "./Typography.types";
export default function Caption({
children,
className = "",
...props
}: CaptionProps) {
return (
<span className={`text-xs text-(--color-text-muted) ${className}`} {...props}>
{children}
</span>
);
}
```
### Create `components/atoms/typography/index.ts`
```typescript
export { default as Heading } from "./Heading";
export { default as Text } from "./Text";
export { default as Caption } from "./Caption";
export type { HeadingProps, TextProps, CaptionProps } from "./Typography.types";
```
---
## Step 4: Create Badge Atom (15 minutes)
### Create directory
```bash
mkdir -p components/atoms/badges
```
### Create `components/atoms/badges/Badge.types.ts`
```typescript
import { HTMLAttributes } from "react";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
size?: "sm" | "md";
children: React.ReactNode;
}
```
### Create `components/atoms/badges/Badge.tsx`
```typescript
import type { BadgeProps } from "./Badge.types";
export default function Badge({
variant = "primary",
size = "md",
children,
className = "",
...props
}: BadgeProps) {
const variants = {
primary: "bg-(--color-primary) text-white",
secondary: "bg-(--color-border-light) text-(--color-text-secondary)",
success: "bg-green-100 text-green-700",
danger: "bg-red-100 text-red-700",
warning: "bg-yellow-100 text-yellow-700",
};
const sizes = {
sm: "px-2 py-1 text-xs",
md: "px-3 py-1.5 text-sm",
};
return (
<span
className={`inline-flex items-center justify-center rounded-full font-semibold ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</span>
);
}
```
### Create `components/atoms/badges/index.ts`
```typescript
export { default as Badge } from "./Badge";
export type { BadgeProps } from "./Badge.types";
```
---
## Step 5: Create Divider Atom (5 minutes)
### Create directory
```bash
mkdir -p components/atoms/dividers
```
### Create `components/atoms/dividers/Divider.tsx`
```typescript
import { HTMLAttributes } from "react";
export interface DividerProps extends HTMLAttributes<HTMLHRElement> {
orientation?: "horizontal" | "vertical";
}
export default function Divider({
orientation = "horizontal",
className = "",
...props
}: DividerProps) {
return (
<hr
className={`border-(--color-border) ${
orientation === "horizontal"
? "border-t"
: "border-l h-full"
} ${className}`}
{...props}
/>
);
}
```
---
## Step 6: Create Atoms Index (10 minutes)
### Create `components/atoms/index.ts`
```typescript
// Buttons
export { Button } from "./buttons";
export type { ButtonProps } from "./buttons";
// Inputs
export { TextInput, SearchInput } from "./inputs";
export type { TextInputProps } from "./inputs";
// Typography
export { Heading, Text, Caption } from "./typography";
export type { HeadingProps, TextProps, CaptionProps } from "./typography";
// Badges
export { Badge } from "./badges";
export type { BadgeProps } from "./badges";
// Dividers
export { Divider } from "./dividers";
export type { DividerProps } from "./dividers";
```
---
## Step 7: Replace Existing Code (Optional, Gradual)
Now you have atoms! Start using them:
### Before (inline)
```tsx
// components/CartProduct.tsx
<button
onClick={onBuy}
className="flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-(--color-primary) px-3 py-1.5 text-xs font-semibold whitespace-nowrap text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-cart-plus"></i>
Mua
</button>
```
### After (using atom)
```tsx
// components/CartProduct.tsx
import { Button } from "@/components/atoms";
<Button variant="primary" size="sm" icon="fa-cart-plus">
Mua
</Button>;
```
### Before (inline)
```tsx
// app/(main)/page.tsx
<h2 className="text-foreground text-xl font-bold">
{activeCategoryLabel}
</h2>
<p className="text-muted-foreground mt-0.5 text-sm">
{filteredProducts.length} món
</p>
```
### After (using atoms)
```tsx
// app/(main)/page.tsx
import { Heading, Text } from "@/components/atoms";
<Heading level={2}>{activeCategoryLabel}</Heading>
<Text variant="body2">{filteredProducts.length} món</Text>
```
---
## Step 8: Verify Everything Works
```bash
# Check for TypeScript errors
npm run lint
# Format code
npm run format
# Run dev server
npm run dev
# Visit http://localhost:3000 and check styling
```
---
## Next: Create Molecules
Once atoms are solid, create molecules that **use** atoms:
```
components/molecules/
├── cards/
│ └── ProductCard.tsx (uses Button, Text, Badge atoms)
├── form-groups/
│ └── FormField.tsx (uses TextInput, Text atoms)
├── ratings/
│ └── RatingInput.tsx (uses Button, Caption atoms)
└── search-bar/
└── SearchBar.tsx (uses SearchInput, Button atoms)
```
**Each molecule = 2-3 atoms combined + simple state**
---
## Checklist
- [ ] Created `components/atoms/buttons/`
- [ ] Created `components/atoms/inputs/`
- [ ] Created `components/atoms/typography/`
- [ ] Created `components/atoms/badges/`
- [ ] Created `components/atoms/dividers/`
- [ ] Created `components/atoms/index.ts` (barrel export)
- [ ] Updated at least 1 component to use atoms
- [ ] Run `npm run lint` - no errors
- [ ] Run `npm run format` - code formatted
- [ ] Test in browser - styling looks correct
---
## 💡 Tips
1. **Start small:** Just do atoms. Don't worry about molecules yet.
2. **Use consistently:** Once Button atom exists, never write `<button>` again.
3. **Expand gradually:** Add more atoms/molecules as you need them.
4. **Keep props simple:** Atoms should have 5-10 props max.
5. **Test variants:** Make sure all variants (primary, secondary, etc.) look
good.
---
## 🚀 You're Ready!
You now have the **foundation**. Everything else builds on these atoms. Nice
work!
**Next:** Once atoms are comfortable, read the full `OPTIMIZATION_PLAN.md` for
molecules and organisms.
+16 -83
View File
@@ -1,23 +1,14 @@
"use client"; "use client";
import { MOCK_SHOPS } from "@/lib/constants"; import { SearchBar } from "@/components/molecules/search-bar";
import Image from "next/image"; import { ShopGrid } from "@/components/organisms/shop-grid";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
export default function FeedPage() { export default function FeedPage() {
const [searchName, setSearchName] = useState(""); const [searchName, setSearchName] = useState("");
const [searchAddress, setSearchAddress] = useState(""); const [searchAddress, setSearchAddress] = useState("");
const filteredShops = MOCK_SHOPS.filter((shop) => { const hasFilters = searchName || searchAddress;
const matchesName =
searchName.trim() === "" ||
shop.name.toLowerCase().includes(searchName.toLowerCase());
const matchesAddress =
searchAddress.trim() === "" ||
shop.address.toLowerCase().includes(searchAddress.toLowerCase());
return matchesName && matchesAddress;
});
return ( return (
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]"> <main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
@@ -32,57 +23,11 @@ export default function FeedPage() {
</p> </p>
</div> </div>
{/* Shop cards grid */} {/* Shop grid */}
{filteredShops.length > 0 ? ( <div className="mb-10">
<div className="mb-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <ShopGrid searchName={searchName} searchAddress={searchAddress} />
{filteredShops.map((shop) => ( {hasFilters && (
<div <div className="mt-4 flex justify-center">
key={shop.id}
className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]"
>
{/* Shop image */}
<div className="relative h-48 w-full sm:h-52">
<Image
src={shop.image}
alt={shop.name}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</div>
{/* Card body */}
<div className="p-4">
{/* Name + View menu button */}
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-foreground truncate text-base font-bold">
{shop.name}
</h3>
<Link
href="/"
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-book-open text-[10px]"></i>
Xem menu
</Link>
</div>
{/* Address */}
<div className="flex items-start gap-2 text-sm text-(--color-text-muted)">
<i className="fa-solid fa-location-dot mt-0.5 shrink-0 text-(--color-accent)"></i>
<span>{shop.address}</span>
</div>
</div>
</div>
))}
</div>
) : (
/* Empty state */
<div className="mb-10 flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium">
Không tìm thấy quán nào phù hợp
</p>
<button <button
onClick={() => { onClick={() => {
setSearchName(""); setSearchName("");
@@ -94,38 +39,26 @@ export default function FeedPage() {
</button> </button>
</div> </div>
)} )}
</div>
{/* Filter / Search bar */} {/* Filter / Search bar — sticky bottom */}
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5"> <div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
{/* Label */}
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)"> <div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
<i className="fa-solid fa-filter text-(--color-primary)"></i> <i className="fa-solid fa-filter text-(--color-primary)"></i>
<span>Lọc quán</span> <span>Lọc quán</span>
</div> </div>
{/* Search by name */} {/* Name search */}
<div className="relative min-w-0 flex-1"> <SearchBar
<i className="fa-solid fa-store pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
<input
type="text"
value={searchName} value={searchName}
onChange={(e) => setSearchName(e.target.value)} onChange={setSearchName}
onClear={() => setSearchName("")}
placeholder="Tìm theo tên quán..." placeholder="Tìm theo tên quán..."
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)" className="min-w-0 flex-1"
/> />
{searchName && (
<button
onClick={() => setSearchName("")}
aria-label="Xóa tìm kiếm tên"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
{/* Search by address */} {/* Address search — different icon so not using SearchBar atom */}
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i> <i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
<input <input
+3 -38
View File
@@ -1,44 +1,9 @@
import Image from "next/image"; import { FeedLayout } from "@/components/templates/feed-layout";
import Link from "next/link";
export default function FeedLayout({ export default function RootFeedLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return <FeedLayout>{children}</FeedLayout>;
<>
{/* Custom Drinkool header — no login button */}
<header className="sticky top-0 z-50 h-(--spacing-header-height) w-full border-b border-(--color-border) bg-(--color-bg-header) shadow-[0_1px_8px_var(--color-shadow-sm)]">
<div className="mx-auto flex h-full max-w-screen-2xl items-center gap-4 px-6 md:px-8 lg:px-12">
<Link
href="/feed"
className="group flex shrink-0 items-center gap-3 no-underline"
>
{/* Logo */}
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
<Image
src="/imgs/logo.png"
alt="Logo Drinkool"
fill
className="object-contain transition-transform duration-200 group-hover:scale-105"
sizes="44px"
priority
/>
</div>
{/* Brand name */}
<span className="text-lg font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-xl">
Drinkool
</span>
</Link>
</div>
</header>
{/* Page content */}
<div className="flex-1">{children}</div>
{/* No footer */}
</>
);
} }
+3 -19
View File
@@ -1,25 +1,9 @@
import CartFab from "@/components/CartFab"; import { MainLayout } from "@/components/templates/main-layout";
import Footer from "@/layouts/footer";
import Header from "@/layouts/header";
export default function MainLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return <MainLayout>{children}</MainLayout>;
<>
{/* Sticky top header */}
<Header />
{/* Page content (grows to fill remaining height) */}
<div className="flex-1">{children}</div>
{/* Footer always at bottom */}
<Footer />
{/* Global floating cart button */}
<CartFab />
</>
);
} }
+14 -124
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import CartProduct from "@/components/CartProduct"; import { CategorySidebar } from "@/components/organisms/navigation";
import Navbar from "@/components/Navbar"; import { ProductGrid } from "@/components/organisms/product-grid";
import { useCart } from "@/lib/cart-context"; import { SearchBar } from "@/components/molecules/search-bar";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context"; import { useMenu } from "@/lib/menu-context";
import { MENU_CATEGORIES } from "@/lib/constants";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/** /**
@@ -16,25 +16,13 @@ import { useEffect, useState } from "react";
* Sidebar state: * Sidebar state:
* - Desktop (≥ 1024px): expanded by default * - Desktop (≥ 1024px): expanded by default
* - Mobile (< 1024px): collapsed by default * - Mobile (< 1024px): collapsed by default
*
* Product grid columns (responsive, depends on sidebar state):
* Collapsed sidebar: 2 → sm:2 → lg:3 → xl:4 → 2xl:5
* Expanded sidebar: 1 → sm:2 → lg:2 → xl:3 → 2xl:4
*/ */
export default function Home() { export default function Home() {
/* Shared category state comes from MenuContext so the header mobile menu
* and this sidebar always reflect the same selection. */
const { activeCategory, setActiveCategory } = useMenu(); const { activeCategory, setActiveCategory } = useMenu();
const { addToCart } = useCart();
/* Start collapsed (false) so SSR and client initial render match.
* useEffect sets the correct value after hydration completes. */
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
/* After mount: sync sidebar with viewport width, then subscribe to changes.
* The initial setIsSidebarOpen call is intentional (post-hydration only)
* and does not cause cascading renders — suppress the lint rule here. */
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)"); const mq = window.matchMedia("(min-width: 1024px)");
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
@@ -44,45 +32,18 @@ export default function Home() {
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
}, []); }, []);
/* Clear search whenever the active category changes (triggered from either
* the sidebar on md+ or the header scrollable menu on < md).
* setState-in-effect is intentional here — suppress the lint rule. */
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setSearchQuery(""); setSearchQuery("");
}, [activeCategory]); }, [activeCategory]);
/* Filter products by availability, active category, and search query.
* p.available defaults to true when undefined (opt-in unavailability). */
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
const isAvailable = p.available !== false;
const matchesCategory =
activeCategory === "all" || p.category === activeCategory;
const matchesSearch =
searchQuery.trim() === "" ||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
return isAvailable && matchesCategory && matchesSearch;
});
/* Active category label */
const activeCategoryLabel = const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả"; MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
/* Responsive grid class based on sidebar state
* Base (< 480px) : 1 col — very small phones
* min-[480px] : 2 cols — larger phones
* lg+ : 2/3 cols depending on sidebar
*/
const gridCols = isSidebarOpen
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
return ( return (
/* Outer wrapper: flex row, align-items: flex-start so sidebar sticks */
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start"> <div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
{/* ── Sidebar ── */} {/* ── Sidebar ── */}
<Navbar <CategorySidebar
isOpen={isSidebarOpen} isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((prev) => !prev)} onToggle={() => setIsSidebarOpen((prev) => !prev)}
activeCategory={activeCategory} activeCategory={activeCategory}
@@ -98,94 +59,23 @@ export default function Home() {
<h2 className="text-foreground text-xl font-bold"> <h2 className="text-foreground text-xl font-bold">
{activeCategoryLabel} {activeCategoryLabel}
</h2> </h2>
<p className="text-muted-foreground mt-0.5 text-sm">
{filteredProducts.length} món
</p>
</div> </div>
{/* Search input */} {/* Search bar */}
<div className="relative w-full sm:max-w-xs"> <SearchBar
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
<input
type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={setSearchQuery}
onClear={() => setSearchQuery("")}
placeholder="Tìm kiếm món..." placeholder="Tìm kiếm món..."
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2" className="sm:max-w-xs"
/> />
{/* Clear button */}
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
title="Xóa tìm kiếm"
aria-label="Xóa tìm kiếm"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
</div> </div>
{/* ── Mobile category menu — visible only on < md, below search, above products ── */} {/* ── Product grid (organism handles mobile category menu + grid) ── */}
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden"> <ProductGrid
<div className="flex items-center gap-1.5 pb-1"> searchQuery={searchQuery}
{MENU_CATEGORIES.map((cat) => { isSidebarOpen={isSidebarOpen}
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
<i
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
<span>{cat.name}</span>
</button>
);
})}
</div>
</div>
{/* ── Product grid ── */}
{filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<CartProduct
key={product.id}
image={product.image}
imageAlt={product.name}
productName={product.name}
price={product.price}
description={product.description}
onBuy={() => addToCart(product)}
/> />
))}
</div>
) : (
/* Empty state */
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
<p className="text-base font-medium">
{searchQuery
? `Không tìm thấy món nào cho "${searchQuery}"`
: "Chưa có món trong danh mục này"}
</p>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
>
Xóa tìm kiếm
</button>
)}
</div>
)}
</main> </main>
</div> </div>
); );
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import ReviewModal from "@/components/ReviewModal"; import { ReviewModal } from "@/components/organisms/modals";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { useCart } from "@/lib/cart-context"; import { useCart } from "@/lib/cart-context";
import Link from "next/link"; import Link from "next/link";
+3 -43
View File
@@ -1,49 +1,9 @@
"use client"; import { ManagerLayout } from "@/components/templates/manager-layout";
import { useAuth } from "@/lib/auth-context"; export default function RootManagerLayout({
import { ManagerProvider } from "@/lib/manager-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ManagerLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { user } = useAuth(); return <ManagerLayout>{children}</ManagerLayout>;
const router = useRouter();
// Redirect non-managers away
useEffect(() => {
if (user !== null && user.role !== "manager") {
router.replace("/");
}
}, [user, router]);
// While loading (user is null but not yet checked), show nothing
if (user === null) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
<Link
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
</Link>
</div>
</div>
);
}
if (user.role !== "manager") return null;
return (
<ManagerProvider>
<div className="bg-background flex min-h-screen flex-col">{children}</div>
</ManagerProvider>
);
} }
File diff suppressed because it is too large Load Diff
+66 -3
View File
@@ -1,11 +1,74 @@
# Components Documentation # Components Documentation
> Whenever you create a new component in the components/ directory, > This project follows **Atomic Design** pattern.
> automatically append a new section following the template below. > See `Atomic.md` at project root for full structure guide.
## Directory Structure
```
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
```
--- ---
## CartProduct ## MOLECULES
### 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.
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
### SearchBar (`molecules/search-bar/SearchBar.tsx`)
**Description:** Search input with clear button. Controlled component — value and onChange come from parent.
---
## ORGANISMS
### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`)
**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.
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
**Description:** Full product grid organism with mobile category tabs, filtering, and empty state. Uses useCart and useMenu contexts.
### ReviewModal (`organisms/modals/ReviewModal.tsx`)
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message.
---
## TEMPLATES
### MainLayout (`templates/main-layout/MainLayout.tsx`)
**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.
---
## LEGACY COMPONENTS (root level — kept for backward compatibility)
### CartProduct
**File:** components/CartProduct.tsx **Description:** Product card component. **File:** components/CartProduct.tsx **Description:** Product card component.
Displays product image, name, description, formatted price, and a Buy button. Displays product image, name, description, formatted price, and a Buy button.
+3 -1
View File
@@ -1,3 +1,5 @@
import type React from "react";
import type { HeadingProps } from "./Typography.types"; import type { HeadingProps } from "./Typography.types";
export default function Heading({ export default function Heading({
@@ -15,7 +17,7 @@ export default function Heading({
6: "text-sm font-semibold", 6: "text-sm font-semibold",
}; };
const Tag = `h${level}` as keyof JSX.IntrinsicElements; const Tag = `h${level}` as React.ElementType;
return ( return (
<Tag className={`text-foreground ${sizes[level]} ${className}`} {...props}> <Tag className={`text-foreground ${sizes[level]} ${className}`} {...props}>
+18
View File
@@ -0,0 +1,18 @@
import type { Product } from "@/lib/types";
export interface ProductCardProps {
image: string;
imageAlt?: string;
productName: string;
price: number | string;
description: string;
onBuy?: () => void;
}
export interface ShopCardProps {
id: number;
name: string;
address: string;
image: string;
onClick?: () => void;
}
@@ -0,0 +1,71 @@
"use client";
import { Button, Caption, Text } from "@/components/atoms";
import Image from "next/image";
import type { ProductCardProps } from "./Card.types";
/**
* 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 ProductCard({
image,
imageAlt = "Ảnh sản phẩm",
productName,
price,
description,
onBuy,
}: ProductCardProps) {
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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Image from "next/image";
import Link from "next/link";
import type { ShopCardProps } from "./Card.types";
export default function ShopCard({ name, address, image }: ShopCardProps) {
return (
<div className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]">
{/* Shop image */}
<div className="relative h-48 w-full sm:h-52">
<Image
src={image}
alt={name}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</div>
{/* Card body */}
<div className="p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-foreground truncate text-base font-bold">
{name}
</h3>
<Link
href="/"
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-book-open text-[10px]"></i>
Xem menu
</Link>
</div>
<div className="flex items-start gap-2 text-sm text-(--color-text-muted)">
<i className="fa-solid fa-location-dot mt-0.5 shrink-0 text-(--color-accent)"></i>
<span>{address}</span>
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { default as ProductCard } from "./ProductCard";
export { default as ShopCard } from "./ShopCard";
export type { ProductCardProps, ShopCardProps } from "./Card.types";
+7
View File
@@ -0,0 +1,7 @@
// Cards
export { ProductCard } from "./cards";
export type { ProductCardProps, ShopCardProps } from "./cards";
// Search Bar
export { SearchBar } from "./search-bar";
export type { SearchBarProps } from "./search-bar";
@@ -0,0 +1,7 @@
export interface SearchBarProps {
value: string;
onChange: (value: string) => void;
onClear?: () => void;
placeholder?: string;
className?: string;
}
@@ -0,0 +1,34 @@
"use client";
import type { SearchBarProps } from "./Search.types";
export default function SearchBar({
value,
onChange,
onClear,
placeholder = "Tìm kiếm...",
className = "",
}: SearchBarProps) {
return (
<div className={`relative w-full ${className}`}>
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
/>
{value && (
<button
onClick={onClear}
title="Xóa tìm kiếm"
aria-label="Xóa tìm kiếm"
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as SearchBar } from "./SearchBar";
export type { SearchBarProps } from "./Search.types";
+3
View File
@@ -0,0 +1,3 @@
export interface CartFabProps {
className?: string;
}
+23
View File
@@ -0,0 +1,23 @@
"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>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as CartFab } from "./CartFab";
export type { CartFabProps } from "./Cart.types";
+38
View File
@@ -0,0 +1,38 @@
// Navigation
export { CategorySidebar } from "./navigation";
export type { CategorySidebarProps } from "./navigation";
// Cart
export { CartFab } from "./cart";
export type { CartFabProps } from "./cart";
// Product Grid
export { ProductGrid } from "./product-grid";
export type { ProductGridProps } from "./product-grid";
// Modals
export { ReviewModal } from "./modals";
export type { ReviewModalProps, ConfirmModalProps } from "./modals";
// Shop Grid
export { ShopGrid } from "./shop-grid";
export type { ShopGridProps } from "./shop-grid";
// Manager
export {
StatusBadge,
DeleteConfirm,
ProductModal,
CategoryModal,
ComboModal,
ProductsTab,
CategoriesTab,
CombosTab,
} from "./manager";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./manager";
@@ -0,0 +1,110 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import CategoryModal from "./CategoryModal";
import DeleteConfirm from "./DeleteConfirm";
export default function CategoriesTab() {
const { categories, products, addCategory, updateCategory, deleteCategory } =
useManager();
const [modalCategory, setModalCategory] = useState<
MenuCategory | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuCategory | null>(null);
const getProductCount = (catId: string) =>
products.filter((p) => p.category === catId).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{categories.length}</strong> danh
mục
</p>
<button
onClick={() => setModalCategory("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm danh mục</span>
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{categories.map((cat) => {
const count = getProductCount(cat.id);
return (
<div
key={cat.id}
className="group relative flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-(--color-accent-light)">
<i className={`${cat.icon} text-xl text-(--color-primary)`}></i>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate font-semibold">
{cat.name}
</p>
<p className="text-xs text-(--color-text-muted)">{count} món</p>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => setModalCategory(cat)}
title="Chỉnh sửa"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-pen text-[11px]"></i>
</button>
<button
onClick={() => setDeleteTarget(cat)}
title="Xóa"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
>
<i className="fa-solid fa-trash text-[11px]"></i>
</button>
</div>
</div>
);
})}
{categories.length === 0 && (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
<p className="text-sm">Chưa danh mục nào</p>
</div>
)}
</div>
{modalCategory !== null && (
<CategoryModal
category={modalCategory === "new" ? null : modalCategory}
onSave={(data) => {
if ("id" in data) {
updateCategory(data as MenuCategory);
} else {
addCategory(data);
}
setModalCategory(null);
}}
onClose={() => setModalCategory(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCategory(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import type { CategoryModalProps } from "./Manager.types";
const FA_ICONS = [
"fa-solid fa-mug-hot",
"fa-solid fa-leaf",
"fa-solid fa-jar",
"fa-solid fa-blender",
"fa-solid fa-mug-saucer",
"fa-solid fa-ice-cream",
"fa-solid fa-layer-group",
"fa-solid fa-burger",
"fa-solid fa-pizza-slice",
"fa-solid fa-bowl-food",
"fa-solid fa-candy-cane",
"fa-solid fa-cookie",
"fa-solid fa-cake-candles",
"fa-solid fa-drumstick-bite",
"fa-solid fa-fish",
"fa-solid fa-carrot",
];
export default function CategoryModal({
category,
onSave,
onClose,
}: CategoryModalProps) {
const isEdit = category !== null;
const [form, setForm] = useState<Omit<MenuCategory, "id">>({
name: category?.name ?? "",
icon: category?.icon ?? FA_ICONS[0],
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit && category) {
onSave({ ...form, id: category.id });
} else {
onSave(form);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa danh mục" : "Thêm danh mục mới"}
</h2>
<button
title="Close"
onClick={onClose}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên danh mục <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
placeholder="Ví dụ: Cà Phê"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Icon
</label>
<div className="grid grid-cols-8 gap-2">
{FA_ICONS.map((icon) => (
<button
key={icon}
type="button"
onClick={() => setForm({ ...form, icon })}
title={icon}
className={`flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition ${
form.icon === icon
? "border-(--color-primary) bg-(--color-primary) text-white"
: "bg-background border-(--color-border-light) text-(--color-text-secondary) hover:border-(--color-primary-light) hover:text-(--color-primary)"
}`}
>
<i className={`${icon} text-sm`}></i>
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
type="submit"
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
{isEdit ? "Lưu thay đổi" : "Thêm danh mục"}
</button>
</div>
</form>
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import type { Combo, Product } from "@/lib/types";
import { useState } from "react";
import type { ComboModalProps } from "./Manager.types";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function ComboModal({
combo,
products,
onSave,
onClose,
}: ComboModalProps) {
const isEdit = combo !== null;
const [form, setForm] = useState<Omit<Combo, "id">>({
name: combo?.name ?? "",
description: combo?.description ?? "",
price: combo?.price ?? 0,
image: combo?.image ?? "/imgs/products/placeholder.jpg",
items: combo?.items ?? [],
available: combo?.available ?? true,
});
const updateItemQty = (productId: number, qty: number) => {
if (qty <= 0) {
setForm((prev) => ({
...prev,
items: prev.items.filter((i) => i.productId !== productId),
}));
} else {
setForm((prev) => {
const existing = prev.items.find((i) => i.productId === productId);
if (existing) {
return {
...prev,
items: prev.items.map((i) =>
i.productId === productId ? { ...i, quantity: qty } : i,
),
};
}
return {
...prev,
items: [...prev.items, { productId, quantity: qty }],
};
});
}
};
const getQty = (productId: number) =>
form.items.find((i) => i.productId === productId)?.quantity ?? 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (form.items.length === 0) return;
if (isEdit && combo) {
onSave({ ...form, id: combo.id });
} else {
onSave(form);
}
};
const inputCls =
"w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="flex max-h-[90vh] w-full max-w-xl flex-col rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa combo" : "Thêm combo mới"}
</h2>
<button
title="Close"
onClick={onClose}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên combo <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="Ví dụ: Combo Cà Phê Đôi"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá combo (đ) <span className="text-red-500">*</span>
</label>
<input
title="Giá combo"
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
</label>
<textarea
title="Mô tả combo"
rows={2}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Món trong combo{" "}
{form.items.length === 0 && (
<span className="text-xs text-red-500">
(Chọn ít nhất 1 món)
</span>
)}
</label>
<div className="bg-background max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-(--color-border-light) p-2">
{products.map((p) => {
const qty = getQty(p.id);
return (
<div
key={p.id}
className="flex items-center justify-between rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="text-foreground flex-1 truncate">
{p.name}
</span>
<span className="mr-3 text-xs text-(--color-text-muted)">
{formatPrice(p.price)}
</span>
<div className="flex items-center gap-1">
<button
title="Giảm"
type="button"
onClick={() => updateItemQty(p.id, qty - 1)}
disabled={qty === 0}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary) disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="fa-solid fa-minus"></i>
</button>
<span className="text-foreground w-5 text-center text-sm font-semibold">
{qty}
</span>
<button
title="Tăng"
type="button"
onClick={() => updateItemQty(p.id, qty + 1)}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus"></i>
</button>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
<div>
<p className="text-foreground text-sm font-medium">
Trạng thái
</p>
<p className="text-xs text-(--color-text-muted)">
{form.available ? "Còn hàng" : "Tạm hết"}
</p>
</div>
<button
title="Chuyển đổi trạng thái"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
form.available ? "bg-(--color-primary)" : "bg-gray-300"
}`}
>
<span
className={`absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
form.available ? "translate-x-5.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
<div className="flex gap-3 border-t border-(--color-border-light) px-6 py-4">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
type="submit"
disabled={form.items.length === 0}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{isEdit ? "Lưu thay đổi" : "Thêm combo"}
</button>
</div>
</form>
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { Combo } from "@/lib/types";
import { useState } from "react";
import ComboModal from "./ComboModal";
import DeleteConfirm from "./DeleteConfirm";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function CombosTab() {
const {
combos,
products,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
} = useManager();
const [modalCombo, setModalCombo] = useState<Combo | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
const getProductName = (id: number) =>
products.find((p) => p.id === id)?.name ?? `Món #${id}`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{combos.length}</strong> combo
</p>
<button
onClick={() => setModalCombo("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm combo</span>
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{combos.length === 0 ? (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-layer-group text-4xl opacity-30"></i>
<p className="text-sm">Chưa combo nào</p>
</div>
) : (
combos.map((combo) => (
<div
key={combo.id}
className="flex flex-col rounded-2xl border border-(--color-border-light) bg-white shadow-sm transition hover:shadow-md"
>
<div className="flex items-start justify-between p-4">
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate font-semibold">
{combo.name}
</h3>
{combo.description && (
<p className="mt-1 line-clamp-2 text-xs text-(--color-text-muted)">
{combo.description}
</p>
)}
</div>
<button
onClick={() => toggleComboAvailability(combo.id)}
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
title="Đổi trạng thái"
>
<StatusBadge available={combo.available} />
</button>
</div>
<div className="bg-background mx-4 mb-3 rounded-xl px-3 py-2">
<p className="mb-1 text-[11px] font-semibold tracking-wide text-(--color-text-muted) uppercase">
Bao gồm
</p>
<ul className="space-y-0.5">
{combo.items.map((item) => (
<li
key={item.productId}
className="flex items-center justify-between text-xs text-(--color-text-secondary)"
>
<span>{getProductName(item.productId)}</span>
<span className="font-medium">×{item.quantity}</span>
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between border-t border-(--color-border-light) px-4 py-3">
<span className="text-base font-bold text-(--color-primary)">
{formatPrice(combo.price)}
</span>
<div className="flex gap-1.5">
<button
onClick={() => setModalCombo(combo)}
title="Chỉnh sửa"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-pen text-xs"></i>
</button>
<button
onClick={() => setDeleteTarget(combo)}
title="Xóa"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
>
<i className="fa-solid fa-trash text-xs"></i>
</button>
</div>
</div>
</div>
))
)}
</div>
{modalCombo !== null && (
<ComboModal
combo={modalCombo === "new" ? null : modalCombo}
products={products}
onSave={(data) => {
if ("id" in data) {
updateCombo(data as Combo);
} else {
addCombo(data);
}
setModalCombo(null);
}}
onClose={() => setModalCombo(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCombo(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import type { DeleteConfirmProps } from "./Manager.types";
export default function DeleteConfirm({
name,
onConfirm,
onClose,
}: DeleteConfirmProps) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex flex-col items-center gap-3 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<i className="fa-solid fa-trash-can text-xl text-red-500"></i>
</div>
<h3 className="text-foreground text-base font-bold">Xóa "{name}"?</h3>
<p className="text-sm text-(--color-text-muted)">
Hành đng này không thể hoàn tác.
</p>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
onClick={onConfirm}
className="flex-1 cursor-pointer rounded-xl border-none bg-red-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-600 active:scale-95"
>
Xóa
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { Combo, MenuCategory, Product } from "@/lib/types";
export interface ProductModalProps {
product: Product | null; // null = add mode
categories: MenuCategory[];
onSave: (p: Omit<Product, "id"> | Product) => void;
onClose: () => void;
}
export interface CategoryModalProps {
category: MenuCategory | null;
onSave: (c: Omit<MenuCategory, "id"> | MenuCategory) => void;
onClose: () => void;
}
export interface ComboModalProps {
combo: Combo | null;
products: Product[];
onSave: (c: Omit<Combo, "id"> | Combo) => void;
onClose: () => void;
}
export interface DeleteConfirmProps {
name: string;
onConfirm: () => void;
onClose: () => void;
}
export interface StatusBadgeProps {
available: boolean;
}
@@ -0,0 +1,165 @@
"use client";
import type { Product } from "@/lib/types";
import { useState } from "react";
import type { ProductModalProps } from "./Manager.types";
export default function ProductModal({
product,
categories,
onSave,
onClose,
}: ProductModalProps) {
const isEdit = product !== null;
const [form, setForm] = useState<Omit<Product, "id">>({
name: product?.name ?? "",
category: product?.category ?? categories[0]?.id ?? "",
price: product?.price ?? 0,
image: product?.image ?? "/imgs/products/placeholder.jpg",
description: product?.description ?? "",
available: product?.available ?? true,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit && product) {
onSave({ ...form, id: product.id });
} else {
onSave(form);
}
};
const inputCls =
"text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa món" : "Thêm món mới"}
</h2>
<button
onClick={onClose}
title="Đóng"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên món <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="Ví dụ: Cà Phê Đen"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Danh mục <span className="text-red-500">*</span>
</label>
<select
required
title="Chọn danh mục"
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputCls}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá (đ) <span className="text-red-500">*</span>
</label>
<input
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
placeholder="25000"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
</label>
<textarea
rows={3}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
placeholder="Mô tả ngắn về món..."
/>
</div>
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
<div>
<p className="text-foreground text-sm font-medium">Trạng thái</p>
<p className="text-xs text-(--color-text-muted)">
{form.available ? "Còn hàng" : "Tạm hết"}
</p>
</div>
<button
title="Chuyển trạng thái"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
form.available ? "bg-(--color-primary)" : "bg-gray-300"
}`}
>
<span
className={`absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
form.available ? "translate-x-5.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
type="submit"
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
{isEdit ? "Lưu thay đổi" : "Thêm món"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { Product } from "@/lib/types";
import { useState } from "react";
import DeleteConfirm from "./DeleteConfirm";
import ProductModal from "./ProductModal";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function ProductsTab() {
const {
products,
categories,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
} = useManager();
const [filterCategory, setFilterCategory] = useState("all");
const [filterStatus, setFilterStatus] = useState<
"all" | "available" | "unavailable"
>("all");
const [search, setSearch] = useState("");
const [modalProduct, setModalProduct] = useState<Product | null | "new">(
null,
);
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
const filtered = products.filter((p) => {
if (filterCategory !== "all" && p.category !== filterCategory) return false;
if (filterStatus === "available" && p.available === false) return false;
if (filterStatus === "unavailable" && p.available !== false) return false;
if (
search &&
!p.name.toLowerCase().includes(search.toLowerCase()) &&
!p.description.toLowerCase().includes(search.toLowerCase())
)
return false;
return true;
});
const getCategoryName = (id: string) =>
categories.find((c) => c.id === id)?.name ?? id;
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative min-w-2/5 flex-1">
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tìm kiếm món..."
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white py-2 pr-9 pl-9 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
{search && (
<button
title="Xóa tìm kiếm"
onClick={() => setSearch("")}
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
title="Lọc theo danh mục"
>
<option value="all">Tất cả danh mục</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) =>
setFilterStatus(
e.target.value as "all" | "available" | "unavailable",
)
}
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
title="Lọc theo trạng thái"
>
<option value="all">Tất cả trạng thái</option>
<option value="available">Còn hàng</option>
<option value="unavailable">Tạm hết</option>
</select>
<button
title="Thêm món"
onClick={() => setModalProduct("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm món</span>
</button>
</div>
<p className="text-sm text-(--color-text-muted)">
Hiển thị <strong className="text-foreground">{filtered.length}</strong>{" "}
/ {products.length} món
</p>
{/* Table */}
<div className="overflow-x-auto rounded-2xl border border-(--color-border-light) bg-white shadow-sm">
<table className="min-w-full divide-y divide-(--color-border-light) text-sm">
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Tên món
</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-right font-semibold text-(--color-text-secondary)">
Giá
</th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Trạng thái
</th>
<th className="px-4 py-3 text-center font-semibold text-(--color-text-secondary)">
Thao tác
</th>
</tr>
</thead>
<tbody className="divide-y divide-(--color-border-light)">
{filtered.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-12 text-center text-(--color-text-muted)"
>
<i className="fa-solid fa-mug-hot mb-2 block text-3xl opacity-30"></i>
Không tìm thấy món nào
</td>
</tr>
) : (
filtered.map((p) => (
<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>
{p.description && (
<p className="mt-0.5 max-w-xs truncate text-xs text-(--color-text-muted)">
{p.description}
</p>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-(--color-accent-light) px-2.5 py-0.5 text-xs font-medium text-(--color-primary-dark)">
<i
className={`${categories.find((c) => c.id === p.category)?.icon ?? "fa-solid fa-tag"} text-[10px]`}
></i>
{getCategoryName(p.category)}
</span>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(p.price)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleProductAvailability(p.id)}
title="Nhấn để đổi trạng thái"
className="cursor-pointer border-none bg-transparent"
>
<StatusBadge available={p.available ?? true} />
</button>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1.5">
<button
onClick={() => setModalProduct(p)}
title="Chỉnh sửa"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-pen text-xs"></i>
</button>
<button
onClick={() => setDeleteTarget(p)}
title="Xóa"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
>
<i className="fa-solid fa-trash text-xs"></i>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{modalProduct !== null && (
<ProductModal
product={modalProduct === "new" ? null : modalProduct}
categories={categories}
onSave={(data) => {
if ("id" in data) {
updateProduct(data as Product);
} else {
addProduct(data);
}
setModalProduct(null);
}}
onClose={() => setModalProduct(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteProduct(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import type { StatusBadgeProps } from "./Manager.types";
export default function StatusBadge({ available }: StatusBadgeProps) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
available
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
available ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
{available ? "Còn hàng" : "Tạm hết"}
</span>
);
}
+15
View File
@@ -0,0 +1,15 @@
export { default as StatusBadge } from "./StatusBadge";
export { default as DeleteConfirm } from "./DeleteConfirm";
export { default as ProductModal } from "./ProductModal";
export { default as CategoryModal } from "./CategoryModal";
export { default as ComboModal } from "./ComboModal";
export { default as ProductsTab } from "./ProductsTab";
export { default as CategoriesTab } from "./CategoriesTab";
export { default as CombosTab } from "./CombosTab";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./Manager.types";
@@ -0,0 +1,14 @@
export interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
}
export interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { Button, Caption, Heading, Text, Textarea } from "@/components/atoms";
import { useState } from "react";
import type { ReviewModalProps } from "./Modal.types";
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>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as ReviewModal } from "./ReviewModal";
export type { ReviewModalProps, ConfirmModalProps } from "./Modal.types";
@@ -0,0 +1,116 @@
"use client";
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
import type { MenuCategory } from "@/lib/types";
import type React from "react";
import type { CategorySidebarProps } from "./Navigation.types";
/**
* 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 CategorySidebar({
isOpen,
onToggle,
activeCategory = "all",
onCategoryChange,
}: CategorySidebarProps) {
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>
);
}
@@ -0,0 +1,10 @@
export interface CategorySidebarProps {
/** 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;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as CategorySidebar } from "./CategorySidebar";
export type { CategorySidebarProps } from "./Navigation.types";
@@ -0,0 +1,90 @@
"use client";
import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import type { ProductGridProps } from "./ProductGrid.types";
export default function ProductGrid({
searchQuery = "",
isSidebarOpen = false,
}: ProductGridProps) {
const { activeCategory, setActiveCategory } = useMenu();
const { addToCart } = useCart();
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
const isAvailable = p.available !== false;
const matchesCategory =
activeCategory === "all" || p.category === activeCategory;
const matchesSearch =
searchQuery.trim() === "" ||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
return isAvailable && matchesCategory && matchesSearch;
});
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
const gridCols = isSidebarOpen
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
return (
<>
{/* ── Mobile category menu — visible only on < md ── */}
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
<div className="flex items-center gap-1.5 pb-1">
{MENU_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
<i
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
<span>{cat.name}</span>
</button>
);
})}
</div>
</div>
{/* ── Product grid ── */}
{filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
image={product.image}
imageAlt={product.name}
productName={product.name}
price={product.price}
description={product.description}
onBuy={() => addToCart(product)}
/>
))}
</div>
) : (
/* Empty state */
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
<p className="text-base font-medium">
{searchQuery
? `Không tìm thấy món nào cho "${searchQuery}"`
: "Chưa có món trong danh mục này"}
</p>
</div>
)}
</>
);
}
@@ -0,0 +1,4 @@
export interface ProductGridProps {
searchQuery?: string;
isSidebarOpen?: boolean;
}
@@ -0,0 +1,2 @@
export { default as ProductGrid } from "./ProductGrid";
export type { ProductGridProps } from "./ProductGrid.types";
@@ -0,0 +1,44 @@
"use client";
import { ShopCard } from "@/components/molecules/cards";
import { MOCK_SHOPS } from "@/lib/constants";
import type { ShopGridProps } from "./ShopGrid.types";
export default function ShopGrid({
searchName = "",
searchAddress = "",
}: ShopGridProps) {
const filtered = MOCK_SHOPS.filter((shop) => {
const matchesName =
searchName.trim() === "" ||
shop.name.toLowerCase().includes(searchName.toLowerCase());
const matchesAddress =
searchAddress.trim() === "" ||
shop.address.toLowerCase().includes(searchAddress.toLowerCase());
return matchesName && matchesAddress;
});
if (filtered.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium">Không tìm thấy quán nào phù hợp</p>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((shop) => (
<ShopCard
key={shop.id}
id={shop.id}
name={shop.name}
address={shop.address}
image={shop.image}
/>
))}
</div>
);
}
@@ -0,0 +1,4 @@
export interface ShopGridProps {
searchName?: string;
searchAddress?: string;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as ShopGrid } from "./ShopGrid";
export type { ShopGridProps } from "./ShopGrid.types";
@@ -0,0 +1,13 @@
import type { AuthLayoutProps } from "./AuthLayout.types";
/**
* Auth layout template — centers content in the screen.
* Used by login and register pages.
*/
export default function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
{children}
</div>
);
}
@@ -0,0 +1,3 @@
export interface AuthLayoutProps {
children: React.ReactNode;
}
@@ -0,0 +1,2 @@
export { default as AuthLayout } from "./AuthLayout";
export type { AuthLayoutProps } from "./AuthLayout.types";
@@ -0,0 +1,40 @@
import Image from "next/image";
import Link from "next/link";
import type { FeedLayoutProps } from "./FeedLayout.types";
/**
* Feed layout template — custom minimal header (Drinkool brand, no auth button).
* Used by the (feed) route group.
*/
export default function FeedLayout({ children }: FeedLayoutProps) {
return (
<>
{/* Custom Drinkool header — no login button */}
<header className="sticky top-0 z-50 h-(--spacing-header-height) w-full border-b border-(--color-border) bg-(--color-bg-header) shadow-[0_1px_8px_var(--color-shadow-sm)]">
<div className="mx-auto flex h-full max-w-screen-2xl items-center gap-4 px-6 md:px-8 lg:px-12">
<Link
href="/feed"
className="group flex shrink-0 items-center gap-3 no-underline"
>
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
<Image
src="/imgs/logo.png"
alt="Logo Drinkool"
fill
className="object-contain transition-transform duration-200 group-hover:scale-105"
sizes="44px"
priority
/>
</div>
<span className="text-lg font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-xl">
Drinkool
</span>
</Link>
</div>
</header>
<div className="flex-1">{children}</div>
</>
);
}
@@ -0,0 +1,3 @@
export interface FeedLayoutProps {
children: React.ReactNode;
}
@@ -0,0 +1,2 @@
export { default as FeedLayout } from "./FeedLayout";
export type { FeedLayoutProps } from "./FeedLayout.types";
+15
View File
@@ -0,0 +1,15 @@
// Main Layout
export { MainLayout } from "./main-layout";
export type { MainLayoutProps } from "./main-layout";
// Auth Layout
export { AuthLayout } from "./auth-layout";
export type { AuthLayoutProps } from "./auth-layout";
// Feed Layout
export { FeedLayout } from "./feed-layout";
export type { FeedLayoutProps } from "./feed-layout";
// Manager Layout
export { ManagerLayout } from "./manager-layout";
export type { ManagerLayoutProps } from "./manager-layout";
@@ -0,0 +1,27 @@
import { CartFab } from "@/components/organisms/cart";
import Footer from "@/layouts/footer";
import Header from "@/layouts/header";
import type { MainLayoutProps } from "./MainLayout.types";
/**
* Main layout template — wraps content with Header, Footer, and CartFab.
* Used by the (main) route group layout.
*/
export default function MainLayout({ children }: MainLayoutProps) {
return (
<>
{/* Sticky top header */}
<Header />
{/* Page content (grows to fill remaining height) */}
<div className="flex-1">{children}</div>
{/* Footer always at bottom */}
<Footer />
{/* Global floating cart button */}
<CartFab />
</>
);
}
@@ -0,0 +1,3 @@
export interface MainLayoutProps {
children: React.ReactNode;
}
@@ -0,0 +1,2 @@
export { default as MainLayout } from "./MainLayout";
export type { MainLayoutProps } from "./MainLayout.types";
@@ -0,0 +1,49 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { ManagerProvider } from "@/lib/manager-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { ManagerLayoutProps } from "./ManagerLayout.types";
/**
* Manager layout template — wraps content with auth guard and ManagerProvider.
* Redirects non-managers away; shows loading state while auth resolves.
*/
export default function ManagerLayout({ children }: ManagerLayoutProps) {
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
if (user !== null && user.role !== "manager") {
router.replace("/");
}
}, [user, router]);
if (user === null) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
<Link
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
</Link>
</div>
</div>
);
}
if (user.role !== "manager") return null;
return (
<ManagerProvider>
<div className="bg-background flex min-h-screen flex-col">{children}</div>
</ManagerProvider>
);
}
@@ -0,0 +1,3 @@
export interface ManagerLayoutProps {
children: React.ReactNode;
}
@@ -0,0 +1,2 @@
export { default as ManagerLayout } from "./ManagerLayout";
export type { ManagerLayoutProps } from "./ManagerLayout.types";