Tái cấu trúc code
This commit is contained in:
@@ -3,7 +3,18 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npx next build)",
|
"Bash(npx next build)",
|
||||||
"Bash(npx tsc --noEmit)",
|
"Bash(npx tsc --noEmit)",
|
||||||
"Bash(npm run lint)"
|
"Bash(npm run lint)",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(feed\\)/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/payment/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/login/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(main\\)/register/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(feed\\)/feed/\")",
|
||||||
|
"Bash(ls \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/\")",
|
||||||
|
"Bash(npm run build)",
|
||||||
|
"Bash(cat \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")",
|
||||||
|
"Bash(wc -l \"c:/VS code/CongNghePhanMem/Final/frondend/app/\\(manager\\)/manager/page.tsx\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { MOCK_SHOPS } from "@/lib/constants";
|
import { SearchBar } from "@/components/molecules/search-bar";
|
||||||
import Image from "next/image";
|
import { ShopGrid } from "@/components/organisms/shop-grid";
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function FeedPage() {
|
export default function FeedPage() {
|
||||||
const [searchName, setSearchName] = useState("");
|
const [searchName, setSearchName] = useState("");
|
||||||
const [searchAddress, setSearchAddress] = useState("");
|
const [searchAddress, setSearchAddress] = useState("");
|
||||||
|
|
||||||
const filteredShops = MOCK_SHOPS.filter((shop) => {
|
const hasFilters = searchName || searchAddress;
|
||||||
const matchesName =
|
|
||||||
searchName.trim() === "" ||
|
|
||||||
shop.name.toLowerCase().includes(searchName.toLowerCase());
|
|
||||||
const matchesAddress =
|
|
||||||
searchAddress.trim() === "" ||
|
|
||||||
shop.address.toLowerCase().includes(searchAddress.toLowerCase());
|
|
||||||
return matchesName && matchesAddress;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
|
<main className="bg-background min-h-[calc(100vh-var(--spacing-header-height))]">
|
||||||
@@ -32,57 +23,11 @@ export default function FeedPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shop cards grid */}
|
{/* Shop grid */}
|
||||||
{filteredShops.length > 0 ? (
|
<div className="mb-10">
|
||||||
<div className="mb-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<ShopGrid searchName={searchName} searchAddress={searchAddress} />
|
||||||
{filteredShops.map((shop) => (
|
{hasFilters && (
|
||||||
<div
|
<div className="mt-4 flex justify-center">
|
||||||
key={shop.id}
|
|
||||||
className="overflow-hidden rounded-2xl border border-(--color-border) bg-(--color-bg-card) shadow-[0_2px_12px_var(--color-shadow-sm)] transition-all duration-250 hover:-translate-y-1 hover:shadow-[0_4px_20px_var(--color-shadow-md)]"
|
|
||||||
>
|
|
||||||
{/* Shop image */}
|
|
||||||
<div className="relative h-48 w-full sm:h-52">
|
|
||||||
<Image
|
|
||||||
src={shop.image}
|
|
||||||
alt={shop.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card body */}
|
|
||||||
<div className="p-4">
|
|
||||||
{/* Name + View menu button */}
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
|
||||||
<h3 className="text-foreground truncate text-base font-bold">
|
|
||||||
{shop.name}
|
|
||||||
</h3>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl bg-(--color-primary) px-3.5 py-2 text-xs font-semibold text-white no-underline transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-book-open text-[10px]"></i>
|
|
||||||
Xem menu
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div className="flex items-start gap-2 text-sm text-(--color-text-muted)">
|
|
||||||
<i className="fa-solid fa-location-dot mt-0.5 shrink-0 text-(--color-accent)"></i>
|
|
||||||
<span>{shop.address}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Empty state */
|
|
||||||
<div className="mb-10 flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
|
||||||
<i className="fa-solid fa-store text-5xl opacity-30"></i>
|
|
||||||
<p className="text-base font-medium">
|
|
||||||
Không tìm thấy quán nào phù hợp
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchName("");
|
setSearchName("");
|
||||||
@@ -94,38 +39,26 @@ export default function FeedPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filter / Search bar */}
|
{/* Filter / Search bar — sticky bottom */}
|
||||||
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
|
<div className="sticky bottom-0 rounded-2xl border border-(--color-border) bg-(--color-bg-card) p-4 shadow-[0_-2px_16px_var(--color-shadow-sm)] md:p-5">
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||||
{/* Label */}
|
|
||||||
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
|
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold text-(--color-text-secondary)">
|
||||||
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
<i className="fa-solid fa-filter text-(--color-primary)"></i>
|
||||||
<span>Lọc quán</span>
|
<span>Lọc quán</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search by name */}
|
{/* Name search */}
|
||||||
<div className="relative min-w-0 flex-1">
|
<SearchBar
|
||||||
<i className="fa-solid fa-store pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchName}
|
value={searchName}
|
||||||
onChange={(e) => setSearchName(e.target.value)}
|
onChange={setSearchName}
|
||||||
|
onClear={() => setSearchName("")}
|
||||||
placeholder="Tìm theo tên quán..."
|
placeholder="Tìm theo tên quán..."
|
||||||
className="bg-background text-foreground focus:ring-opacity-20 w-full rounded-xl border border-(--color-border) py-2.5 pr-9 pl-9 text-sm transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)"
|
className="min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
{searchName && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchName("")}
|
|
||||||
aria-label="Xóa tìm kiếm tên"
|
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-xmark text-sm"></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search by address */}
|
{/* Address search — different icon so not using SearchBar atom */}
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
|
<i className="fa-solid fa-location-dot pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-xs text-(--color-text-muted)"></i>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+3
-38
@@ -1,44 +1,9 @@
|
|||||||
import Image from "next/image";
|
import { FeedLayout } from "@/components/templates/feed-layout";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function FeedLayout({
|
export default function RootFeedLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <FeedLayout>{children}</FeedLayout>;
|
||||||
<>
|
|
||||||
{/* Custom Drinkool header — no login button */}
|
|
||||||
<header className="sticky top-0 z-50 h-(--spacing-header-height) w-full border-b border-(--color-border) bg-(--color-bg-header) shadow-[0_1px_8px_var(--color-shadow-sm)]">
|
|
||||||
<div className="mx-auto flex h-full max-w-screen-2xl items-center gap-4 px-6 md:px-8 lg:px-12">
|
|
||||||
<Link
|
|
||||||
href="/feed"
|
|
||||||
className="group flex shrink-0 items-center gap-3 no-underline"
|
|
||||||
>
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="relative h-10 w-10 shrink-0 md:h-11 md:w-11">
|
|
||||||
<Image
|
|
||||||
src="/imgs/logo.png"
|
|
||||||
alt="Logo Drinkool"
|
|
||||||
fill
|
|
||||||
className="object-contain transition-transform duration-200 group-hover:scale-105"
|
|
||||||
sizes="44px"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Brand name */}
|
|
||||||
<span className="text-lg font-bold text-(--color-primary-dark) transition-colors duration-150 group-hover:text-(--color-primary) md:text-xl">
|
|
||||||
Drinkool
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Page content */}
|
|
||||||
<div className="flex-1">{children}</div>
|
|
||||||
|
|
||||||
{/* No footer */}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-19
@@ -1,25 +1,9 @@
|
|||||||
import CartFab from "@/components/CartFab";
|
import { MainLayout } from "@/components/templates/main-layout";
|
||||||
import Footer from "@/layouts/footer";
|
|
||||||
import Header from "@/layouts/header";
|
|
||||||
|
|
||||||
export default function MainLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <MainLayout>{children}</MainLayout>;
|
||||||
<>
|
|
||||||
{/* Sticky top header */}
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
{/* Page content (grows to fill remaining height) */}
|
|
||||||
<div className="flex-1">{children}</div>
|
|
||||||
|
|
||||||
{/* Footer always at bottom */}
|
|
||||||
<Footer />
|
|
||||||
|
|
||||||
{/* Global floating cart button */}
|
|
||||||
<CartFab />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-124
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import CartProduct from "@/components/CartProduct";
|
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||||
import Navbar from "@/components/Navbar";
|
import { ProductGrid } from "@/components/organisms/product-grid";
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { SearchBar } from "@/components/molecules/search-bar";
|
||||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
|
||||||
import { useMenu } from "@/lib/menu-context";
|
import { useMenu } from "@/lib/menu-context";
|
||||||
|
import { MENU_CATEGORIES } from "@/lib/constants";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,25 +16,13 @@ import { useEffect, useState } from "react";
|
|||||||
* Sidebar state:
|
* Sidebar state:
|
||||||
* - Desktop (≥ 1024px): expanded by default
|
* - Desktop (≥ 1024px): expanded by default
|
||||||
* - Mobile (< 1024px): collapsed by default
|
* - Mobile (< 1024px): collapsed by default
|
||||||
*
|
|
||||||
* Product grid columns (responsive, depends on sidebar state):
|
|
||||||
* Collapsed sidebar: 2 → sm:2 → lg:3 → xl:4 → 2xl:5
|
|
||||||
* Expanded sidebar: 1 → sm:2 → lg:2 → xl:3 → 2xl:4
|
|
||||||
*/
|
*/
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
/* Shared category state comes from MenuContext so the header mobile menu
|
|
||||||
* and this sidebar always reflect the same selection. */
|
|
||||||
const { activeCategory, setActiveCategory } = useMenu();
|
const { activeCategory, setActiveCategory } = useMenu();
|
||||||
const { addToCart } = useCart();
|
|
||||||
|
|
||||||
/* Start collapsed (false) so SSR and client initial render match.
|
|
||||||
* useEffect sets the correct value after hydration completes. */
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
/* After mount: sync sidebar with viewport width, then subscribe to changes.
|
|
||||||
* The initial setIsSidebarOpen call is intentional (post-hydration only)
|
|
||||||
* and does not cause cascading renders — suppress the lint rule here. */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(min-width: 1024px)");
|
const mq = window.matchMedia("(min-width: 1024px)");
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
@@ -44,45 +32,18 @@ export default function Home() {
|
|||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* Clear search whenever the active category changes (triggered from either
|
|
||||||
* the sidebar on md+ or the header scrollable menu on < md).
|
|
||||||
* setState-in-effect is intentional here — suppress the lint rule. */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}, [activeCategory]);
|
}, [activeCategory]);
|
||||||
|
|
||||||
/* Filter products by availability, active category, and search query.
|
|
||||||
* p.available defaults to true when undefined (opt-in unavailability). */
|
|
||||||
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
|
|
||||||
const isAvailable = p.available !== false;
|
|
||||||
const matchesCategory =
|
|
||||||
activeCategory === "all" || p.category === activeCategory;
|
|
||||||
const matchesSearch =
|
|
||||||
searchQuery.trim() === "" ||
|
|
||||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
return isAvailable && matchesCategory && matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Active category label */
|
|
||||||
const activeCategoryLabel =
|
const activeCategoryLabel =
|
||||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
||||||
|
|
||||||
/* Responsive grid class based on sidebar state
|
|
||||||
* Base (< 480px) : 1 col — very small phones
|
|
||||||
* min-[480px] : 2 cols — larger phones
|
|
||||||
* lg+ : 2/3 cols depending on sidebar
|
|
||||||
*/
|
|
||||||
const gridCols = isSidebarOpen
|
|
||||||
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
|
||||||
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Outer wrapper: flex row, align-items: flex-start so sidebar sticks */
|
|
||||||
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
|
||||||
{/* ── Sidebar ── */}
|
{/* ── Sidebar ── */}
|
||||||
<Navbar
|
<CategorySidebar
|
||||||
isOpen={isSidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
onToggle={() => setIsSidebarOpen((prev) => !prev)}
|
onToggle={() => setIsSidebarOpen((prev) => !prev)}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
@@ -98,94 +59,23 @@ export default function Home() {
|
|||||||
<h2 className="text-foreground text-xl font-bold">
|
<h2 className="text-foreground text-xl font-bold">
|
||||||
{activeCategoryLabel}
|
{activeCategoryLabel}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
||||||
{filteredProducts.length} món
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search input */}
|
{/* Search bar */}
|
||||||
<div className="relative w-full sm:max-w-xs">
|
<SearchBar
|
||||||
<i className="fa-solid fa-magnifying-glass pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm text-(--color-text-muted)"></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={setSearchQuery}
|
||||||
|
onClear={() => setSearchQuery("")}
|
||||||
placeholder="Tìm kiếm món..."
|
placeholder="Tìm kiếm món..."
|
||||||
className="bg-card text-foreground border-border placeholder:text-muted-foreground focus:border-primary focus:ring-primary focus:ring-opacity-20 w-full rounded-xl border py-2 pr-9 pl-9 text-sm transition-all duration-150 outline-none focus:ring-2"
|
className="sm:max-w-xs"
|
||||||
/>
|
/>
|
||||||
{/* Clear button */}
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
title="Xóa tìm kiếm"
|
|
||||||
aria-label="Xóa tìm kiếm"
|
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer border-none bg-transparent p-0 text-(--color-text-muted) transition-colors duration-150 hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-xmark text-sm"></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Mobile category menu — visible only on < md, below search, above products ── */}
|
{/* ── Product grid (organism handles mobile category menu + grid) ── */}
|
||||||
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
|
<ProductGrid
|
||||||
<div className="flex items-center gap-1.5 pb-1">
|
searchQuery={searchQuery}
|
||||||
{MENU_CATEGORIES.map((cat) => {
|
isSidebarOpen={isSidebarOpen}
|
||||||
const isActive = activeCategory === cat.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
onClick={() => setActiveCategory(cat.id)}
|
|
||||||
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
|
|
||||||
isActive
|
|
||||||
? "bg-(--color-primary) text-white shadow-sm"
|
|
||||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
|
||||||
} `}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
|
||||||
></i>
|
|
||||||
<span>{cat.name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Product grid ── */}
|
|
||||||
{filteredProducts.length > 0 ? (
|
|
||||||
<div className={`grid gap-4 ${gridCols}`}>
|
|
||||||
{filteredProducts.map((product) => (
|
|
||||||
<CartProduct
|
|
||||||
key={product.id}
|
|
||||||
image={product.image}
|
|
||||||
imageAlt={product.name}
|
|
||||||
productName={product.name}
|
|
||||||
price={product.price}
|
|
||||||
description={product.description}
|
|
||||||
onBuy={() => addToCart(product)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Empty state */
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
|
|
||||||
<i className="fa-solid fa-mug-hot text-5xl opacity-30"></i>
|
|
||||||
<p className="text-base font-medium">
|
|
||||||
{searchQuery
|
|
||||||
? `Không tìm thấy món nào cho "${searchQuery}"`
|
|
||||||
: "Chưa có món trong danh mục này"}
|
|
||||||
</p>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
|
|
||||||
>
|
|
||||||
Xóa tìm kiếm
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ReviewModal from "@/components/ReviewModal";
|
import { ReviewModal } from "@/components/organisms/modals";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { useCart } from "@/lib/cart-context";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|||||||
@@ -1,49 +1,9 @@
|
|||||||
"use client";
|
import { ManagerLayout } from "@/components/templates/manager-layout";
|
||||||
|
|
||||||
import { useAuth } from "@/lib/auth-context";
|
export default function RootManagerLayout({
|
||||||
import { ManagerProvider } from "@/lib/manager-context";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function ManagerLayout({
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
return <ManagerLayout>{children}</ManagerLayout>;
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Redirect non-managers away
|
|
||||||
useEffect(() => {
|
|
||||||
if (user !== null && user.role !== "manager") {
|
|
||||||
router.replace("/");
|
|
||||||
}
|
|
||||||
}, [user, router]);
|
|
||||||
|
|
||||||
// While loading (user is null but not yet checked), show nothing
|
|
||||||
if (user === null) {
|
|
||||||
return (
|
|
||||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
|
|
||||||
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
|
|
||||||
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="text-sm font-medium text-(--color-primary) hover:underline"
|
|
||||||
>
|
|
||||||
Đăng nhập nếu chưa 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1100
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,74 @@
|
|||||||
# Components Documentation
|
# Components Documentation
|
||||||
|
|
||||||
> Whenever you create a new component in the components/ directory,
|
> This project follows **Atomic Design** pattern.
|
||||||
> automatically append a new section following the template below.
|
> See `Atomic.md` at project root for full structure guide.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── atoms/ # Basic building blocks (Button, Input, Text, Badge...)
|
||||||
|
├── molecules/ # Groups of atoms (ProductCard, SearchBar...)
|
||||||
|
├── organisms/ # Complex UI sections (CategorySidebar, CartFab, ProductGrid, ReviewModal...)
|
||||||
|
├── templates/ # Page layout structures (MainLayout, AuthLayout...)
|
||||||
|
├── CartProduct.tsx # Legacy — use molecules/cards/ProductCard instead
|
||||||
|
├── Navbar.tsx # Legacy — use organisms/navigation/CategorySidebar instead
|
||||||
|
├── CartFab.tsx # Legacy — use organisms/cart/CartFab instead
|
||||||
|
└── ReviewModal.tsx # Legacy — use organisms/modals/ReviewModal instead
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CartProduct
|
## MOLECULES
|
||||||
|
|
||||||
|
### ProductCard (`molecules/cards/ProductCard.tsx`)
|
||||||
|
|
||||||
|
**Description:** Product card molecule. Displays product image, name, description, formatted price, and a Buy button.
|
||||||
|
Width is controlled by the parent grid (w-full), not the card itself.
|
||||||
|
|
||||||
|
**Atomic level:** Molecule (composed of Button, Caption, Text atoms)
|
||||||
|
|
||||||
|
### SearchBar (`molecules/search-bar/SearchBar.tsx`)
|
||||||
|
|
||||||
|
**Description:** Search input with clear button. Controlled component — value and onChange come from parent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ORGANISMS
|
||||||
|
|
||||||
|
### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`)
|
||||||
|
|
||||||
|
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header.
|
||||||
|
|
||||||
|
### CartFab (`organisms/cart/CartFab.tsx`)
|
||||||
|
|
||||||
|
**Description:** Floating Action Button displaying cart item count. Links to /payment page.
|
||||||
|
|
||||||
|
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
|
||||||
|
|
||||||
|
**Description:** Full product grid organism with mobile category tabs, filtering, and empty state. Uses useCart and useMenu contexts.
|
||||||
|
|
||||||
|
### ReviewModal (`organisms/modals/ReviewModal.tsx`)
|
||||||
|
|
||||||
|
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TEMPLATES
|
||||||
|
|
||||||
|
### MainLayout (`templates/main-layout/MainLayout.tsx`)
|
||||||
|
|
||||||
|
**Description:** Main layout template — wraps content with Header, Footer, and CartFab. Used by (main) route group.
|
||||||
|
|
||||||
|
### AuthLayout (`templates/auth-layout/AuthLayout.tsx`)
|
||||||
|
|
||||||
|
**Description:** Auth layout template — centers content in screen. Used by login/register pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LEGACY COMPONENTS (root level — kept for backward compatibility)
|
||||||
|
|
||||||
|
### CartProduct
|
||||||
|
|
||||||
**File:** components/CartProduct.tsx **Description:** Product card component.
|
**File:** components/CartProduct.tsx **Description:** Product card component.
|
||||||
Displays product image, name, description, formatted price, and a Buy button.
|
Displays product image, name, description, formatted price, and a Buy button.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
import type { HeadingProps } from "./Typography.types";
|
import type { HeadingProps } from "./Typography.types";
|
||||||
|
|
||||||
export default function Heading({
|
export default function Heading({
|
||||||
@@ -15,7 +17,7 @@ export default function Heading({
|
|||||||
6: "text-sm font-semibold",
|
6: "text-sm font-semibold",
|
||||||
};
|
};
|
||||||
|
|
||||||
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
|
const Tag = `h${level}` as React.ElementType;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag className={`text-foreground ${sizes[level]} ${className}`} {...props}>
|
<Tag className={`text-foreground ${sizes[level]} ${className}`} {...props}>
|
||||||
|
|||||||
@@ -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