Tái cấu trúc code
This commit is contained in:
@@ -3,7 +3,18 @@
|
||||
"allow": [
|
||||
"Bash(npx next build)",
|
||||
"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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -1,23 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { MOCK_SHOPS } from "@/lib/constants";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SearchBar } from "@/components/molecules/search-bar";
|
||||
import { ShopGrid } from "@/components/organisms/shop-grid";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function FeedPage() {
|
||||
const [searchName, setSearchName] = useState("");
|
||||
const [searchAddress, setSearchAddress] = useState("");
|
||||
|
||||
const filteredShops = 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;
|
||||
});
|
||||
const hasFilters = searchName || searchAddress;
|
||||
|
||||
return (
|
||||
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
|
||||
@@ -32,57 +23,11 @@ export default function FeedPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shop cards grid */}
|
||||
{filteredShops.length > 0 ? (
|
||||
<div className="mb-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredShops.map((shop) => (
|
||||
<div
|
||||
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>
|
||||
{/* Shop grid */}
|
||||
<div className="mb-10">
|
||||
<ShopGrid searchName={searchName} searchAddress={searchAddress} />
|
||||
{hasFilters && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchName("");
|
||||
@@ -94,38 +39,26 @@ export default function FeedPage() {
|
||||
</button>
|
||||
</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="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)">
|
||||
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
||||
<span>Lọc quán</span>
|
||||
</div>
|
||||
|
||||
{/* Search by name */}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<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"
|
||||
{/* Name search */}
|
||||
<SearchBar
|
||||
value={searchName}
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
onChange={setSearchName}
|
||||
onClear={() => setSearchName("")}
|
||||
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">
|
||||
<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
|
||||
|
||||
+3
-38
@@ -1,44 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FeedLayout } from "@/components/templates/feed-layout";
|
||||
|
||||
export default function FeedLayout({
|
||||
export default function RootFeedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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"
|
||||
>
|
||||
{/* 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 */}
|
||||
</>
|
||||
);
|
||||
return <FeedLayout>{children}</FeedLayout>;
|
||||
}
|
||||
|
||||
+3
-19
@@ -1,25 +1,9 @@
|
||||
import CartFab from "@/components/CartFab";
|
||||
import Footer from "@/layouts/footer";
|
||||
import Header from "@/layouts/header";
|
||||
import { MainLayout } from "@/components/templates/main-layout";
|
||||
|
||||
export default function MainLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
return <MainLayout>{children}</MainLayout>;
|
||||
}
|
||||
|
||||
+14
-124
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import CartProduct from "@/components/CartProduct";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
||||
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||
import { ProductGrid } from "@/components/organisms/product-grid";
|
||||
import { SearchBar } from "@/components/molecules/search-bar";
|
||||
import { useMenu } from "@/lib/menu-context";
|
||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
@@ -16,25 +16,13 @@ import { useEffect, useState } from "react";
|
||||
* Sidebar state:
|
||||
* - Desktop (≥ 1024px): expanded 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() {
|
||||
/* Shared category state comes from MenuContext so the header mobile menu
|
||||
* and this sidebar always reflect the same selection. */
|
||||
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 [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(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -44,45 +32,18 @@ export default function Home() {
|
||||
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(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchQuery("");
|
||||
}, [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 =
|
||||
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 (
|
||||
/* 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">
|
||||
{/* ── Sidebar ── */}
|
||||
<Navbar
|
||||
<CategorySidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={() => setIsSidebarOpen((prev) => !prev)}
|
||||
activeCategory={activeCategory}
|
||||
@@ -98,94 +59,23 @@ export default function Home() {
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<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"
|
||||
{/* Search bar */}
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={setSearchQuery}
|
||||
onClear={() => setSearchQuery("")}
|
||||
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>
|
||||
|
||||
{/* ── Mobile category menu — visible only on < md, below search, above products ── */}
|
||||
<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) => (
|
||||
<CartProduct
|
||||
key={product.id}
|
||||
image={product.image}
|
||||
imageAlt={product.name}
|
||||
productName={product.name}
|
||||
price={product.price}
|
||||
description={product.description}
|
||||
onBuy={() => addToCart(product)}
|
||||
{/* ── Product grid (organism handles mobile category menu + grid) ── */}
|
||||
<ProductGrid
|
||||
searchQuery={searchQuery}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import ReviewModal from "@/components/ReviewModal";
|
||||
import { ReviewModal } from "@/components/organisms/modals";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useCart } from "@/lib/cart-context";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -1,49 +1,9 @@
|
||||
"use client";
|
||||
import { ManagerLayout } from "@/components/templates/manager-layout";
|
||||
|
||||
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";
|
||||
|
||||
export default function ManagerLayout({
|
||||
export default function RootManagerLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
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 có 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>
|
||||
);
|
||||
return <ManagerLayout>{children}</ManagerLayout>;
|
||||
}
|
||||
|
||||
+6
-1100
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,74 @@
|
||||
# Components Documentation
|
||||
|
||||
> Whenever you create a new component in the components/ directory,
|
||||
> automatically append a new section following the template below.
|
||||
> This project follows **Atomic Design** pattern.
|
||||
> 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.
|
||||
Displays product image, name, description, formatted price, and a Buy button.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type React from "react";
|
||||
|
||||
import type { HeadingProps } from "./Typography.types";
|
||||
|
||||
export default function Heading({
|
||||
@@ -15,7 +17,7 @@ export default function Heading({
|
||||
6: "text-sm font-semibold",
|
||||
};
|
||||
|
||||
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
|
||||
const Tag = `h${level}` as React.ElementType;
|
||||
|
||||
return (
|
||||
<Tag className={`text-foreground ${sizes[level]} ${className}`} {...props}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ProductCard } from "./ProductCard";
|
||||
export { default as ShopCard } from "./ShopCard";
|
||||
export type { ProductCardProps, ShopCardProps } from "./Card.types";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SearchBar } from "./SearchBar";
|
||||
export type { SearchBarProps } from "./Search.types";
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface CartFabProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CartFab } from "./CartFab";
|
||||
export type { CartFabProps } from "./Cart.types";
|
||||
@@ -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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -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)">
|
||||
Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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)">
|
||||
Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 có 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";
|
||||
Reference in New Issue
Block a user