Compare commits

...

9 Commits

Author SHA1 Message Date
TaNguyenThanhQuy c2afb3d3b5 fix: connect services to backend (#37)
Release package / release (push) Successful in 7m28s
Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn>
Reviewed-on: #37
Co-authored-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
Co-committed-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
2026-05-05 14:42:13 +00:00
gitea-actions 48fc033ffa chore: release [ci skip] 2026-04-19 09:02:56 +00:00
TakahashiNguyen 8b3b8946ab fix: update ci installing packages (#36)
Release package / release (push) Successful in 4m36s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #36
2026-04-19 08:58:51 +00:00
TakahashiNguyen 46d366a662 fix: minor (#35)
Release package / release (push) Failing after 2m18s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #35
2026-04-19 08:47:10 +00:00
TakahashiNguyen 96584c5494 fix: add connection to backend (#34)
Release package / release (push) Has been cancelled
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #34
2026-04-19 02:22:46 +00:00
gitea-actions 77f9a11132 chore: release [ci skip] 2026-04-17 06:48:04 +00:00
TaNguyenThanhQuy 60b5233e95 feat: add pages (#33)
Release package / release (push) Successful in 18m50s
Co-authored-by: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn>
Co-authored-by: Thanh Quy- wolf <524H0124@student.tdtu.edu.vn>
Reviewed-on: #33
Co-authored-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
Co-committed-by: TaNguyenThanhQuy <tanguyenthanhquy@noreply.localhost>
2026-04-17 06:28:43 +00:00
gitea-actions b9c0e6a59f chore: release [ci skip] 2026-04-14 03:05:52 +00:00
TakahashiNguyen 86d55540b4 fix(ci): modified push domain (#32)
Release package / release (push) Successful in 21m40s
Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #32
2026-04-14 02:44:16 +00:00
77 changed files with 3405 additions and 2165 deletions
-5
View File
@@ -1,5 +0,0 @@
## Rules:
- Khi làm một tính năng mới, trước khi hoàn thành phải update các file mark down mà thư mục đó được update.
- Khi có từ khóa "Yêu cầu" và một list các yêu cầu thì phải hoàn thành ĐÚNG yêu cầu, không thêm không bớt.
- Sử dụng thư viện tailwind CSS để code css cho project.
- Mỗi feature được update đều phải được responsive với các kích cỡ màn hình như smartphone, tablet, desktop.
@@ -0,0 +1,483 @@
---
name: frontend-atomic-design
description: Create modern, responsive frontend components and layouts using atomic design methodology with Tailwind CSS. Use this skill whenever building React components, designing UI layouts, creating responsive pages, or working with atomic design principles. Triggers include: component development, page design, responsive design requests, Tailwind CSS styling, creating design systems, building reusable UI elements, or designing for multiple screen sizes (desktop, tablet, mobile). This skill emphasizes reusable variables, modern aesthetics, and responsive design across all viewport sizes.
---
# Atomic Design Frontend System with Tailwind CSS
A comprehensive guide for building modern, responsive frontend applications
using atomic design principles and Tailwind CSS.
## Core Principles
### 1. Atomic Design Hierarchy
Atomic Design breaks UI into five distinct levels:
#### **Atoms**
Smallest, indivisible UI elements that cannot be broken down without losing
functionality.
- Buttons, input fields, labels, icons, text styles
- Color variables, spacing units, typography scales
- Simple, pure, reusable building blocks
```jsx
// Example: Button Atom
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700">
Click me
</button>
```
#### **Molecules**
Groups of atoms bonded together, forming simple functional units.
- Search bars (input + button + icon)
- Form fields (label + input + error message)
- Card headers (avatar + title + subtitle)
- Navigation items with icons and labels
```jsx
// Example: Search Molecule
<div className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2">
<SearchIcon className="h-5 w-5 text-gray-500" />
<input type="text" placeholder="Search..." className="flex-1 outline-none" />
</div>
```
#### **Organisms**
Complex functional units made of groups of molecules and/or atoms.
- Header/Navigation bars
- Form sections (multiple form molecules)
- Card layouts with multiple sections
- Modals with title, content, and actions
- Data tables with headers, rows, and pagination
```jsx
// Example: Product Card Organism
<div className="overflow-hidden rounded-lg border border-gray-200 shadow-sm transition-shadow hover:shadow-md">
<img src="image.jpg" className="h-48 w-full object-cover" />
<div className="p-4">
<h3 className="text-lg font-semibold">Product Name</h3>
<p className="mt-1 text-sm text-gray-600">Description</p>
<div className="mt-4 flex items-center justify-between">
<span className="font-bold text-blue-600">$99</span>
<button className="rounded bg-blue-600 px-3 py-1 text-white">Add</button>
</div>
</div>
</div>
```
#### **Templates**
Page-level wireframes showing layout and component placement without final
content.
- Single-column layouts
- Two-column layouts (sidebar + main)
- Grid-based layouts
- Hero + content sections
#### **Pages**
Specific instances of templates populated with real content and data.
- Homepage with actual products
- User profile with real user data
- Dashboard with live metrics
---
## Design System Variables (Reusable Values)
### Color Palette
```css
/* Define in Tailwind config or use CSS variables */
Primary: #2563eb (blue-600)
Secondary: #7c3aed (violet-600)
Success: #16a34a (green-600)
Warning: #ea580c (orange-600)
Danger: #dc2626 (red-600)
Neutral: #6b7280 (gray-500)
```
### Typography Scale
```css
H1: 32px (2rem) - font-bold
H2: 24px (1.5rem) - font-bold
H3: 20px (1.25rem) - font-semibold
Body: 16px (1rem) - font-normal
Small: 14px (0.875rem) - font-normal
Tiny: 12px (0.75rem) - font-normal
```
### Spacing Scale
```css
xs: 4px (0.25rem)
sm: 8px (0.5rem)
md: 16px (1rem)
lg: 24px (1.5rem)
xl: 32px (2rem)
2xl: 48px (3rem)
```
### Border Radius
```css
Subtle: 4px (rounded-sm)
Standard: 8px (rounded-lg)
Large: 12px (rounded-xl)
Full: 9999px (rounded-full)
```
### Box Shadows
```css
Subtle: 0 1px 2px rgba(0,0,0,0.05)
Soft: 0 4px 6px rgba(0,0,0,0.07)
Medium: 0 10px 15px rgba(0,0,0,0.10)
Strong: 0 20px 25px rgba(0,0,0,0.15)
```
---
## Responsive Design Strategy
### Breakpoints (Tailwind Default)
```
Mobile: < 640px (sm)
Tablet: 640px (md, lg)
Desktop: 1024px+ (xl, 2xl)
```
### Mobile-First Approach
1. **Start with mobile styles** (default, no prefix)
2. **Layer tablet styles** (md: prefix)
3. **Layer desktop styles** (lg:, xl: prefix)
### Example: Responsive Layout
```jsx
// Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<Card key={item.id} {...item} />
))}
</div>
```
### Common Responsive Patterns
**Responsive Typography**
```jsx
<h1 className="text-2xl font-bold md:text-3xl lg:text-4xl">
Responsive Heading
</h1>
```
**Responsive Padding/Margins**
```jsx
<div className="p-4 md:p-6 lg:p-8">Content with responsive spacing</div>
```
**Responsive Grid**
```jsx
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
{/* Grid items */}
</div>
```
**Responsive Flexbox**
```jsx
<div className="flex flex-col gap-4 md:flex-row">
<aside className="w-full md:w-64">Sidebar</aside>
<main className="flex-1">Main content</main>
</div>
```
**Responsive Images**
```jsx
<img
src="image.jpg"
className="h-auto w-full object-cover"
alt="Responsive image"
/>
```
---
## Modern Design Patterns
### 1. Consistency & Visual Hierarchy
- **Use consistent spacing**: Apply spacing scale uniformly
- **Establish clear hierarchy**: Size, weight, color for emphasis
- **Group related content**: Use whitespace to separate sections
- **Align elements**: Use grids for crisp layouts
```jsx
<section className="space-y-6">
<div>
<h2 className="mb-2 text-2xl font-bold">Section Title</h2>
<p className="text-gray-600">Description text</p>
</div>
<div className="space-y-4">{/* Related items with consistent spacing */}</div>
</section>
```
### 2. Interactive Feedback
- **Hover states**: Subtle color/shadow changes
- **Active states**: Indicate current selection
- **Focus states**: Keyboard navigation support
- **Loading states**: Spinners or skeleton screens
- **Transitions**: Smooth animations (200-300ms)
```jsx
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition-all duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none active:scale-95 disabled:cursor-not-allowed disabled:opacity-50">
Actionable Button
</button>
```
### 3. Depth & Layering
- **Subtle shadows**: Create depth without heaviness
- **Elevation levels**: Consistent shadow progression
- **Overlays**: Semi-transparent backgrounds for modals
- **Z-index strategy**: Clear layering hierarchy
```jsx
<div className="rounded-lg border border-gray-200 shadow-sm transition-shadow duration-300 hover:shadow-md">
Card content
</div>
```
### 4. Color Usage
- **Primary action**: Most frequent call-to-action
- **Secondary action**: Alternative actions
- **Semantic colors**: Status indicators (success, warning, danger)
- **Contrast**: Ensure WCAG AA compliance (4.5:1 ratio)
- **Limited palette**: 3-5 colors maximum in most designs
### 5. Whitespace & Breathing Room
- Don't crowd elements
- Use consistent gap values (gap-4, gap-6, gap-8)
- Separate sections with vertical rhythm
- Generous padding in cards and containers
```jsx
<div className="mx-auto max-w-4xl px-4 py-8 md:px-6 md:py-12">
<div className="space-y-8">{/* Sections with good breathing room */}</div>
</div>
```
---
## Tailwind CSS Best Practices
### 1. Use Utility Classes Effectively
```jsx
// Good: Semantic, reusable, organized
<button className="
px-4 py-2
bg-blue-600 text-white
rounded-lg
hover:bg-blue-700
transition-colors
disabled:opacity-50
">
Submit
</button>
// Avoid: Too many utilities, hard to read
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200">
```
### 2. Extract Reusable Components
```jsx
// Create a Button component to avoid repetition
const Button = ({ children, variant = "primary", ...props }) => {
const baseStyles = "px-4 py-2 rounded-lg font-medium transition-colors";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700",
};
return (
<button className={`${baseStyles} ${variants[variant]}`} {...props}>
{children}
</button>
);
};
```
### 3. Organize Utility Classes
```jsx
// Organize by category: layout → sizing → colors → effects → responsive
<div className="/* Layout */ /* Sizing */ /* Spacing */ /* Colors & text */ /* Borders & shadows */ /* Effects */ /* Responsive */ my-8 flex w-full max-w-2xl flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 text-gray-900 shadow-sm transition-shadow hover:shadow-md md:flex-row lg:gap-6"></div>
```
### 4. Use Tailwind Config for Consistency
```js
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: "#2563eb",
secondary: "#7c3aed",
},
spacing: {
gutter: "1rem",
},
},
},
};
```
---
## Component Construction Template
Use this template when building atomic components:
```jsx
/**
* {Component Name}
*
* Atoms/Molecules/Organisms level component
* Purpose: [Brief description]
*
* Props:
* - prop1: type - description
* - prop2: type - description
*/
export const ComponentName = ({ prop1, prop2, className = "" }) => {
return (
<div
className={`/* Base styles */ /* Responsive */ /* Custom className */ flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 md:flex-row lg:p-8 ${className} `}
>
{/* Component content */}
</div>
);
};
```
---
## Layout Patterns
### Container + Padding
```jsx
<div className="mx-auto max-w-6xl px-4 md:px-6 lg:px-8">
{/* Content constrained to max width with responsive padding */}
</div>
```
### Two-Column Sidebar Layout
```jsx
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="w-full flex-shrink-0 lg:w-64">
{/* Sidebar: full width on mobile, fixed on desktop */}
</aside>
<main className="min-w-0 flex-1">
{/* Main content: takes remaining space */}
</main>
</div>
```
### Responsive Grid
```jsx
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Items automatically stack on mobile, 2 columns on tablet, 3 on desktop */}
</div>
```
### Hero Section
```jsx
<section className="relative overflow-hidden bg-gradient-to-r from-blue-600 to-violet-600 py-12 text-white md:py-20 lg:py-32">
<div className="relative z-10 mx-auto max-w-6xl px-4 md:px-6">
<h1 className="mb-4 text-4xl font-bold md:text-5xl lg:text-6xl">
Hero Title
</h1>
<p className="mb-8 max-w-2xl text-lg opacity-90 md:text-xl">
Hero subtitle or description
</p>
<button className="rounded-lg bg-white px-8 py-3 font-semibold text-blue-600 transition-colors hover:bg-gray-100">
Call to Action
</button>
</div>
</section>
```
---
## Accessibility & Best Practices
1. **Semantic HTML**: Use `<button>`, `<a>`, `<form>`, etc. appropriately
2. **Color contrast**: Ensure 4.5:1 for text, 3:1 for UI components
3. **Focus states**: Always visible keyboard navigation (`focus:ring-2`)
4. **ARIA labels**: Add when needed (`aria-label`, `aria-describedby`)
5. **Responsive text**: Use relative sizing, not fixed pixels
6. **Touch targets**: Minimum 44x44px for interactive elements
```jsx
<button
className="min-h-[44px] px-4 py-3 focus:ring-2 focus:ring-offset-2 focus:outline-none"
aria-label="Close modal"
>
</button>
```
---
## Performance Considerations
1. **Limit custom CSS**: Rely on Tailwind utilities
2. **Tree-shake unused styles**: Configure Tailwind content paths
3. **Optimize images**: Use responsive images, WebP format
4. **Lazy load**: Defer non-critical components
5. **Minimize bundle**: Use PurgeCSS in production
---
## Quick Reference Checklist
When building a component, ensure:
- [ ] Follows atomic design hierarchy (Atom/Molecule/Organism)
- [ ] Uses design system variables (colors, spacing, typography)
- [ ] Responsive across mobile, tablet, desktop
- [ ] Consistent hover/active/focus states
- [ ] Proper whitespace and visual hierarchy
- [ ] Semantic HTML structure
- [ ] Accessible (contrast, focus, labels)
- [ ] Mobile-first approach
- [ ] No hardcoded values (use Tailwind config)
@@ -0,0 +1,7 @@
{
"name": "frontend-atomic-design",
"version": "1.0.0",
"description": "Create modern, responsive frontend components and layouts using atomic design methodology with Tailwind CSS",
"author": "Claude",
"created": "2026-04-12"
}
+112
View File
@@ -0,0 +1,112 @@
---
name: prompt-optimizer
description:
Help users rewrite and improve AI/LLM prompts by adding specificity, context,
and constraints. Trigger this skill whenever users ask to improve, rewrite,
optimize, or refine prompts for AI models. Focus on making prompts clearer,
more specific, and more likely to produce better AI results. Present
suggestions interactively so users can choose which improvements to apply.
---
# Prompt Optimizer
A beginner-friendly skill for improving AI/LLM prompts to get better results.
## What This Skill Does
This skill helps you rewrite prompts to work better with AI models like Claude.
Instead of just giving you a rewritten prompt, it shows you specific improvement
suggestions that you can choose to apply or skip.
## Key Improvements
When optimizing a prompt, focus on three main areas:
### 1. **Specificity** — Making the Request Clear
Good prompts are specific about what you want. Vague prompts get vague results.
**Example improvements:**
- Add details about format: "Give me a bullet list of 5 items" instead of "tell
me about X"
- Be clear about length: "Write 200 words" instead of "Write something short"
- Define who the audience is: "Explain this for a 10-year-old" or "Use technical
language"
### 2. **Context** — Giving the AI Background Information
More context helps the AI make better decisions.
**Example improvements:**
- Explain the goal: "I'm writing a resume, so focus on professional language"
- Share constraints: "We only have $500 budget" or "It needs to work on mobile"
- Provide background: "I already know Python but not JavaScript"
### 3. **Constraints** — Setting Boundaries
Constraints prevent unwanted outputs.
**Example improvements:**
- Set length limits: "Keep it under 100 words"
- Specify format: "Use JSON format" or "Write as a numbered list"
- Define tone: "Be casual and friendly, not formal"
- Say what NOT to include: "Don't use technical jargon"
## How to Use This Skill
1. **Share your prompt** — Give me the original prompt you want to improve
2. **Review suggestions** — I'll show you specific improvements in each area
3. **Choose what you like** — Pick which suggestions to apply
4. **Get the final version** — I'll rewrite your prompt with your chosen
improvements
## Interactive Selection Process
When you use this skill, you'll see:
- **Original prompt** — Your starting point
- **Improvement suggestions** — Specific changes grouped by category
(Specificity, Context, Constraints)
- **Preview examples** — What each change would look like with the improvement
applied
- **Your choices** — You pick which suggestions help most (you can apply all,
some, or none)
Then you get a rewritten prompt combining all your choices.
### Example Interaction
**Original:** "Write me a blog post"
**Suggestions I might offer:**
- **Specificity**: Add a topic (e.g., "about sustainable living")
- **Context**: Explain your goal (e.g., "to build authority on my website")
- **Constraints**: Set a word count (e.g., "800-1000 words")
**Your choice:** "I want all three — add topic, goal, and word count"
**Final rewritten prompt:** "Write an 800-1000 word blog post about sustainable
living for my website. The goal is to establish my authority on eco-friendly
practices. Target an audience of people interested in reducing their carbon
footprint. Include 3-4 practical tips they can implement immediately, and end
with a call-to-action encouraging them to sign up for my newsletter."
## Tips for Best Results
- **Start simple** — Even small improvements help
- **Focus on your goal** — What outcome do you want?
- **Add one constraint at a time** — Too many rules can be confusing
- **Test and iterate** — Try the new prompt and see if results improve
## What Makes a Good Prompt
A prompt becomes "good" when:
- The AI understands exactly what you want ✓
- You've given enough context to explain why ✓
- You've set boundaries to prevent bad outputs ✓
- Someone else could read it and understand your intent ✓
+302
View File
@@ -0,0 +1,302 @@
---
name: ui-ux-testing
description:
Automated visual regression testing and UI/UX analysis for web applications.
Use this skill whenever developers mention "test this UI", "visual
regression", "test the UI", "check this interface", "UI testing", or want to
create automated tests for web pages. Analyzes URLs and generates
comprehensive test strategies including Playwright/Cypress test scripts,
manual testing checklists, visual regression detection, and detailed reports
with findings and recommendations.
compatibility:
tools: Claude in Chrome browser automation
frameworks: Playwright, Cypress, Selenium
---
# UI/UX Testing Skill
This skill helps developers create automated visual regression tests and
comprehensive UI/UX testing strategies for web applications.
## Overview
When a developer asks you to test a UI or create visual regression tests, this
skill guides you through:
1. **Analyzing the target URL** - Inspect the web page structure and components
2. **Generating test strategies** - Create both automated and manual testing
approaches
3. **Writing test code** - Generate Playwright/Cypress test scripts or Selenium
code
4. **Creating test checklists** - Manual testing steps for visual regression and
UX flows
5. **Generating reports** - Detailed findings, issues, and recommendations in
markdown/HTML
## When to Trigger
Trigger this skill when the user:
- Provides a URL and asks to "test this UI"
- Requests "visual regression testing" for a web page
- Wants to "check accessibility" or test a component
- Asks to "create automated tests" for a UI
- Wants a "testing strategy" or "test plan" for a web application
- Mentions QA, testing, or validation of UI components
## Workflow
### Step 1: Inspect the Target URL
Use Claude in Chrome to:
- Navigate to the provided URL
- Take screenshots of different viewport sizes (desktop, tablet, mobile)
- Inspect the DOM structure using `read_page` tool
- Identify key components, interactive elements, and critical flows
- Note responsive behavior and CSS properties
### Step 2: Create a Test Strategy
Based on your inspection, identify:
- **Visual elements** to regression test (buttons, forms, headers, layouts)
- **Interactive flows** to test (hover states, click handlers, form submission)
- **Responsive breakpoints** to validate (mobile, tablet, desktop)
- **Accessibility concerns** (ARIA labels, color contrast, keyboard navigation)
- **Critical user paths** to validate (common workflows)
### Step 3: Generate Test Code
**For Automated Testing (Choose one or more):**
#### Playwright (Recommended)
```javascript
// Example structure
import { expect, test } from "@playwright/test";
test("visual regression - homepage", async ({ page }) => {
await page.goto("https://example.com");
// Capture baseline screenshot
await expect(page).toHaveScreenshot("homepage.png");
// Test interactive elements
await page.hover("button.primary");
await expect(page).toHaveScreenshot("button-hover.png");
});
test("responsive layout - mobile", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("https://example.com");
await expect(page).toHaveScreenshot("mobile-layout.png");
});
```
#### Cypress
```javascript
describe("Visual Regression Tests", () => {
beforeEach(() => {
cy.visit("https://example.com");
});
it("captures baseline screenshot", () => {
cy.screenshot("homepage");
cy.get('[data-testid="header"]').should("be.visible");
});
it("tests button hover state", () => {
cy.get("button.primary").trigger("mouseenter");
cy.screenshot("button-hover-state");
});
});
```
#### Selenium
```python
from selenium import webdriver
from selenium.webdriver.common.by import By
from PIL import Image
driver = webdriver.Chrome()
driver.get('https://example.com')
# Capture screenshot
driver.save_screenshot('homepage.png')
# Test responsive
driver.set_window_size(375, 812)
driver.save_screenshot('mobile-view.png')
```
### Step 4: Create Manual Testing Checklist
Generate a checklist including:
- [ ] **Visual Consistency**
- [ ] All elements render correctly at 1920x1080
- [ ] All elements render correctly at 1366x768
- [ ] All elements render correctly at 768x1024 (tablet)
- [ ] All elements render correctly at 375x667 (mobile)
- [ ] Colors match design specifications
- [ ] Typography renders correctly (font families, sizes, weights)
- [ ] Images load and display at correct aspect ratios
- [ ] **Responsive Behavior**
- [ ] Layout adapts correctly on mobile (no horizontal scroll)
- [ ] Navigation collapses/expands appropriately
- [ ] Form inputs are touch-friendly (min 44x44px)
- [ ] Content reflows without overlapping
- [ ] **Interactive Elements**
- [ ] All buttons are clickable and have hover states
- [ ] Form inputs accept user input
- [ ] Dropdowns open/close correctly
- [ ] Links are underlined and properly colored
- [ ] **Accessibility**
- [ ] Keyboard navigation works (Tab key)
- [ ] Color contrast meets WCAG AA standards
- [ ] Images have alt text
- [ ] Form labels are associated with inputs
- [ ] **Critical User Paths**
- [ ] [Specific path 1]: [Steps and expected result]
- [ ] [Specific path 2]: [Steps and expected result]
### Step 5: Generate Test Report
Create an HTML/Markdown report with:
```markdown
# UI/UX Testing Report
## Executive Summary
- URL tested: [URL]
- Viewports tested: Desktop (1920x1080), Tablet (768x1024), Mobile (375x667)
- Testing date: [Date]
- Total issues found: [Count]
## Issues Found
### Critical (Breaks functionality)
1. **Issue Title**
- Severity: Critical
- Location: [Element/Component]
- Steps to reproduce: [Steps]
- Expected: [What should happen]
- Actual: [What actually happens]
- Screenshot: [If applicable]
### Major (Significant visual/UX impact)
1. **Issue Title**
- Severity: Major
- Location: [Element/Component]
- Impact: [User impact]
### Minor (Polish/optimization)
1. **Issue Title**
- Severity: Minor
- Location: [Element/Component]
- Recommendation: [Suggestion]
## Visual Regression Analysis
### Desktop (1920x1080)
- [List observations]
- [List changes from baseline if available]
### Tablet (768x1024)
- [List observations]
- [Responsive issues found]
### Mobile (375x667)
- [List observations]
- [Mobile-specific issues]
## Accessibility Assessment
| Element | Issue | WCAG Level | Recommendation |
| --------- | ------- | ---------- | -------------- |
| [Element] | [Issue] | [AA/AAA] | [Fix] |
## Recommendations
1. **High Priority**
- [Recommendation with rationale]
2. **Medium Priority**
- [Recommendation with rationale]
3. **Low Priority**
- [Recommendation with rationale]
## Test Coverage Summary
- Automated tests needed: [Count and types]
- Manual test cases: [Count]
- Estimated testing effort: [Time estimate]
- Regression risk: [High/Medium/Low]
---
Generated using UI/UX Testing Skill
```
## Output Options
Based on what the developer needs, generate:
1. **Test Code Only** - Playwright/Cypress/Selenium scripts ready to integrate
2. **Testing Strategy** - Comprehensive checklist and manual test plan
3. **Full Report** - Screenshots, findings, issues, and automated test code
4. **All of the above** - Complete testing package
## Best Practices
- **Multiple viewports:** Always test at least mobile (375px), tablet (768px),
and desktop (1920px)
- **Visual baselines:** Save baseline screenshots before making changes
- **Critical paths:** Prioritize testing main user workflows first
- **Accessibility first:** Include WCAG AA compliance checks
- **Clear assertions:** Make test assertions explicit and meaningful
- **Maintainability:** Use data attributes (data-testid) for reliable element
selection
## Example: Complete Testing Session
1. Developer provides URL: "https://myapp.com/dashboard"
2. You inspect the page (screenshots, DOM, responsive behavior)
3. You identify:
- Dashboard header with navigation
- Data table with sorting/filtering
- Form for creating items
- Mobile menu collapse
4. You generate:
- Playwright tests for visual regression
- Manual testing checklist
- HTML report with findings
5. Developer receives complete testing artifact ready to use
## Tips for Success
- Take screenshots at each viewport to catch responsive issues
- Test interactive states (hover, focus, active, disabled)
- Verify critical user journeys end-to-end
- Include accessibility testing automatically
- Provide both automated (code) and manual (checklist) approaches
- Make reports actionable with clear severity levels and recommendations
---
**Last Updated:** 2024 **Skill Version:** 1.0
@@ -0,0 +1,264 @@
---
name: website-creation-automation
description:
Automate end-to-end website creation with AI-powered optimization, design, and
testing. Trigger this skill whenever the user wants to create a new website,
build a landing page, design a web application, or automatically generate a
complete website from a description. This skill takes a user's website idea or
prompt, optimizes it for clarity, designs a modern responsive website, and
automatically tests it for quality issues. Use this skill for any "create a
website" or "build a web application" request, including landing pages,
portfolio sites, e-commerce pages, dashboards, or any web-based project.
compatibility:
models:
- claude-sonnet-4-20250514 (design and testing)
- claude-haiku-4.5-20251001 (bug fixes)
required_skills:
- prompt-optimizer
- frontend-atomic-design
- ui-ux-testing
---
# Website Creation Automation Skill
An intelligent, end-to-end automation workflow for creating complete, tested
websites from simple prompts.
## What This Skill Does
This skill orchestrates a complete website creation pipeline:
1. **Prompt Optimization** — Takes your website description and optimizes it
into a detailed, structured prompt
2. **Website Design** — Uses the optimized prompt to design and build a modern,
responsive website
3. **Automated Testing** — Tests the generated website for visual, interactive,
and responsive issues
4. **Auto-Fix** — Detects and fixes any issues found during testing
The entire workflow is automated, so you just provide a simple description of
what you want, and the skill handles the rest.
## Core Workflow
### Step 1: Optimize Your Website Prompt
Your initial description (e.g., "Create a portfolio website for a freelance
designer") is passed to the **prompt-optimizer** skill, which:
- Adds specific details about layout, features, and target audience
- Clarifies design preferences and functionality requirements
- Structures the request to guide high-quality website generation
- Returns a detailed, optimized prompt ready for design
**Example transformation:**
```
Input: "Create a portfolio website for a freelance designer"
Output: "Create a modern portfolio website for a freelance graphic designer.
Include: hero section with featured work, project showcase grid (6-8 projects),
about section, services list, client testimonials, contact form, and footer.
Target audience: potential clients and collaborators. Design should be minimalist
with emphasis on visual work. Mobile-responsive. Use modern sans-serif typography
and white space. Include smooth scroll animations."
```
### Step 2: Design the Website
The **frontend-atomic-design** skill uses the optimized prompt to:
- Break down the website into atomic components (atoms, molecules, organisms)
- Create a responsive layout that works on desktop, tablet, and mobile
- Apply modern design patterns using Tailwind CSS
- Build interactive elements and proper semantic HTML
- Generate a complete, production-ready HTML file (or React component)
**Outputs:**
- Full HTML file with embedded CSS and JavaScript
- All assets (icons, fonts) are self-contained
- Responsive design with mobile-first approach
- Accessible markup with semantic HTML5
### Step 3: Test the Website
The **ui-ux-testing** skill performs comprehensive testing:
- Visual regression testing (captures baseline screenshots)
- Responsive layout validation (mobile, tablet, desktop)
- Interactive element testing (buttons, forms, links)
- Accessibility checking (color contrast, keyboard navigation)
- Cross-browser compatibility assessment
- User flow validation
**Testing output includes:**
- Screenshots from multiple viewport sizes
- Detailed findings and issues detected
- Visual regression comparison
- Recommendations for improvements
### Step 4: Auto-Fix Detected Issues
Any issues detected in testing are automatically fixed:
- **Using Haiku 4.5** — A faster model optimized for targeted fixes
- **HTML-only fixes** — Modifications to structure, styling, or interactivity
- **Preserves design intent** — Fixes maintain the original design aesthetic
- **Re-validates** — Quick verification that fixes resolved the issues
**Common fixes include:**
- Correcting responsive behavior issues
- Fixing accessibility problems
- Adjusting spacing, alignment, or colors
- Improving interactive element behavior
- Ensuring all content is properly visible
---
## When to Use This Skill
Use this skill whenever you want to:
- **Create a new website** from scratch based on a description
- **Build a landing page** for a product, service, or event
- **Design a portfolio site** to showcase your work
- **Create an e-commerce page** with product displays
- **Build a dashboard or web app** UI
- **Generate a multi-page website** (returns first page, can iterate)
- **Prototype a website concept** quickly
## How to Trigger This Skill
Simply provide:
1. **Website description** — What kind of website you want (e.g., "e-commerce
store for handmade jewelry", "SaaS landing page", "restaurant menu website")
2. **Optional details** — Any specific requirements (colors, features, tone,
audience)
The skill handles everything else automatically.
## Example Usage
**User prompt:** "Create a landing page for a sustainable fashion startup called
EcoStitch. Include a hero section, features of our eco-friendly materials,
pricing plans, customer testimonials, and a newsletter signup."
**Skill processes:**
1. Optimizes prompt with specific design details and layout structure
2. Designs a modern, responsive landing page with all requested sections
3. Tests layout across mobile/tablet/desktop, forms, links, and visual design
4. Fixes any responsive or interactive issues found
5. Returns production-ready HTML file
**Final output:** A complete, tested, bug-free website ready to deploy or
customize further.
---
## Technical Details
### Model Usage
- **Sonnet 4.6** — Used for optimization, design, and testing (high-quality
complex tasks)
- **Haiku 4.5** — Used for bug fixes only (fast, targeted improvements)
### Skill Integration
This skill coordinates three core skills in sequence:
```
User Prompt
[prompt-optimizer] → Optimized Prompt
[frontend-atomic-design] → HTML/React Website
[ui-ux-testing] → Test Results + Screenshots
[Bug Fix Loop] → Fixed HTML/React Website
Final Website (Ready to Use)
```
### Output Format
The final website is delivered as:
- **HTML file** — Self-contained with CSS and JavaScript embedded
- **Screenshots** — Before/after testing comparison
- **Test report** — Issues found and fixes applied
- **Deployment ready** — Can be hosted on any static hosting service
---
## Limitations & Notes
- **Single-page output** — Generates one complete page (though can be expanded
to multi-page)
- **Static by default** — Returns HTML; can generate React components if needed
- **Database-free** — Forms are functional but don't store data without backend
integration
- **Rapid iteration** — If you want to modify the result, you can iterate by
running the skill again with updated requirements
---
## Tips for Best Results
1. **Be descriptive** — More detail in your initial prompt leads to better
results
2. **Specify audience** — Who is this website for? (target customers, users,
etc.)
3. **Include features** — What should the website do? (e.g., showcase products,
collect emails, etc.)
4. **Mention style** — Any aesthetic preferences? (minimalist, colorful,
corporate, playful, etc.)
5. **Test thoroughly** — Review the testing results to ensure the site meets
your needs
---
## Workflow Diagram
```
START
Input: Website Description
[Call: prompt-optimizer skill]
Receive: Optimized Detailed Prompt
[Call: frontend-atomic-design skill]
Receive: HTML/React Website Code
[Call: ui-ux-testing skill]
Receive: Test Results + Issues Found
Are there critical issues?
├─ YES → [Use Haiku 4.5 to fix] → Re-test
├─ NO → Proceed
COMPLETE: Return Website + Test Report
```
---
## Future Enhancements
Potential expansions to this skill:
- Multi-page website generation (homepage, about, services, contact, etc.)
- CMS integration (connect to content management systems)
- Backend API scaffolding (Node.js/Express templates)
- SEO optimization (meta tags, structured data, open graph)
- Analytics integration (Google Analytics, Mixpanel)
- E-commerce integration (payment processing, inventory)
+2 -2
View File
@@ -15,10 +15,10 @@
"vscode": { "vscode": {
"extensions": ["esbenp.prettier-vscode"] "extensions": ["esbenp.prettier-vscode"]
} }
} },
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [], "forwardPorts": [3000]
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "uname -a", // "postCreateCommand": "uname -a",
+25 -6
View File
@@ -22,9 +22,16 @@ jobs:
id-token: write id-token: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: gerlero/apt-install@v1 - name: Install dependencies with Proxy
with: env:
packages: docker.io zip http_proxy: "http://host.docker.internal:3142"
https_proxy: "http://host.docker.internal:3142"
run: |
# Kiểm tra proxy có hoạt động không
echo "Acquire::http::Proxy \"$http_proxy\";" > /etc/apt/apt.conf.d/80proxy
apt-get update
apt-get install -y --no-install-recommends --no-install-suggests \
docker-cli zip docker-buildx
- name: Setup SSH Key - name: Setup SSH Key
run: | run: |
@@ -43,7 +50,19 @@ jobs:
with: with:
version: latest version: latest
run_install: false run_install: false
cache: true
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Update dependencies - name: Update dependencies
run: pnpm update run: pnpm update
@@ -95,8 +114,8 @@ jobs:
context: . context: .
push: true push: true
tags: | tags: |
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:latest
git.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }} vps.demonkernel.io.vn/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }}
- name: Commit & Push Changes - name: Commit & Push Changes
run: | run: |
-762
View File
@@ -1,762 +0,0 @@
# ATOMIC DESIGN STRUCTURE GUIDE
## Overview
Project sử dụng **Atomic Design Pattern** để tổ chức UI components theo 5 cấp
độ:
1. **Atoms** - Khối xây dựng cơ bản, không thể chia nhỏ hơn
2. **Molecules** - Nhóm atoms đơn giản hoạt động cùng nhau
3. **Organisms** - Khu vực UI phức tạp, riêng biệt
4. **Templates** - Bố cục cấp trang, cấu trúc nội dung
5. **Pages** - Các phiên bản cụ thể với dữ liệu thật
---
## 1) ATOMS (`components/atoms/`)
**Mục đích:** Khối xây dựng cơ bản, tái sử dụng cao, không phụ thuộc logic phức
tạp.
Không có context/hooks logic phức tạp, chỉ nhận props từ parent.
### Cấu trúc thư mục
```
components/atoms/
├── buttons/
│ ├── Button.tsx # Nút cơ bản (primary, secondary, danger)
│ ├── IconButton.tsx # Nút chỉ có icon
│ └── Button.types.ts # Props types
├── inputs/
│ ├── TextInput.tsx # Text input cơ bản
│ ├── NumberInput.tsx # Number input với up/down
│ ├── Checkbox.tsx # Checkbox
│ └── Input.types.ts # Props types
├── badges/
│ ├── Badge.tsx # Badge cơ bản (color variants)
│ ├── PriceBadge.tsx # Badge hiển thị giá
│ └── Badge.types.ts # Props types
├── icons/
│ ├── StarIcon.tsx # Rating star icon
│ ├── CartIcon.tsx # Shopping cart icon
│ ├── SearchIcon.tsx # Search icon
│ └── icons.types.ts # Props types
├── typography/
│ ├── Heading.tsx # h1-h6 headings
│ ├── Text.tsx # Body text variants
│ ├── Caption.tsx # Small caption text
│ └── Typography.types.ts # Props types
├── dividers/
│ ├── Divider.tsx # Horizontal divider
│ └── Divider.types.ts # Props types
├── loaders/
│ ├── Spinner.tsx # Loading spinner
│ ├── Skeleton.tsx # Skeleton loader
│ └── Loader.types.ts # Props types
└── index.ts # Barrel export
```
### Ví dụ Atoms
**Button.tsx:**
```tsx
import { ButtonHTMLAttributes } from "react";
import type { ButtonProps } from "./Button.types";
export default function Button({
variant = "primary",
size = "md",
disabled = false,
children,
className = "",
...props
}: ButtonProps) {
const baseStyles =
"font-semibold rounded-lg transition-colors disabled:opacity-50";
const variants = {
primary:
"bg-[color:var(--color-primary)] text-white hover:bg-[color:var(--color-primary-dark)]",
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
danger: "bg-red-500 text-white hover:bg-red-600",
};
const sizes = {
sm: "px-3 py-1 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
```
**Button.types.ts:**
```tsx
import { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
disabled?: boolean;
children: React.ReactNode;
className?: string;
}
```
---
## 2) MOLECULES (`components/molecules/`)
**Mục đích:** Nhóm atoms tạo thành những UI unit nhỏ, tái sử dụng, có logic đơn
giản.
Có thể sử dụng `useState`, nhưng logic chủ yếu nằm ở parent component.
### Cấu trúc thư mục
```
components/molecules/
├── form-groups/
│ ├── FormField.tsx # Input + label + error message
│ ├── FormGroup.tsx # Label + input wrapper
│ └── FormGroup.types.ts # Props types
├── cards/
│ ├── ProductCard.tsx # Card hiển thị sản phẩm (image + name + price + btn)
│ ├── ShopCard.tsx # Card hiển thị quán
│ ├── ReviewCard.tsx # Card hiển thị review
│ └── Card.types.ts # Props types
├── ratings/
│ ├── RatingStars.tsx # Hiển thị 5 sao rating
│ ├── RatingInput.tsx # Input 5 sao (interactive)
│ └── Rating.types.ts # Props types
├── price-display/
│ ├── PriceTag.tsx # Hiển thị giá formatted
│ ├── PriceRange.tsx # Hiển thị range giá
│ └── Price.types.ts # Props types
├── search-bar/
│ ├── SearchInput.tsx # Search input với icon
│ ├── SearchBar.tsx # Search bar wrapper
│ └── Search.types.ts # Props types
├── breadcrumb/
│ ├── Breadcrumb.tsx # Breadcrumb navigation
│ └── Breadcrumb.types.ts # Props types
├── tabs/
│ ├── TabGroup.tsx # Tabs wrapper
│ ├── Tab.tsx # Individual tab
│ └── Tabs.types.ts # Props types
└── index.ts # Barrel export
```
### Ví dụ Molecules
**ProductCard.tsx:**
```tsx
import Button from "@/components/atoms/buttons/Button";
import Text from "@/components/atoms/typography/Text";
import Image from "next/image";
import type { ProductCardProps } from "./Card.types";
export default function ProductCard({
product,
onAddToCart,
}: ProductCardProps) {
return (
<div className="rounded-lg border border-[color:var(--color-border)] bg-[color:var(--color-bg-card)] p-4 shadow-sm transition-shadow hover:shadow-md">
<div className="relative mb-3 h-48 w-full overflow-hidden rounded-md">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover"
/>
</div>
<Text variant="body1" className="font-semibold">
{product.name}
</Text>
<Text
variant="caption"
className="text-[color:var(--color-text-secondary)]"
>
{product.description}
</Text>
<div className="mt-3 flex items-center justify-between">
<Text
variant="body2"
className="font-bold text-[color:var(--color-primary)]"
>
${product.price.toFixed(2)}
</Text>
<Button size="sm" onClick={() => onAddToCart(product)}>
Add
</Button>
</div>
</div>
);
}
```
---
## 3) ORGANISMS (`components/organisms/`)
**Mục đích:** Khu vực UI phức tạp, độc lập, có logic riêng.
Kết hợp multiple molecules/atoms, có thể sử dụng contexts (useAuth, useCart,
etc.), state phức tạp.
### Cấu trúc thư mục
```
components/organisms/
├── navigation/
│ ├── Navbar.tsx # Sidebar category filter (cũ CartProduct)
│ ├── CategoryMenu.tsx # Category menu wrapper
│ └── Navigation.types.ts # Props types
├── cart/
│ ├── CartFab.tsx # Floating action button giỏ hàng
│ ├── CartSummary.tsx # Cart summary widget
│ ├── CartList.tsx # Danh sách sản phẩm trong giỏ
│ └── Cart.types.ts # Props types
├── product-grid/
│ ├── ProductGrid.tsx # Grid hiển thị danh sách sản phẩm
│ ├── ProductFilters.tsx # Bộ lọc sản phẩm (category, price, rating)
│ └── ProductGrid.types.ts # Props types
├── forms/
│ ├── LoginForm.tsx # Form đăng nhập (username + password + submit)
│ ├── RegisterForm.tsx # Form đăng ký
│ ├── CheckoutForm.tsx # Form thanh toán
│ ├── ReviewForm.tsx # Form đánh giá (modal content)
│ └── Forms.types.ts # Props types
├── modals/
│ ├── ReviewModal.tsx # Modal đánh giá (header + form + footer)
│ ├── ConfirmModal.tsx # Modal xác nhận generic
│ └── Modal.types.ts # Props types
├── shop-grid/
│ ├── ShopGrid.tsx # Grid hiển thị danh sách quán
│ ├── ShopFilters.tsx # Bộ lọc quán (location, rating)
│ └── ShopGrid.types.ts # Props types
├── hero-section/
│ ├── HeroSection.tsx # Banner hero cấp trang
│ └── Hero.types.ts # Props types
├── featured-section/
│ ├── FeaturedProducts.tsx # Section sản phẩm nổi bật
│ ├── FeaturedShops.tsx # Section quán nổi bật
│ └── Featured.types.ts # Props types
└── index.ts # Barrel export
```
### Ví dụ Organisms
**ProductGrid.tsx:**
```tsx
"use client";
import ProductCard from "@/components/molecules/cards/ProductCard";
import { useCart } from "@/lib/cart-context";
import { MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import type { ProductGridProps } from "./ProductGrid.types";
export default function ProductGrid({ searchQuery = "" }: ProductGridProps) {
const { activeCategory } = useMenu();
const { addToCart } = useCart();
const filtered = MOCK_PRODUCTS.filter((product) => {
const matchCategory =
activeCategory === "all" || product.category === activeCategory;
const matchSearch =
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchCategory && matchSearch;
});
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{filtered.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={addToCart}
/>
))}
</div>
);
}
```
---
## 4) TEMPLATES (`components/templates/`)
**Mục đích:** Bố cục cấp trang, cấu trúc nội dung, không có data cụ thể.
Chứa layout và structure của page, nhưng data được truyền từ page component.
### Cấu trúc thư mục
```
components/templates/
├── main-layout/
│ ├── MainLayout.tsx # Layout chính (header + sidebar + content + footer)
│ ├── MainLayout.types.ts # Props types
│ └── styles.ts # Responsive grid layout logic
├── feed-layout/
│ ├── FeedLayout.tsx # Layout feed (khám phá quán)
│ └── FeedLayout.types.ts # Props types
├── manager-layout/
│ ├── ManagerLayout.tsx # Layout manager dashboard
│ └── ManagerLayout.types.ts # Props types
├── checkout-layout/
│ ├── CheckoutLayout.tsx # Layout thanh toán (steps, cart, form)
│ └── CheckoutLayout.types.ts # Props types
├── auth-layout/
│ ├── AuthLayout.tsx # Layout auth (login/register)
│ └── AuthLayout.types.ts # Props types
└── index.ts # Barrel export
```
### Ví dụ Templates
**MainLayout.tsx:**
```tsx
import Navbar from "@/components/organisms/navigation/Navbar";
import Footer from "@/layouts/footer";
import Header from "@/layouts/header";
import type { MainLayoutProps } from "./MainLayout.types";
export default function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1">
{/* Sidebar - ẩn trên mobile */}
<nav className="hidden w-64 border-r border-[color:var(--color-border)] bg-[color:var(--color-bg-sidebar)] md:block">
<Navbar />
</nav>
{/* Main content */}
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
<Footer />
</div>
);
}
```
---
## 5) PAGES (`app/*/page.tsx`)
**Mục đích:** Các phiên bản cụ thể với dữ liệu thật, logic cấp trang.
Server/client components sử dụng templates, organisms, nhận data từ API/context.
### Ví dụ Pages
**app/(main)/page.tsx:**
```tsx
"use client";
import FeaturedSection from "@/components/organisms/featured-section/FeaturedSection";
import ProductGrid from "@/components/organisms/product-grid/ProductGrid";
import MainLayout from "@/components/templates/main-layout/MainLayout";
import { useState } from "react";
export default function MainPage() {
const [searchQuery, setSearchQuery] = useState("");
return (
<MainLayout>
<FeaturedSection />
<div className="mt-8">
<ProductGrid searchQuery={searchQuery} />
</div>
</MainLayout>
);
}
```
---
## 6) File Hierarchy Summary
```
components/
├── atoms/
│ ├── buttons/
│ ├── inputs/
│ ├── badges/
│ ├── icons/
│ ├── typography/
│ ├── dividers/
│ ├── loaders/
│ └── index.ts
├── molecules/
│ ├── form-groups/
│ ├── cards/
│ ├── ratings/
│ ├── price-display/
│ ├── search-bar/
│ ├── breadcrumb/
│ ├── tabs/
│ └── index.ts
├── organisms/
│ ├── navigation/
│ ├── cart/
│ ├── product-grid/
│ ├── forms/
│ ├── modals/
│ ├── shop-grid/
│ ├── hero-section/
│ ├── featured-section/
│ └── index.ts
├── templates/
│ ├── main-layout/
│ ├── feed-layout/
│ ├── manager-layout/
│ ├── checkout-layout/
│ ├── auth-layout/
│ └── index.ts
└── ATOMIC_DESIGN.md (this file)
```
---
## 7) Migration Guide (Old → New)
### Old Structure → New Structure Mapping
| Old File | New Location | Type |
| ----------------- | ---------------------------------- | -------- |
| `CartProduct.tsx` | `molecules/cards/ProductCard.tsx` | Molecule |
| `Navbar.tsx` | `organisms/navigation/Navbar.tsx` | Organism |
| `CartFab.tsx` | `organisms/cart/CartFab.tsx` | Organism |
| `ReviewModal.tsx` | `organisms/modals/ReviewModal.tsx` | Organism |
### Migration Steps
1. Create new directory structure under `components/`
2. Move existing components to appropriate levels (atoms → molecules →
organisms)
3. Extract shared styles/logic into atoms
4. Update imports in `app/` pages
5. Test responsiveness at each breakpoint
6. Update `COMPONENTS.md` with new structure
---
## 8) Best Practices
### Atoms Development
- ✅ Reusable across entire project
- ✅ No business logic
- ✅ No context/hooks (useAuth, useCart)
- ✅ Pure props-based
- ✅ Full TypeScript typing
- ❌ No "use client" needed (unless interactive, e.g., Button)
### Molecules Development
- ✅ Combines multiple atoms
- ✅ Simple state (open/close, hover state)
- ✅ No complex business logic
- ✅ Can use useState for UI state
- ✅ Reusable in multiple contexts
- ❌ No global state (useAuth, useCart)
### Organisms Development
- ✅ Complex UI sections
- ✅ Can use contexts (useAuth, useCart, useMenu)
- ✅ Business logic
- ✅ Always "use client"
- ✅ Filter, sort, complex interactions
- ❌ Not reusable across different page types
### Templates Development
- ✅ Page layout structure
- ✅ Composition of organisms + layout
- ✅ No data fetching/business logic
- ✅ Children prop pattern
- ✅ Props for customization
- ❌ No hardcoded data
### Pages Development
- ✅ Specific page implementations
- ✅ Route-specific logic
- ✅ Data integration
- ✅ Context usage at page level
- ✅ State management orchestration
- ❌ No UI component definitions (use organisms)
---
## 9) Import Patterns
### Atoms
```tsx
import Button from "@/components/atoms/buttons/Button";
import Text from "@/components/atoms/typography/Text";
```
### Molecules
```tsx
import ProductCard from "@/components/molecules/cards/ProductCard";
import FormField from "@/components/molecules/form-groups/FormField";
```
### Organisms
```tsx
import LoginForm from "@/components/organisms/forms/LoginForm";
import ProductGrid from "@/components/organisms/product-grid/ProductGrid";
```
### Templates
```tsx
import MainLayout from "@/components/templates/main-layout/MainLayout";
```
### Barrel Exports
```tsx
// Usage
import { Button, Text } from "@/components/atoms";
// components/atoms/index.ts
export { default as Button } from "./buttons/Button";
export { default as Text } from "./typography/Text";
export * from "./buttons/Button.types";
```
---
## 10) Common Patterns
### Creating a New Atom
```tsx
// atoms/buttons/NewButton.tsx
export default function NewButton({ variant, ...props }: Props) {
return <button className={styles[variant]} {...props} />;
}
// atoms/buttons/NewButton.types.ts
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant: "type1" | "type2";
}
```
### Creating a New Molecule
```tsx
// molecules/cards/NewCard.tsx
export default function NewCard({ item, onAction }: Props) {
const [hover, setHover] = useState(false);
return (
<div onMouseEnter={() => setHover(true)}>{/* atoms composition */}</div>
);
}
```
### Creating a New Organism
```tsx
// organisms/sections/NewSection.tsx
"use client";
import ProductCard from "@/components/molecules/cards/ProductCard";
import { useAuth } from "@/lib/auth-context";
export default function NewSection() {
const { user } = useAuth();
// business logic, filtering, etc.
return <section>{/* molecules composition + logic */}</section>;
}
```
### Creating a New Template
```tsx
// templates/layouts/NewTemplate.tsx
export default function NewTemplate({ children, header }: Props) {
return (
<div className="layout">
<header>{header}</header>
<main>{children}</main>
</div>
);
}
```
---
## 11) Testing & Documentation
### For Each Component Level
#### Atoms
- Unit test: Props validation, styling
- Storybook: All variants, all states
- Doc: Props interface, usage examples
#### Molecules
- Integration test: Atoms composition
- Storybook: Different molecule states
- Doc: Props, behavior, dependencies
#### Organisms
- Integration test: With contexts mocked
- E2E: User interactions
- Doc: Logic flow, API integration points
#### Templates
- Layout test: Responsive grid layouts
- Visual: Desktop/tablet/mobile
- Doc: Layout structure, breakpoints
#### Pages
- E2E test: Full user flows
- Performance: Metrics
- Doc: Route, data flow, features
---
## 12) Performance Optimization
### Code Splitting
- Atoms: Always bundled (small, frequently used)
- Molecules: Bundled by page/feature
- Organisms: Use `dynamic()` for heavy sections
- Templates: Bundled by layout type
- Pages: Automatic splitting by Next.js
### Lazy Loading Example
```tsx
import dynamic from "next/dynamic";
const ReviewModal = dynamic(
() => import("@/components/organisms/modals/ReviewModal"),
{ loading: () => <Spinner /> },
);
```
### Image Optimization
- Use Next.js `Image` component (atoms/molecules)
- Optimize with `priority` for above-fold
- Use responsive sizes: `sizes="(max-width: 640px) 100vw, 50vw"`
---
## 13) Accessibility
### All Levels
- Semantic HTML: `<button>`, `<a>`, `<form>`, `<nav>`
- ARIA attributes: `aria-label`, `aria-expanded`, `role`
- Keyboard navigation: Tab order, focus visible
- Color contrast: WCAG AA minimum
- Alt text: All images have meaningful `alt`
### Example
```tsx
<button
aria-label="Add to cart"
className="focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<CartIcon aria-hidden="true" />
Add to Cart
</button>
```
---
## 14) Version Control & Documentation
### File Format
```
components/
├── atoms/
│ ├── buttons/
│ │ ├── Button.tsx
│ │ ├── Button.types.ts
│ │ └── Button.md # Component documentation
│ └── ...
├── molecules/
│ ├── cards/
│ │ ├── ProductCard.tsx
│ │ ├── Card.types.ts
│ │ └── ProductCard.md
│ └── ...
└── ...
```
### Documentation Template
```markdown
# ProductCard
## Purpose
Display individual product with image, name, price, and action button.
## Props
- `product: Product` - Product data
- `onAddToCart: (product: Product) => void` - Add to cart handler
## Usage
\`\`\`tsx <ProductCard product={item} onAddToCart={addToCart} /> \`\`\`
## Variants
- Image with loading state
- With discount badge
- With rating stars
## Responsive
- Mobile: Single column, full width
- Desktop: Grid layout
```
---
Cấu trúc này cung cấp: ✅ **Scalability**: Dễ thêm components mới ✅
**Reusability**: Tối đa tái sử dụng ✅ **Maintainability**: Code dễ hiểu, tìm
kiếm ✅ **Testability**: Mỗi level có logic riêng ✅ **Performance**: Smart
code-splitting
+29 -23
View File
@@ -67,13 +67,15 @@ Dành cho khách hàng:
Dành cho quản lý: Dành cho quản lý:
- Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới Analytics - Sidebar desktop: Brand, tab navigation (Thực đơn / Combo / Danh mục), link tới
Analytics
- CRUD sản phẩm, combo, danh mục qua modals - CRUD sản phẩm, combo, danh mục qua modals
- Auth guard: tự động redirect non-manager về `/` - Auth guard: tự động redirect non-manager về `/`
#### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`) #### 9. **Trang Phân Tích Tài Chính** (`app/(manager)/manager/analytics`)
- Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh kỳ trước) - Summary cards: Doanh thu, đơn hàng, lợi nhuận, giá trị đơn trung bình (so sánh
kỳ trước)
- Bộ chọn kỳ: Ngày / Tuần / Tháng / Năm - Bộ chọn kỳ: Ngày / Tuần / Tháng / Năm
- Biểu đồ SVG thuần: Line, Bar, Pie — hover tooltips tương tác - Biểu đồ SVG thuần: Line, Bar, Pie — hover tooltips tương tác
- Bảng top 5 sản phẩm và bảng chi tiết có thể sắp xếp - Bảng top 5 sản phẩm và bảng chi tiết có thể sắp xếp
@@ -196,18 +198,18 @@ frondend/
## Công Nghệ Sử Dụng ## Công Nghệ Sử Dụng
| Công nghệ | Phiên bản | Mục đích | | Công nghệ | Phiên bản | Mục đích |
| ------------ | --------- | ------------------------------------- | | ---------------- | --------- | ------------------------------------- |
| Next.js | 16.1.7 | React Framework (App Router) | | Next.js | 16.1.7 | React Framework (App Router) |
| React | 19.2.3 | Thư viện UI | | React | 19.2.3 | Thư viện UI |
| TypeScript | ^5 | Kiểu dữ liệu tĩnh | | TypeScript | ^5 | Kiểu dữ liệu tĩnh |
| Tailwind CSS | ^4.2.2 | Utility-first CSS framework | | Tailwind CSS | ^4.2.2 | Utility-first CSS framework |
| Geist Font | - | Font chữ (Google Fonts via next/font) | | Geist Font | - | Font chữ (Google Fonts via next/font) |
| FontAwesome | 6.7.2 | Icon library (CDN) | | FontAwesome | 6.7.2 | Icon library (CDN) |
| pnpm | - | Package manager | | pnpm | - | Package manager |
| ESLint | ^9 | Linting | | ESLint | ^9 | Linting |
| Prettier | ^3 | Code formatting | | Prettier | ^3 | Code formatting |
| semantic-release | ^25 | Automated versioning & release | | semantic-release | ^25 | Automated versioning & release |
--- ---
@@ -225,11 +227,11 @@ frondend/
### Tài Khoản Demo ### Tài Khoản Demo
| Loại tài khoản | Tên đăng nhập | Mật khẩu | | Loại tài khoản | Tên đăng nhập | Mật khẩu |
| -------------- | -------------- | -------------- | | -------------- | ------------- | ------------- |
| Manager | admin | admin | | Manager | admin | admin |
| Staff | Nguyễn Văn An | Nguyễn Văn An | | Staff | Nguyễn Văn An | Nguyễn Văn An |
| Customer | 0987654321 | user1 | | Customer | 0987654321 | user1 |
### Design & Styling ### Design & Styling
@@ -243,14 +245,18 @@ frondend/
Dự án tuân theo **Atomic Design** pattern — chi tiết tại `Atomic.md`: Dự án tuân theo **Atomic Design** pattern — chi tiết tại `Atomic.md`:
- **Atoms** - Nguyên tố cơ bản: Button, Input, Badge, Text, Heading, Divider - **Atoms** - Nguyên tố cơ bản: Button, Input, Badge, Text, Heading, Divider
- **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar, PaymentSummaryCard - **Molecules** - Nhóm atoms: ProductCard, ShopCard, SearchBar,
- **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid, ShopGrid, ReviewModal, analytics charts, manager tabs/modals PaymentSummaryCard
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout, ManagerLayout - **Organisms** - Phần UI phức tạp: CategorySidebar, CartFab, ProductGrid,
ShopGrid, ReviewModal, analytics charts, manager tabs/modals
- **Templates** - Bố cục trang: MainLayout, AuthLayout, FeedLayout,
ManagerLayout
### Data & Integration ### Data & Integration
- Mock data nằm trong `lib/constants.ts` - Mock data nằm trong `lib/constants.ts`
- Context providers trong `app/providers.tsx`: AuthProvider, MenuProvider, CartProvider - Context providers trong `app/providers.tsx`: AuthProvider, MenuProvider,
CartProvider
- ManagerProvider được thêm bởi `app/(manager)/layout.tsx` - ManagerProvider được thêm bởi `app/(manager)/layout.tsx`
- Thay bằng API calls khi backend sẵn sàng - Thay bằng API calls khi backend sẵn sàng
- Ảnh sản phẩm: thêm vào `public/imgs/products/` - Ảnh sản phẩm: thêm vào `public/imgs/products/`
+7 -7
View File
@@ -16,10 +16,10 @@ export default function FeedPage() {
{/* Page title */} {/* Page title */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-foreground text-2xl font-bold md:text-3xl"> <h1 className="text-foreground text-2xl font-bold md:text-3xl">
Khám phá quán nước Explore Shops
</h1> </h1>
<p className="mt-1 text-sm text-(--color-text-muted)"> <p className="mt-1 text-sm text-(--color-text-muted)">
Tìm chọn quán yêu thích của bạn Find and choose your favorite shop
</p> </p>
</div> </div>
@@ -35,7 +35,7 @@ export default function FeedPage() {
}} }}
className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline" className="cursor-pointer border-none bg-transparent text-sm text-(--color-primary) hover:underline"
> >
Xóa bộ lọc Clear filters
</button> </button>
</div> </div>
)} )}
@@ -46,7 +46,7 @@ export default function FeedPage() {
<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">
<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>Filter shops</span>
</div> </div>
{/* Name search */} {/* Name search */}
@@ -54,7 +54,7 @@ export default function FeedPage() {
value={searchName} value={searchName}
onChange={setSearchName} onChange={setSearchName}
onClear={() => setSearchName("")} onClear={() => setSearchName("")}
placeholder="Tìm theo tên quán..." placeholder="Search by shop name..."
className="min-w-0 flex-1" className="min-w-0 flex-1"
/> />
@@ -65,13 +65,13 @@ export default function FeedPage() {
type="text" type="text"
value={searchAddress} value={searchAddress}
onChange={(e) => setSearchAddress(e.target.value)} onChange={(e) => setSearchAddress(e.target.value)}
placeholder="Tìm theo địa chỉ..." placeholder="Search by address..."
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="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)"
/> />
{searchAddress && ( {searchAddress && (
<button <button
onClick={() => setSearchAddress("")} onClick={() => setSearchAddress("")}
aria-label="Xóa tìm kiếm địa ch" aria-label="Clear address search"
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)" 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> <i className="fa-solid fa-xmark text-sm"></i>
+173
View File
@@ -0,0 +1,173 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
export default function LoginOtpPage() {
const router = useRouter();
const { setUser } = useAuth();
const [phone, setPhone] = useState("");
const [role, setRole] = useState("");
const [otp, setOtp] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ otp: "", general: "" });
useEffect(() => {
const storedPhone = sessionStorage.getItem("login_phone");
const storedRole = sessionStorage.getItem("login_role");
if (!storedPhone || !storedRole) {
router.replace("/login");
return;
}
setPhone(storedPhone);
setRole(storedRole);
}, [router]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!otp.trim()) {
setErrors({ otp: "Please enter your OTP code", general: "" });
return;
}
setIsLoading(true);
setErrors({ otp: "", general: "" });
try {
const endpoint =
role === "manager"
? "/api/manager/quick_login"
: "/api/customer/quick_login";
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, otp }),
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone");
sessionStorage.removeItem("login_role");
router.push(role === "manager" ? "/manager" : "/");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const msg =
errorCode === "InvalidOTP"
? "Incorrect or expired OTP code"
: "An error occurred, please try again";
setErrors({ otp: msg, general: "" });
}
} catch {
setErrors({ otp: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
if (!phone) return null;
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo & Shop Name */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Verify your phone number
</p>
</div>
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Info */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="text-sm text-blue-800">
<i className="fa-solid fa-circle-info mr-2"></i>
OTP code sent to <strong>{phone}</strong>
</p>
</div>
{/* OTP Input */}
<div>
<label
htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
OTP code
</label>
<div className="relative">
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="otp"
type="text"
value={otp}
onChange={(e) => {
setOtp(e.target.value);
setErrors({ otp: "", general: "" });
}}
placeholder="Enter OTP code"
maxLength={6}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors.otp && (
<ErrorMessageLogin message={errors.otp} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Sign in"
)}
</Button>
<button
type="button"
onClick={() => router.push("/login")}
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
+3 -30
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { SHOP_INFO } from "@/lib/constants";
import LoginForm from "@/components/organisms/forms/LoginForm"; import LoginForm from "@/components/organisms/forms/LoginForm";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image"; import Image from "next/image";
export default function LoginPage() { export default function LoginPage() {
@@ -25,39 +25,12 @@ export default function LoginPage() {
{SHOP_INFO.name} {SHOP_INFO.name}
</h1> </h1>
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm text-(--color-text-muted)">
Đăng nhập vào hệ thống Enter your phone number to sign in
</p> </p>
</div> </div>
{/* Login Form */} {/* Login Form */}
<LoginForm /> <LoginForm />
{/* Demo Credentials Info */}
<div className="bg-background mt-6 rounded-lg p-4">
<p className="mb-2 text-xs font-semibold text-(--color-text-muted)">
Tài khoản demo:
</p>
<ul className="space-y-1 text-xs text-(--color-text-muted)">
<li>
Quản :{" "}
<code className="rounded bg-white px-1.5 py-0.5">
admin / admin
</code>
</li>
<li>
Nhân viên:{" "}
<code className="rounded bg-white px-1.5 py-0.5">
Nguyễn Văn An / Nguyễn Văn An
</code>
</li>
<li>
Khách hàng:{" "}
<code className="rounded bg-white px-1.5 py-0.5">
0987654321 / user1
</code>
</li>
</ul>
</div>
</div> </div>
</div> </div>
); );
+176
View File
@@ -0,0 +1,176 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import { useAuth } from "@/lib/auth-context";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
export default function LoginPasswordPage() {
const router = useRouter();
const { setUser } = useAuth();
const [phone, setPhone] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ password: "", general: "" });
useEffect(() => {
const storedPhone = sessionStorage.getItem("login_phone");
const storedRole = sessionStorage.getItem("login_role");
if (!storedPhone || storedRole !== "manager") {
router.replace("/login");
return;
}
setPhone(storedPhone);
}, [router]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!password.trim()) {
setErrors({ password: "Please enter your password", general: "" });
return;
}
setIsLoading(true);
setErrors({ password: "", general: "" });
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, password, role: "manager" }),
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
sessionStorage.removeItem("login_phone");
sessionStorage.removeItem("login_role");
router.push("/manager");
} else {
const STATUS_ERROR_MAP: Record<number, string> = {
400: "Incorrect login details, please try again",
401: "Incorrect password",
403: "Account is locked",
404: "Account does not exist",
};
const msg =
STATUS_ERROR_MAP[res.status] ??
(res.status >= 500
? "Server error, please try again later"
: "An error occurred, please try again");
setErrors({ password: "", general: msg });
}
} catch {
setErrors({ password: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
if (!phone) return null;
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo & Shop Name */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">Manager sign in</p>
</div>
{errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Phone display */}
<div className="rounded-lg border border-(--color-border) bg-(--color-background) px-4 py-3 text-sm text-(--color-text-secondary)">
<i className="fa-solid fa-phone mr-2 text-(--color-text-muted)"></i>
{phone}
</div>
{/* Password Input */}
<div>
<label
htmlFor="password"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
Password
</label>
<div className="relative">
<i className="fa-solid fa-lock absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({ password: "", general: "" });
}}
placeholder="Enter password"
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.password ? "border-red-400" : "border-(--color-border)"}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Hide password" : "Show password"}
>
<i className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}></i>
</button>
</div>
{errors.password && (
<ErrorMessageLogin message={errors.password} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2">
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Sign in"
)}
</Button>
<button
type="button"
onClick={() => router.push("/login")}
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
>
Change phone number
</button>
</div>
</form>
</div>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
type PageState = "checking" | "available" | "closed" | "error";
export default function ManagerSignupPage() {
const router = useRouter();
const [pageState, setPageState] = useState<PageState>("checking");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
useEffect(() => {
fetch("/api/manager/signup")
.then((res) => {
setPageState("available")
// if (res.ok) ;
// else if (res.status === 403) setPageState("closed");
// else setPageState("error");
})
.catch(() => setPageState("error"));
}, []);
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = { name: "", phone: "", password: "", eateryName: "", submit: "" };
if (!form.name.trim()) next.name = "Please enter your full name";
if (!form.phone.trim()) next.phone = "Please enter your phone number";
else if (!validatePhone(form.phone)) next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter your password";
else if (form.password.length < 6) next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim()) next.eateryName = "Please enter the restaurant name";
setErrors(next);
return !next.name && !next.phone && !next.password && !next.eateryName;
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
try {
const res = await fetch("/api/manager/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok || res.status === 201) {
router.push("/login");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const errorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-lg">
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image src={SHOP_INFO.logo} alt={SHOP_INFO.name} fill className="object-contain" sizes="80px" priority />
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">{SHOP_INFO.name}</h1>
<p className="text-sm text-(--color-text-muted)">Create a manager account</p>
</div>
{/* Checking */}
{pageState === "checking" && (
<div className="flex flex-col items-center gap-3 py-8 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-2xl text-(--color-primary)"></i>
<p className="text-sm">Checking...</p>
</div>
)}
{/* Closed */}
{pageState === "closed" && (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Registration closed</h2>
<p className="text-sm text-(--color-text-muted)">The system already has a restaurant. Registration is no longer available.</p>
</div>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Quay lại đăng nhập
</Link>
</div>
)}
{/* Error */}
{pageState === "error" && (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100">
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Không thể kết nối</h2>
<p className="text-sm text-(--color-text-muted)">Không thể kiểm tra trạng thái đăng . Vui lòng thử lại.</p>
</div>
<Button variant="primaryNoBorder" style="login" size="lg" onClick={() => { setPageState("checking"); fetch("/api/manager/signup").then((r) => { if (r.ok) setPageState("available"); else if (r.status === 403) setPageState("closed"); else setPageState("error"); }).catch(() => setPageState("error")); }}>
Thử lại
</Button>
<Link href="/login" className="text-sm text-(--color-primary) underline">Quay lại đăng nhập</Link>
</div>
)}
{/* Signup Form */}
{pageState === "available" && (
<form onSubmit={handleSubmit} className="space-y-4">
{[
{ id: "name", label: "Họ tên", icon: "fa-user", placeholder: "Nguyễn Văn A", field: "name" as const, type: "text" },
{ id: "phone", label: "Số điện thoại", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
{ id: "password", label: "Mật khẩu", icon: "fa-lock", placeholder: "Ít nhất 6 ký tự", field: "password" as const, type: "password" },
{ id: "eateryName", label: "Tên nhà hàng", icon: "fa-store", placeholder: "Coffee & More", field: "eateryName" as const, type: "text" },
].map(({ id, label, icon, placeholder, field, type }) => (
<div key={id}>
<label htmlFor={id} className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
{label}
</label>
<div className="relative">
<i className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}></i>
<input
id={id}
type={type}
value={form[field]}
onChange={handleChange(field)}
placeholder={placeholder}
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors[field] ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors[field] && (
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{errors[field]}
</p>
)}
</div>
))}
{errors.submit && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<i className="fa-solid fa-circle-exclamation mr-2"></i>
{errors.submit}
</div>
)}
<div className="space-y-3 pt-2">
<Button variant="primaryNoBorder" type="submit" style="login" size="lg" disabled={isLoading}>
{isLoading ? (
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử ...</>
) : (
"Đăng ký"
)}
</Button>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white"
>
Đã tài khoản? Đăng nhập
</Link>
</div>
</form>
)}
</div>
</div>
);
}
+11 -11
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation"; import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid"; import { ProductGrid } from "@/components/organisms/product-grid";
import { SearchBar } from "@/components/molecules/search-bar";
import { useMenu } from "@/lib/menu-context";
import { MENU_CATEGORIES } from "@/lib/constants"; import { MENU_CATEGORIES } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/** /**
@@ -38,7 +38,7 @@ export default function Home() {
}, [activeCategory]); }, [activeCategory]);
const activeCategoryLabel = const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả"; MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
return ( return (
<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">
@@ -56,26 +56,26 @@ export default function Home() {
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center"> <div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
{/* Title + count */} {/* Title + count */}
<div className="shrink-0"> <div className="shrink-0">
<h2 className="text-foreground text-xl font-bold"> <h1 className="text-foreground text-xl font-bold">
{activeCategoryLabel} {activeCategoryLabel}
</h2> </h1>
</div> </div>
{/* Search bar */} {/* Search bar */}
<SearchBar <SearchBar
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onChange={(q) => {
if (q && activeCategory !== "all") setActiveCategory("all");
setSearchQuery(q);
}}
onClear={() => setSearchQuery("")} onClear={() => setSearchQuery("")}
placeholder="Tìm kiếm món..." placeholder="Search items..."
className="sm:max-w-xs" className="sm:max-w-xs"
/> />
</div> </div>
{/* ── Product grid (organism handles mobile category menu + grid) ── */} {/* ── Product grid (organism handles mobile category menu + grid) ── */}
<ProductGrid <ProductGrid searchQuery={searchQuery} isSidebarOpen={isSidebarOpen} />
searchQuery={searchQuery}
isSidebarOpen={isSidebarOpen}
/>
</main> </main>
</div> </div>
); );
+20 -10
View File
@@ -45,14 +45,23 @@ export default function PaymentPage() {
<table className="w-full min-w-190 text-sm"> <table className="w-full min-w-190 text-sm">
<thead> <thead>
<tr className="bg-(--color-border-light)/40 text-left"> <tr className="bg-(--color-border-light)/40 text-left">
<th className="px-4 py-3 font-semibold"> <th scope="col" className="px-4 py-3 font-semibold">
Tên sản phẩm Product name
</th> </th>
<th className="px-4 py-3 font-semibold">Giá tiền</th> <th scope="col" className="px-4 py-3 font-semibold">
<th className="px-4 py-3 font-semibold"> tả</th> Price
<th className="px-4 py-3 font-semibold">Số lượng</th> </th>
<th className="px-4 py-3 text-right font-semibold"> <th scope="col" className="px-4 py-3 font-semibold">
Xóa Description
</th>
<th scope="col" className="px-4 py-3 font-semibold">
Quantity
</th>
<th
scope="col"
className="px-4 py-3 text-right font-semibold"
>
Delete
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -75,7 +84,7 @@ export default function PaymentPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => decreaseQty(item.id)} onClick={() => decreaseQty(item.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Giảm số lượng ${item.name}`} aria-label={`Giảm số lượng ${item.name}`}
> >
- -
@@ -92,7 +101,7 @@ export default function PaymentPage() {
/> />
<button <button
onClick={() => increaseQty(item.id)} onClick={() => increaseQty(item.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-(--color-border) hover:bg-(--color-border-light)" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Tăng số lượng ${item.name}`} aria-label={`Tăng số lượng ${item.name}`}
> >
+ +
@@ -105,8 +114,9 @@ export default function PaymentPage() {
variant="danger" variant="danger"
size="md" size="md"
style="payment" style="payment"
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
> >
Xóa sản phẩm Delete product
</Button> </Button>
</td> </td>
</tr> </tr>
+147 -33
View File
@@ -6,19 +6,48 @@ import { SHOP_INFO } from "@/lib/constants";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
// Static OTP for demo (in production, this would be sent via SMS) // Static OTP for demo (in production, this would be sent via SMS)
const DEMO_OTP = "123456"; const DEMO_OTP = "123456";
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
const { completeRegistration } = useAuth(); const { setUser } = useAuth();
const [step, setStep] = useState<"phone" | "otp">("phone"); const [step, setStep] = useState<"phone" | "otp">("phone");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState("");
const [errors, setErrors] = useState({ phone: "", otp: "" }); const [errors, setErrors] = useState({ phone: "", otp: "" });
const [isLoading, setIsLoading] = useState(false);
const [otpSending, setOtpSending] = useState(false);
const [otpSendError, setOtpSendError] = useState("");
const sendOtp = async () => {
setOtpSending(true);
setOtpSendError("");
try {
const res = await fetch("/api/sms_otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (!res.ok) {
setOtpSendError("Unable to send OTP, please try again");
}
} catch {
setOtpSendError("Unable to connect, please try again");
} finally {
setOtpSending(false);
}
};
useEffect(() => {
if (step === "otp") {
sendOtp();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
// Validate Vietnamese phone number // Validate Vietnamese phone number
const validatePhone = (phoneNumber: string): boolean => { const validatePhone = (phoneNumber: string): boolean => {
@@ -28,43 +57,86 @@ export default function RegisterPage() {
return phoneRegex.test(phoneNumber); return phoneRegex.test(phoneNumber);
}; };
const handlePhoneSubmit = (e: FormEvent) => { const handlePhoneSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!phone.trim()) { if (!phone.trim()) {
setErrors({ ...errors, phone: "Vui lòng nhập số điện thoại" }); setErrors({ ...errors, phone: "Please enter your phone number" });
return; return;
} }
if (!validatePhone(phone)) { if (!validatePhone(phone)) {
setErrors({ setErrors({
...errors, ...errors,
phone: "Số điện thoại không hợp lệ (VD: 0987654321)", phone: "Invalid phone number (e.g. 0987654321)",
}); });
return; return;
} }
// Move to OTP step setIsLoading(true);
setStep("otp");
setErrors({ phone: "", otp: "" }); setErrors({ phone: "", otp: "" });
try {
const res = await fetch("/api/customer/quick_signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (res.ok) {
setStep("otp");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const phoneErrorMap: Record<string, string> = {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
setErrors({ phone: msg, otp: "" });
}
} catch {
setErrors({ phone: "Unable to connect, please try again", otp: "" });
} finally {
setIsLoading(false);
}
}; };
const handleOtpSubmit = (e: FormEvent) => { const handleOtpSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!otp.trim()) { if (!otp.trim()) {
setErrors({ ...errors, otp: "Vui lòng nhập mã OTP" }); setErrors({ ...errors, otp: "Please enter your OTP code" });
return; return;
} }
if (otp !== DEMO_OTP) { setIsLoading(true);
setErrors({ ...errors, otp: "Mã OTP không đúng" }); setErrors({ phone: "", otp: "" });
return;
}
// Complete registration try {
completeRegistration(phone); const res = await fetch("/api/customer/quick_login", {
router.push("/"); method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, otp }),
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
router.push("/");
} else {
const errorCode = (await res.text().catch(() => "")).trim();
const msg =
errorCode === "InvalidOTP"
? "Incorrect or expired OTP code"
: "An error occurred, please try again";
setErrors({ phone: "", otp: msg });
}
} catch {
setErrors({ phone: "", otp: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
}
}; };
const handleBackToPhone = () => { const handleBackToPhone = () => {
@@ -94,8 +166,8 @@ export default function RegisterPage() {
</h1> </h1>
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm text-(--color-text-muted)">
{step === "phone" {step === "phone"
? "Đăng ký tài khoản khách hàng" ? "Create a customer account"
: "Xác thực số điện thoại"} : "Verify your phone number"}
</p> </p>
</div> </div>
@@ -131,7 +203,7 @@ export default function RegisterPage() {
htmlFor="phone" htmlFor="phone"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)" className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
> >
Số điện thoại Phone number
</label> </label>
<div className="relative"> <div className="relative">
<i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i> <i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
@@ -144,7 +216,8 @@ export default function RegisterPage() {
setErrors({ ...errors, phone: "" }); setErrors({ ...errors, phone: "" });
}} }}
placeholder="0987654321" placeholder="0987654321"
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `} disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"} `}
/> />
</div> </div>
{errors.phone && ( {errors.phone && (
@@ -154,7 +227,7 @@ export default function RegisterPage() {
</p> </p>
)} )}
<p className="mt-2 text-xs text-(--color-text-muted)"> <p className="mt-2 text-xs text-(--color-text-muted)">
Nhập số điện thoại Việt Nam (10 số, bắt đu bằng 0) Enter a Vietnamese phone number (10 digits, starting with 0)
</p> </p>
</div> </div>
@@ -166,8 +239,16 @@ export default function RegisterPage() {
type="submit" type="submit"
style="login" style="login"
size="lg" size="lg"
disabled={isLoading}
> >
Tiếp tục {isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
</Button> </Button>
{/* Back to Login */} {/* Back to Login */}
@@ -175,7 +256,7 @@ export default function RegisterPage() {
href="/login" href="/login"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98" className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
> >
Quay lại đăng nhập Back to sign in
</Link> </Link>
</div> </div>
</form> </form>
@@ -188,7 +269,16 @@ export default function RegisterPage() {
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4"> <div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="mb-2 text-sm text-blue-800"> <p className="mb-2 text-sm text-blue-800">
<i className="fa-solid fa-circle-info mr-2"></i> <i className="fa-solid fa-circle-info mr-2"></i>
OTP đã đưc gửi đến số <strong>{phone}</strong> {otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Sending OTP to <strong>{phone}</strong>...
</>
) : (
<>
OTP code sent to <strong>{phone}</strong>
</>
)}
</p> </p>
<p className="text-xs text-blue-600"> <p className="text-xs text-blue-600">
Demo OTP:{" "} Demo OTP:{" "}
@@ -197,6 +287,12 @@ export default function RegisterPage() {
</code> </code>
</p> </p>
</div> </div>
{otpSendError && (
<p className="flex items-center gap-1 text-xs text-red-500">
<i className="fa-solid fa-circle-exclamation"></i>
{otpSendError}
</p>
)}
{/* OTP Input */} {/* OTP Input */}
<div> <div>
@@ -204,7 +300,7 @@ export default function RegisterPage() {
htmlFor="otp" htmlFor="otp"
className="mb-2 block text-sm font-medium text-(--color-text-secondary)" className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
> >
OTP OTP code
</label> </label>
<div className="relative"> <div className="relative">
<i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i> <i className="fa-solid fa-key absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
@@ -216,9 +312,10 @@ export default function RegisterPage() {
setOtp(e.target.value); setOtp(e.target.value);
setErrors({ ...errors, otp: "" }); setErrors({ ...errors, otp: "" });
}} }}
placeholder="Nhập mã OTP" placeholder="Enter OTP code"
maxLength={6} maxLength={6}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `} disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-4 py-3 text-center text-lg tracking-widest transition-all duration-150 outline-none placeholder:text-sm placeholder:tracking-normal placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.otp ? "border-red-400" : "border-(--color-border)"} `}
/> />
</div> </div>
{errors.otp && ( {errors.otp && (
@@ -237,8 +334,16 @@ export default function RegisterPage() {
type="submit" type="submit"
style="login" style="login"
size="lg" size="lg"
disabled={isLoading || otpSending}
> >
Hoàn tất đăng {isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Complete registration"
)}
</Button> </Button>
{/* Back Button */} {/* Back Button */}
@@ -247,19 +352,28 @@ export default function RegisterPage() {
onClick={handleBackToPhone} onClick={handleBackToPhone}
size="lg" size="lg"
style="login" style="login"
disabled={isLoading}
> >
Thay đi số điện thoại Change phone number
</Button> </Button>
</div> </div>
{/* Resend OTP (disabled in demo) */} {/* Resend OTP */}
<div className="text-center"> <div className="text-center">
<button <button
type="button" type="button"
disabled onClick={sendOtp}
className="cursor-not-allowed text-sm text-(--color-text-muted)" disabled={otpSending || isLoading}
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
> >
Gửi lại OTP (60s) {otpSending ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-1"></i>
Resending...
</>
) : (
"Resend OTP code"
)}
</button> </button>
</div> </div>
</form> </form>
+32 -32
View File
@@ -28,10 +28,10 @@ import { useMemo, useState } from "react";
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = { const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
day: "Theo ngày", day: "By day",
week: "Theo tuần", week: "By week",
month: "Theo tháng", month: "By month",
year: "Theo năm", year: "By year",
}; };
const CATEGORY_COLORS = [ const CATEGORY_COLORS = [
@@ -67,7 +67,7 @@ const CHART_META: Record<ChartType, { icon: string; label: string }> = {
function CategorySelect({ function CategorySelect({
value, value,
onChange, onChange,
label = "Danh mục:", label = "Category:",
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
@@ -78,12 +78,12 @@ function CategorySelect({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-(--color-text-muted)">{label}</label> <label className="text-xs text-(--color-text-muted)">{label}</label>
<select <select
title="Danh mục" title="Category"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs" className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
> >
<option value="all">Tất cả</option> <option value="all">All</option>
{categories.map((c) => ( {categories.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} {c.name}
@@ -181,7 +181,7 @@ export default function AnalyticsPage() {
</span> </span>
<div> <div>
<h1 className="text-foreground text-lg leading-tight font-bold"> <h1 className="text-foreground text-lg leading-tight font-bold">
Thống & Phân tích tài chính Financial Statistics & Analytics
</h1> </h1>
<p className="text-xs text-(--color-text-muted)"> <p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard Financial Analytics Dashboard
@@ -205,7 +205,7 @@ export default function AnalyticsPage() {
</button> </button>
))} ))}
<select <select
title="Chọn kỳ" title="Select period"
value={period} value={period}
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)} onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden" className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
@@ -226,12 +226,12 @@ export default function AnalyticsPage() {
{/* ── Summary Cards ── */} {/* ── Summary Cards ── */}
<section> <section>
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase"> <h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
Tổng quan Overview
</h2> </h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard <SummaryCard
icon="fa-solid fa-sack-dollar" icon="fa-solid fa-sack-dollar"
title="Tổng doanh thu" title="Total Revenue"
value={formatCurrency(totalRevenue)} value={formatCurrency(totalRevenue)}
subtitle={PERIOD_LABELS[period]} subtitle={PERIOD_LABELS[period]}
change={revComp.change} change={revComp.change}
@@ -240,27 +240,27 @@ export default function AnalyticsPage() {
/> />
<SummaryCard <SummaryCard
icon="fa-solid fa-receipt" icon="fa-solid fa-receipt"
title="Số đơn hàng" title="Total Orders"
value={totalOrders.toLocaleString()} value={totalOrders.toLocaleString()}
subtitle="Tổng đơn trong kỳ" subtitle="Total orders in period"
change={ordComp.change} change={ordComp.change}
changePercent={ordComp.changePercent} changePercent={ordComp.changePercent}
isPositive={ordComp.isPositive} isPositive={ordComp.isPositive}
/> />
<SummaryCard <SummaryCard
icon="fa-solid fa-circle-dollar-to-slot" icon="fa-solid fa-circle-dollar-to-slot"
title="Tổng lợi nhuận" title="Total Profit"
value={formatCurrency(totalProfit)} value={formatCurrency(totalProfit)}
subtitle="Ước tính từ dữ liệu bán hàng" subtitle="Estimated from sales data"
change={proComp.change} change={proComp.change}
changePercent={proComp.changePercent} changePercent={proComp.changePercent}
isPositive={proComp.isPositive} isPositive={proComp.isPositive}
/> />
<SummaryCard <SummaryCard
icon="fa-solid fa-basket-shopping" icon="fa-solid fa-basket-shopping"
title="Giá trị đơn TB" title="Avg. Order Value"
value={formatCurrency(avgOrderValue)} value={formatCurrency(avgOrderValue)}
subtitle="Doanh thu / số đơn hàng" subtitle="Revenue / number of orders"
change={0} change={0}
changePercent={0} changePercent={0}
isPositive={true} isPositive={true}
@@ -273,7 +273,7 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold"> <h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i> <i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
Biểu đ doanh thu Revenue Chart
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
{CHART_TYPES.map((t) => ( {CHART_TYPES.map((t) => (
@@ -298,7 +298,7 @@ export default function AnalyticsPage() {
{activeChart === "line" && ( {activeChart === "line" && (
<> <>
<p className="mb-3 text-xs text-(--color-text-muted)"> <p className="mb-3 text-xs text-(--color-text-muted)">
Doanh thu theo thời gian {PERIOD_LABELS[period]} Revenue over time {PERIOD_LABELS[period]}
</p> </p>
<LineChart data={revenueData} height={220} /> <LineChart data={revenueData} height={220} />
</> </>
@@ -306,7 +306,7 @@ export default function AnalyticsPage() {
{activeChart === "bar" && ( {activeChart === "bar" && (
<> <>
<p className="mb-3 text-xs text-(--color-text-muted)"> <p className="mb-3 text-xs text-(--color-text-muted)">
So sánh doanh thu nửa đu nửa sau kỳ hiện tại Comparing revenue of the first and second half of the current period
</p> </p>
<BarChart <BarChart
current={barCurrent} current={barCurrent}
@@ -318,7 +318,7 @@ export default function AnalyticsPage() {
{activeChart === "pie" && ( {activeChart === "pie" && (
<> <>
<p className="mb-3 text-xs text-(--color-text-muted)"> <p className="mb-3 text-xs text-(--color-text-muted)">
Tỷ trọng doanh thu theo danh mục sản phẩm Revenue share by product category
</p> </p>
<PieChart data={pieData} /> <PieChart data={pieData} />
</> </>
@@ -330,7 +330,7 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold"> <h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-fire mr-2 text-orange-500"></i> <i className="fa-solid fa-fire mr-2 text-orange-500"></i>
Top sản phẩm bán chạy Top best-selling products
</h2> </h2>
<CategorySelect <CategorySelect
value={categoryFilter} value={categoryFilter}
@@ -353,7 +353,7 @@ export default function AnalyticsPage() {
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs"> <div className="flex shrink-0 items-center gap-3 text-xs">
<span className="text-(--color-text-muted) tabular-nums"> <span className="text-(--color-text-muted) tabular-nums">
{p.unitsSold} ly {p.unitsSold} cups
</span> </span>
<span className="font-semibold text-(--color-primary) tabular-nums"> <span className="font-semibold text-(--color-primary) tabular-nums">
{formatCurrency(p.revenue)} {formatCurrency(p.revenue)}
@@ -377,17 +377,17 @@ export default function AnalyticsPage() {
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-foreground text-base font-semibold"> <h2 className="text-foreground text-base font-semibold">
<i className="fa-solid fa-table text-foreground mr-2"></i> <i className="fa-solid fa-table text-foreground mr-2"></i>
Phân tích chi tiết sản phẩm Detailed product analytics
</h2> </h2>
<CategorySelect <CategorySelect
value={categoryFilter} value={categoryFilter}
onChange={setCategoryFilter} onChange={setCategoryFilter}
label="Lọc danh mục:" label="Filter category:"
/> />
</div> </div>
<p className="mb-3 text-xs text-(--color-text-muted)"> <p className="mb-3 text-xs text-(--color-text-muted)">
Click vào tiêu đ cột đ sắp xếp. Hiển thị {filteredSales.length}{" "} Click column headers to sort. Showing {filteredSales.length}{" "}
sản phẩm. products.
</p> </p>
<ProductTable data={filteredSales} /> <ProductTable data={filteredSales} />
@@ -395,7 +395,7 @@ export default function AnalyticsPage() {
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm"> <div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
<div> <div>
<span className="text-(--color-text-muted)"> <span className="text-(--color-text-muted)">
Tổng doanh thu:{" "} Total revenue:{" "}
</span> </span>
<span className="font-semibold text-(--color-primary)"> <span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)} {formatCurrencyFull(filteredRevenue)}
@@ -403,7 +403,7 @@ export default function AnalyticsPage() {
</div> </div>
<div> <div>
<span className="text-(--color-text-muted)"> <span className="text-(--color-text-muted)">
Tổng lợi nhuận:{" "} Total profit:{" "}
</span> </span>
<span className="font-semibold text-green-600"> <span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)} {formatCurrencyFull(filteredProfit)}
@@ -411,15 +411,15 @@ export default function AnalyticsPage() {
</div> </div>
<div> <div>
<span className="text-(--color-text-muted)"> <span className="text-(--color-text-muted)">
Tổng sản lượng:{" "} Total units:{" "}
</span> </span>
<span className="text-foreground font-semibold"> <span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} ly {filteredUnits.toLocaleString()} cups
</span> </span>
</div> </div>
<div> <div>
<span className="text-(--color-text-muted)"> <span className="text-(--color-text-muted)">
Biên LN trung bình:{" "} Avg. profit margin:{" "}
</span> </span>
<span className="font-semibold text-yellow-700"> <span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}% {avgMargin.toFixed(1)}%
+42 -30
View File
@@ -3,6 +3,7 @@
import { import {
CategoriesTab, CategoriesTab,
CombosTab, CombosTab,
MenuItemsTab,
ProductsTab, ProductsTab,
} from "@/components/organisms/manager"; } from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
@@ -17,7 +18,7 @@ export default function ManagerPage() {
const tabs = [ const tabs = [
{ {
id: "products" as const, id: "products" as const,
label: "Thực đơn", label: "Menu",
icon: "fa-solid fa-utensils", icon: "fa-solid fa-utensils",
count: products.length, count: products.length,
}, },
@@ -29,10 +30,16 @@ export default function ManagerPage() {
}, },
{ {
id: "categories" as const, id: "categories" as const,
label: "Danh mục", label: "Categories",
icon: "fa-solid fa-tags", icon: "fa-solid fa-tags",
count: categories.length, count: categories.length,
}, },
{
id: "menu-items" as const,
label: "Menu",
icon: "fa-solid fa-bowl-food",
count: null,
},
]; ];
return ( return (
@@ -51,7 +58,7 @@ export default function ManagerPage() {
<nav className="flex-1 space-y-1 p-3"> <nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Quản thực đơn Menu Management
</p> </p>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
@@ -65,29 +72,31 @@ export default function ManagerPage() {
> >
<i className={`${tab.icon} w-4 text-center`}></i> <i className={`${tab.icon} w-4 text-center`}></i>
<span className="flex-1 text-left">{tab.label}</span> <span className="flex-1 text-left">{tab.label}</span>
<span {tab.count !== null && (
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${ <span
activeTab === tab.id className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
? "bg-white/20 text-white" activeTab === tab.id
: "bg-(--color-border-light) text-(--color-text-muted)" ? "bg-white/20 text-white"
}`} : "bg-(--color-border-light) text-(--color-text-muted)"
> }`}
{tab.count} >
</span> {tab.count}
</span>
)}
</button> </button>
))} ))}
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Phân tích Analytics
</p> </p>
<Link <Link
href="/manager/analytics" href="/manager/analytics"
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)" className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
> >
<i className="fa-solid fa-chart-line w-4 text-center"></i> <i className="fa-solid fa-chart-line w-4 text-center"></i>
<span className="flex-1 text-left">Tài chính</span> <span className="flex-1 text-left">Finance</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)"> <span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
Mới New
</span> </span>
</Link> </Link>
<Link <Link
@@ -95,9 +104,9 @@ export default function ManagerPage() {
className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)" className="hover:bg-background flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) no-underline transition-all hover:text-(--color-primary-dark)"
> >
<i className="fa-solid fa-calendar-days w-4 text-center"></i> <i className="fa-solid fa-calendar-days w-4 text-center"></i>
<span className="flex-1 text-left">Ca làm</span> <span className="flex-1 text-left">Shifts</span>
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)"> <span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs font-semibold text-(--color-primary)">
Mới New
</span> </span>
</Link> </Link>
</div> </div>
@@ -110,9 +119,9 @@ export default function ManagerPage() {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold"> <p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Quản lý"} {user?.name ?? "Manager"}
</p> </p>
<p className="text-xs text-(--color-text-muted)">Quản quán</p> <p className="text-xs text-(--color-text-muted)">Store Manager</p>
</div> </div>
</div> </div>
<div className="mt-1 flex gap-2 px-1"> <div className="mt-1 flex gap-2 px-1">
@@ -121,14 +130,14 @@ export default function ManagerPage() {
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition" className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
> >
<i className="fa-solid fa-house"></i> <i className="fa-solid fa-house"></i>
Trang chủ Home
</Link> </Link>
<button <button
onClick={logout} onClick={logout}
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50" className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
> >
<i className="fa-solid fa-right-from-bracket"></i> <i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất Logout
</button> </button>
</div> </div>
</div> </div>
@@ -139,16 +148,18 @@ export default function ManagerPage() {
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm"> <header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-5 py-4 shadow-sm">
<div> <div>
<h1 className="text-foreground text-lg font-bold"> <h1 className="text-foreground text-lg font-bold">
{tabs.find((t) => t.id === activeTab)?.label ?? "Quản lý"} {tabs.find((t) => t.id === activeTab)?.label ?? "Manager"}
</h1> </h1>
<p className="text-xs text-(--color-text-muted)"> <p className="text-xs text-(--color-text-muted)">
Quản {" "} Manage{" "}
{activeTab === "products" {activeTab === "products"
? "thực đơn" ? "menu items"
: activeTab === "combos" : activeTab === "combos"
? "combo" ? "combos"
: "danh mục"}{" "} : activeTab === "categories"
của quán ? "categories"
: "menu"}{" "}
for your store
</p> </p>
</div> </div>
@@ -173,7 +184,7 @@ export default function ManagerPage() {
className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70" className="flex items-center gap-1.5 rounded-xl border-none bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:bg-(--color-accent-light)/70"
> >
<i className="fa-solid fa-chart-line"></i> <i className="fa-solid fa-chart-line"></i>
<span className="hidden sm:inline">Tài chính</span> <span className="hidden sm:inline">Finance</span>
</Link> </Link>
</div> </div>
@@ -184,14 +195,14 @@ export default function ManagerPage() {
className="flex items-center gap-1.5 rounded-xl bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80" className="flex items-center gap-1.5 rounded-xl bg-(--color-accent-light) px-3 py-2 text-xs font-medium text-(--color-primary) no-underline transition hover:opacity-80"
> >
<i className="fa-solid fa-chart-line"></i> <i className="fa-solid fa-chart-line"></i>
Thống tài chính Financial Analytics
</Link> </Link>
<Link <Link
href="/" href="/"
className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition" className="hover:bg-background flex items-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent px-3 py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
> >
<i className="fa-solid fa-house"></i> <i className="fa-solid fa-house"></i>
Trang chủ Home
</Link> </Link>
</div> </div>
</header> </header>
@@ -200,6 +211,7 @@ export default function ManagerPage() {
{activeTab === "products" && <ProductsTab />} {activeTab === "products" && <ProductsTab />}
{activeTab === "combos" && <CombosTab />} {activeTab === "combos" && <CombosTab />}
{activeTab === "categories" && <CategoriesTab />} {activeTab === "categories" && <CategoriesTab />}
{activeTab === "menu-items" && <MenuItemsTab />}
</main> </main>
</div> </div>
</div> </div>
+40 -27
View File
@@ -12,9 +12,18 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
const MONTH_NAMES = [ const MONTH_NAMES = [
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4", "January",
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8", "February",
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12", "March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]; ];
function getMonday(d: Date): Date { function getMonday(d: Date): Date {
@@ -86,7 +95,7 @@ export default function StaffSchedulePage() {
<i className="fa-solid fa-calendar-days text-sm text-white"></i> <i className="fa-solid fa-calendar-days text-sm text-white"></i>
</div> </div>
<div> <div>
<p className="text-foreground text-sm font-bold">Lịch làm việc</p> <p className="text-foreground text-sm font-bold">Work Schedule</p>
<p className="text-xs text-(--color-text-muted)"> <p className="text-xs text-(--color-text-muted)">
{isManager ? "Manager" : "Staff"} {isManager ? "Manager" : "Staff"}
</p> </p>
@@ -96,7 +105,7 @@ export default function StaffSchedulePage() {
{/* View toggle */} {/* View toggle */}
<nav className="flex-1 space-y-1 p-3"> <nav className="flex-1 space-y-1 p-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Chế đ xem View
</p> </p>
<button <button
type="button" type="button"
@@ -108,7 +117,7 @@ export default function StaffSchedulePage() {
}`} }`}
> >
<i className="fa-solid fa-table-columns w-4 text-center"></i> <i className="fa-solid fa-table-columns w-4 text-center"></i>
<span className="flex-1 text-left">Theo tuần</span> <span className="flex-1 text-left">Weekly</span>
</button> </button>
<button <button
type="button" type="button"
@@ -120,13 +129,13 @@ export default function StaffSchedulePage() {
}`} }`}
> >
<i className="fa-solid fa-calendar w-4 text-center"></i> <i className="fa-solid fa-calendar w-4 text-center"></i>
<span className="flex-1 text-left">Theo tháng</span> <span className="flex-1 text-left">Monthly</span>
</button> </button>
{/* Quick nav */} {/* Quick nav */}
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Điều hướng Navigation
</p> </p>
<button <button
type="button" type="button"
@@ -134,7 +143,7 @@ export default function StaffSchedulePage() {
className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)" className="hover:bg-background flex w-full cursor-pointer items-center gap-3 rounded-xl border-none bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-secondary) transition-all hover:text-(--color-primary-dark)"
> >
<i className="fa-solid fa-crosshairs w-4 text-center"></i> <i className="fa-solid fa-crosshairs w-4 text-center"></i>
<span className="flex-1 text-left">Hôm nay</span> <span className="flex-1 text-left">Today</span>
</button> </button>
</div> </div>
@@ -142,7 +151,7 @@ export default function StaffSchedulePage() {
{isManager && ( {isManager && (
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-3 border-t border-(--color-border-light) pt-3">
<p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase"> <p className="mb-2 px-3 text-[11px] font-semibold tracking-wider text-(--color-text-muted) uppercase">
Quản Management
</p> </p>
<Link <Link
href="/manager" href="/manager"
@@ -158,7 +167,7 @@ export default function StaffSchedulePage() {
<div className="mt-3 border-t border-(--color-border-light) pt-3"> <div className="mt-3 border-t border-(--color-border-light) pt-3">
<div className="rounded-xl bg-(--color-primary)/5 p-3"> <div className="rounded-xl bg-(--color-primary)/5 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase"> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Ngân sách tuần Weekly Budget
</p> </p>
<p className="mt-1 text-lg font-bold text-(--color-primary)"> <p className="mt-1 text-lg font-bold text-(--color-primary)">
{weeklyBudget.toLocaleString("vi-VN")} {weeklyBudget.toLocaleString("vi-VN")}
@@ -172,14 +181,16 @@ export default function StaffSchedulePage() {
<div className="border-t border-(--color-border-light) p-3"> <div className="border-t border-(--color-border-light) p-3">
<div className="flex items-center gap-3 rounded-xl p-3"> <div className="flex items-center gap-3 rounded-xl p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light)">
<i className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}></i> <i
className={`fa-solid ${isManager ? "fa-user-tie" : "fa-user"} text-sm text-(--color-primary)`}
></i>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-semibold"> <p className="text-foreground truncate text-sm font-semibold">
{user?.name ?? "Nhân viên"} {user?.name ?? "Staff"}
</p> </p>
<p className="text-xs text-(--color-text-muted)"> <p className="text-xs text-(--color-text-muted)">
{isManager ? "Quản lý" : "Nhân viên"} {isManager ? "Manager" : "Staff"}
</p> </p>
</div> </div>
</div> </div>
@@ -189,7 +200,7 @@ export default function StaffSchedulePage() {
className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition" className="hover:bg-background flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-(--color-border-light) bg-transparent py-2 text-xs font-medium text-(--color-text-secondary) no-underline transition"
> >
<i className="fa-solid fa-house"></i> <i className="fa-solid fa-house"></i>
Trang chủ Home
</Link> </Link>
<button <button
type="button" type="button"
@@ -197,7 +208,7 @@ export default function StaffSchedulePage() {
className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50" className="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-xl border-none bg-transparent py-2 text-xs font-medium text-red-500 transition hover:bg-red-50"
> >
<i className="fa-solid fa-right-from-bracket"></i> <i className="fa-solid fa-right-from-bracket"></i>
Đăng xuất Logout
</button> </button>
</div> </div>
</div> </div>
@@ -209,10 +220,12 @@ export default function StaffSchedulePage() {
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4"> <header className="sticky top-0 z-40 flex items-center justify-between border-b border-(--color-border-light) bg-white px-4 py-3 shadow-sm md:px-5 md:py-4">
<div> <div>
<h1 className="text-foreground text-base font-bold md:text-lg"> <h1 className="text-foreground text-base font-bold md:text-lg">
Đăng ca làm Register Shift
</h1> </h1>
<p className="text-xs text-(--color-text-muted)"> <p className="text-xs text-(--color-text-muted)">
{view === "week" ? weekLabel : `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`} {view === "week"
? weekLabel
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`}
</p> </p>
</div> </div>
@@ -228,7 +241,7 @@ export default function StaffSchedulePage() {
: "bg-transparent text-(--color-text-secondary)" : "bg-transparent text-(--color-text-secondary)"
}`} }`}
> >
Tuần Week
</button> </button>
<button <button
type="button" type="button"
@@ -239,14 +252,14 @@ export default function StaffSchedulePage() {
: "bg-transparent text-(--color-text-secondary)" : "bg-transparent text-(--color-text-secondary)"
}`} }`}
> >
Tháng Month
</button> </button>
</div> </div>
{/* Navigation arrows */} {/* Navigation arrows */}
<div className="hidden items-center gap-1 md:flex"> <div className="hidden items-center gap-1 md:flex">
<button <button
title="Về trước" title="Previous"
type="button" type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth} onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
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:bg-gray-50" 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:bg-gray-50"
@@ -258,10 +271,10 @@ export default function StaffSchedulePage() {
onClick={goToToday} onClick={goToToday}
className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50" className="cursor-pointer rounded-lg border border-(--color-border-light) bg-transparent px-3 py-1.5 text-xs font-medium text-(--color-text-secondary) transition hover:bg-gray-50"
> >
Hôm nay Today
</button> </button>
<button <button
title="Tiếp theo" title="Next"
type="button" type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth} onClick={view === "week" ? goToNextWeek : goToNextMonth}
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:bg-gray-50" 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:bg-gray-50"
@@ -281,14 +294,14 @@ export default function StaffSchedulePage() {
className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex" className="hidden cursor-pointer items-center gap-1.5 rounded-xl border-none bg-(--color-primary) px-3 py-2 text-xs font-semibold text-white transition hover:opacity-90 md:flex"
> >
<i className="fa-solid fa-plus"></i> <i className="fa-solid fa-plus"></i>
Tạo ca Create Shift
</button> </button>
)} )}
{/* Mobile nav */} {/* Mobile nav */}
<div className="flex items-center gap-1 md:hidden"> <div className="flex items-center gap-1 md:hidden">
<button <button
title="Về trước" title="Previous"
type="button" type="button"
onClick={view === "week" ? goToPrevWeek : goToPrevMonth} onClick={view === "week" ? goToPrevWeek : goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
@@ -296,7 +309,7 @@ export default function StaffSchedulePage() {
<i className="fa-solid fa-chevron-left text-xs"></i> <i className="fa-solid fa-chevron-left text-xs"></i>
</button> </button>
<button <button
title="Tiếp theo" title="Next"
type="button" type="button"
onClick={view === "week" ? goToNextWeek : goToNextMonth} onClick={view === "week" ? goToNextWeek : goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted)"
@@ -340,7 +353,7 @@ export default function StaffSchedulePage() {
{/* Mobile FAB for manager */} {/* Mobile FAB for manager */}
{isManager && ( {isManager && (
<button <button
title="Tạo ca" title="Create Shift"
type="button" type="button"
onClick={() => { onClick={() => {
setCreateDate(undefined); setCreateDate(undefined);
+42 -25
View File
@@ -1,7 +1,7 @@
# Components Documentation # Components Documentation
> This project follows **Atomic Design** pattern. > This project follows **Atomic Design** pattern. See `Atomic.md` at project
> See `Atomic.md` at project root for full structure guide. > root for full structure guide.
## Directory Structure ## Directory Structure
@@ -19,17 +19,21 @@ components/
### ProductCard (`molecules/cards/ProductCard.tsx`) ### ProductCard (`molecules/cards/ProductCard.tsx`)
**Description:** Product card molecule. Displays product image, name, description, formatted price, and a Buy button. Width is controlled by the parent grid (w-full). **Description:** Product card molecule. Displays product image, name,
description, formatted price, and a Buy button. Width is controlled by the
parent grid (w-full).
**Atomic level:** Molecule (composed of Button, Caption, Text atoms) **Atomic level:** Molecule (composed of Button, Caption, Text atoms)
### ShopCard (`molecules/cards/ShopCard.tsx`) ### ShopCard (`molecules/cards/ShopCard.tsx`)
**Description:** Shop discovery card. Displays shop image, name, address, and a link to the menu. **Description:** Shop discovery card. Displays shop image, name, address, and a
link to the menu.
### SearchBar (`molecules/search-bar/SearchBar.tsx`) ### SearchBar (`molecules/search-bar/SearchBar.tsx`)
**Description:** Search input with clear button. Controlled component — value and onChange come from parent. **Description:** Search input with clear button. Controlled component — value
and onChange come from parent.
--- ---
@@ -37,35 +41,41 @@ components/
### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`) ### CategorySidebar (`organisms/navigation/CategorySidebar.tsx`)
**Description:** Left sidebar with collapsible category filter. Sticky below header, full viewport height minus header. **Description:** Left sidebar with collapsible category filter. Sticky below
header, full viewport height minus header.
### CartFab (`organisms/cart/CartFab.tsx`) ### CartFab (`organisms/cart/CartFab.tsx`)
**Description:** Floating Action Button displaying cart item count. Links to /payment page. **Description:** Floating Action Button displaying cart item count. Links to
/payment page.
### ProductGrid (`organisms/product-grid/ProductGrid.tsx`) ### ProductGrid (`organisms/product-grid/ProductGrid.tsx`)
**Description:** Full product grid organism with mobile category tabs, filtering, and empty state. Uses useCart and useMenu contexts. **Description:** Full product grid organism with mobile category tabs,
filtering, and empty state. Uses useCart and useMenu contexts.
### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`) ### ShopGrid (`organisms/shop-grid/ShopGrid.tsx`)
**Description:** Responsive grid of ShopCard molecules for the feed/discovery page. **Description:** Responsive grid of ShopCard molecules for the feed/discovery
page.
### ReviewModal (`organisms/modals/ReviewModal.tsx`) ### ReviewModal (`organisms/modals/ReviewModal.tsx`)
**Description:** Modal for customer reviews. Shows 5-star rating and textarea. After submission, displays thank-you message. **Description:** Modal for customer reviews. Shows 5-star rating and textarea.
After submission, displays thank-you message.
### Analytics Charts (`organisms/analytics/`) ### Analytics Charts (`organisms/analytics/`)
**Description:** Pure-SVG chart and table components for the Financial Analytics dashboard. All components are interactive with hover tooltips. **Description:** Pure-SVG chart and table components for the Financial Analytics
dashboard. All components are interactive with hover tooltips.
| Component | File | Description | | Component | File | Description |
|-----------|------|-------------| | ------------ | ---------------- | ---------------------------------------------------------- |
| LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips | | LineChart | LineChart.tsx | Revenue trend line chart with area fill and hover tooltips |
| BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period | | BarChart | BarChart.tsx | Grouped bar chart comparing current vs previous period |
| PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown | | PieChart | PieChart.tsx | Pie chart with interactive legend for category breakdown |
| SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator | | SummaryCard | SummaryCard.tsx | Metric card with period-over-period comparison indicator |
| ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges | | ProductTable | ProductTable.tsx | Sortable product sales table with profit margin badges |
**Usage:** Imported via `@/components/organisms/analytics` barrel index. **Usage:** Imported via `@/components/organisms/analytics` barrel index.
@@ -75,11 +85,13 @@ components/
### MainLayout (`templates/main-layout/MainLayout.tsx`) ### MainLayout (`templates/main-layout/MainLayout.tsx`)
**Description:** Main layout template — wraps content with Header, Footer, and CartFab. Used by (main) route group. **Description:** Main layout template — wraps content with Header, Footer, and
CartFab. Used by (main) route group.
### AuthLayout (`templates/auth-layout/AuthLayout.tsx`) ### AuthLayout (`templates/auth-layout/AuthLayout.tsx`)
**Description:** Auth layout template — centers content in screen. Used by login/register pages. **Description:** Auth layout template — centers content in screen. Used by
login/register pages.
### FeedLayout (`templates/feed-layout/FeedLayout.tsx`) ### FeedLayout (`templates/feed-layout/FeedLayout.tsx`)
@@ -87,7 +99,8 @@ components/
### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`) ### ManagerLayout (`templates/manager-layout/ManagerLayout.tsx`)
**Description:** Manager layout template — auth guard + ManagerProvider. Used by the manager route group. **Description:** Manager layout template — auth guard + ManagerProvider. Used by
the manager route group.
--- ---
@@ -95,16 +108,20 @@ components/
### AuthContext (`lib/auth-context.tsx`) ### AuthContext (`lib/auth-context.tsx`)
Manages user authentication state including login, logout, and registration. Uses localStorage for persistence. Manages user authentication state including login, logout, and registration.
Uses localStorage for persistence.
### CartContext (`lib/cart-context.tsx`) ### CartContext (`lib/cart-context.tsx`)
Manages shopping cart state with localStorage persistence. Tracks items, quantities, and totals. Manages shopping cart state with localStorage persistence. Tracks items,
quantities, and totals.
### MenuContext (`lib/menu-context.tsx`) ### MenuContext (`lib/menu-context.tsx`)
Provides shared activeCategory state across components. Synchronizes Header mobile menu and CategorySidebar selection. Provides shared activeCategory state across components. Synchronizes Header
mobile menu and CategorySidebar selection.
### ManagerContext (`lib/manager-context.tsx`) ### ManagerContext (`lib/manager-context.tsx`)
Manages menu CRUD state (products, combos, categories) for the manager dashboard. Manages menu CRUD state (products, combos, categories) for the manager
dashboard.
+19
View File
@@ -42,6 +42,7 @@ Main interactive button component with multiple variants and sizes.
- All standard HTML button attributes - All standard HTML button attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
@@ -59,6 +60,7 @@ import { Button } from "@/components/atoms";
``` ```
**Styling:** **Styling:**
- Primary: Branded color with dark hover - Primary: Branded color with dark hover
- Secondary: Border style with light background on hover - Secondary: Border style with light background on hover
- Danger: Red for destructive actions - Danger: Red for destructive actions
@@ -78,6 +80,7 @@ Button designed specifically for icon-only interactions.
- All standard HTML button attributes - All standard HTML button attributes
**Usage:** **Usage:**
```tsx ```tsx
import { IconButton } from "@/components/atoms"; import { IconButton } from "@/components/atoms";
@@ -102,6 +105,7 @@ General text input field with optional label, error, and icon.
- All standard HTML input attributes - All standard HTML input attributes
**Usage:** **Usage:**
```tsx ```tsx
import { TextInput } from "@/components/atoms"; import { TextInput } from "@/components/atoms";
@@ -135,6 +139,7 @@ Search input with built-in search icon and clear button.
- All standard HTML input attributes - All standard HTML input attributes
**Usage:** **Usage:**
```tsx ```tsx
import { SearchInput } from "@/components/atoms"; import { SearchInput } from "@/components/atoms";
@@ -161,6 +166,7 @@ Multi-line text input with optional label and error.
- All standard HTML textarea attributes - All standard HTML textarea attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Textarea } from "@/components/atoms"; import { Textarea } from "@/components/atoms";
@@ -188,6 +194,7 @@ Semantic heading component with level-based sizing.
- All standard HTML heading attributes - All standard HTML heading attributes
**Sizing:** **Sizing:**
- Level 1: text-3xl font-bold - Level 1: text-3xl font-bold
- Level 2: text-2xl font-bold - Level 2: text-2xl font-bold
- Level 3: text-xl font-bold - Level 3: text-xl font-bold
@@ -196,6 +203,7 @@ Semantic heading component with level-based sizing.
- Level 6: text-sm font-semibold - Level 6: text-sm font-semibold
**Usage:** **Usage:**
```tsx ```tsx
import { Heading } from "@/components/atoms"; import { Heading } from "@/components/atoms";
@@ -217,12 +225,14 @@ Paragraph text with semantic variants.
- All standard HTML paragraph attributes - All standard HTML paragraph attributes
**Variants:** **Variants:**
- body1: text-base (main content) - body1: text-base (main content)
- body2: text-sm (secondary content) - body2: text-sm (secondary content)
- caption: text-xs (smallest text) - caption: text-xs (smallest text)
- label: text-sm font-medium (form labels) - label: text-sm font-medium (form labels)
**Usage:** **Usage:**
```tsx ```tsx
import { Text } from "@/components/atoms"; import { Text } from "@/components/atoms";
@@ -244,6 +254,7 @@ Small caption/note text component.
- All standard HTML span attributes - All standard HTML span attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Caption } from "@/components/atoms"; import { Caption } from "@/components/atoms";
@@ -268,6 +279,7 @@ Labeled badge component for highlighting information.
- All standard HTML span attributes - All standard HTML span attributes
**Variants:** **Variants:**
- primary: Branded color - primary: Branded color
- secondary: Light gray - secondary: Light gray
- success: Green - success: Green
@@ -275,6 +287,7 @@ Labeled badge component for highlighting information.
- warning: Yellow - warning: Yellow
**Usage:** **Usage:**
```tsx ```tsx
import { Badge } from "@/components/atoms"; import { Badge } from "@/components/atoms";
@@ -296,6 +309,7 @@ Specialized badge for displaying formatted prices.
- All standard HTML span attributes - All standard HTML span attributes
**Usage:** **Usage:**
```tsx ```tsx
import { PriceBadge } from "@/components/atoms"; import { PriceBadge } from "@/components/atoms";
@@ -304,6 +318,7 @@ import { PriceBadge } from "@/components/atoms";
``` ```
**Output:** **Output:**
- VND: "50.000 ₫" - VND: "50.000 ₫"
- USD: "$99.99" - USD: "$99.99"
@@ -321,6 +336,7 @@ Visual separator element.
- All standard HTML hr attributes - All standard HTML hr attributes
**Usage:** **Usage:**
```tsx ```tsx
import { Divider } from "@/components/atoms"; import { Divider } from "@/components/atoms";
@@ -414,6 +430,7 @@ import type {
``` ```
### Product Card ### Product Card
```tsx ```tsx
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<Heading level={3}>Product Name</Heading> <Heading level={3}>Product Name</Heading>
@@ -426,6 +443,7 @@ import type {
``` ```
### Rating Display ### Rating Display
```tsx ```tsx
<div> <div>
<Heading level={4}>Reviews</Heading> <Heading level={4}>Reviews</Heading>
@@ -439,6 +457,7 @@ import type {
## 🧪 Testing Atoms ## 🧪 Testing Atoms
### Props Validation ### Props Validation
```tsx ```tsx
// ✅ Valid // ✅ Valid
<Button variant="primary" size="sm">Click</Button> <Button variant="primary" size="sm">Click</Button>
-31
View File
@@ -1,31 +0,0 @@
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>
);
}
-7
View File
@@ -1,7 +0,0 @@
import { HTMLAttributes } from "react";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: "primary" | "secondary" | "success" | "danger" | "warning";
size?: "sm" | "md";
children: React.ReactNode;
}
-31
View File
@@ -1,31 +0,0 @@
import { HTMLAttributes } from "react";
export interface PriceBadgeProps extends HTMLAttributes<HTMLSpanElement> {
price: number;
currency?: string;
className?: string;
}
export default function PriceBadge({
price,
currency = "VND",
className = "",
...props
}: PriceBadgeProps) {
const formattedPrice =
currency === "VND"
? price.toLocaleString("vi-VN", {
style: "currency",
currency: "VND",
})
: `${price.toFixed(2)} ${currency}`;
return (
<span
className={`text-sm font-bold text-(--color-primary) ${className}`}
{...props}
>
{formattedPrice}
</span>
);
}
-4
View File
@@ -1,4 +0,0 @@
export { default as Badge } from "./Badge";
export { default as PriceBadge } from "./PriceBadge";
export type { BadgeProps } from "./Badge.types";
export type { PriceBadgeProps } from "./PriceBadge";
+2 -1
View File
@@ -8,6 +8,7 @@ export default function Button({
size = "md", size = "md",
icon, icon,
iconPosition = "left", iconPosition = "left",
className = "",
disabled = false, disabled = false,
children, children,
...props ...props
@@ -43,7 +44,7 @@ export default function Button({
return ( return (
<button <button
className={`${styles[style]} ${variants[variant]} ${sizes[size]}`} className={`${styles[style]} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled} disabled={disabled}
{...props} {...props}
> >
+1
View File
@@ -15,6 +15,7 @@ export interface ButtonProps extends Omit<
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
icon?: string; // FontAwesome class like "fa-solid fa-cart-plus" icon?: string; // FontAwesome class like "fa-solid fa-cart-plus"
iconPosition?: "left" | "right"; iconPosition?: "left" | "right";
className?: string;
disabled?: boolean; disabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
-46
View File
@@ -1,46 +0,0 @@
"use client";
import type { ButtonProps } from "./Button.types";
export default function IconButton({
variant = "primary",
size = "md",
icon,
disabled = false,
className = "",
children,
style: _style,
...props
}: ButtonProps) {
const baseStyles =
"font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center";
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
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",
primaryNoBorder:
"bg-(--color-primary) text-white hover:bg-(--color-primary-dark) active:scale-95",
bgWhite:
"bg-white text-(--color-text-primary) hover:bg-gray-100 active:scale-95",
};
const sizes = {
sm: "h-8 w-8 text-sm",
md: "h-10 w-10 text-base",
lg: "h-12 w-12 text-lg",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{icon ? <i className={`fa-solid ${icon}`}></i> : children}
</button>
);
}
+2 -6
View File
@@ -3,17 +3,13 @@ export { Button } from "./buttons";
export type { ButtonProps } from "./buttons"; export type { ButtonProps } from "./buttons";
// Inputs // Inputs
export { TextInput, SearchInput, Textarea } from "./inputs"; export { TextInput, Textarea } from "./inputs";
export type { TextInputProps, SearchInputProps, TextareaProps } from "./inputs"; export type { TextInputProps, TextareaProps } from "./inputs";
// Typography // Typography
export { Heading, Text, Caption } from "./typography"; export { Heading, Text, Caption } from "./typography";
export type { HeadingProps, TextProps, CaptionProps } from "./typography"; export type { HeadingProps, TextProps, CaptionProps } from "./typography";
// Badges
export { Badge, PriceBadge } from "./badges";
export type { BadgeProps } from "./badges";
// Dividers // Dividers
export { Divider } from "./dividers"; export { Divider } from "./dividers";
export type { DividerProps } from "./dividers"; export type { DividerProps } from "./dividers";
-14
View File
@@ -13,17 +13,3 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
error?: string; error?: string;
className?: string; className?: string;
} }
export interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
onClear?: () => void;
className?: string;
}
export interface LoginInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
type: string;
name: string;
value: string;
errors?: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
-60
View File
@@ -1,60 +0,0 @@
import { useState } from "react";
import { LoginInputProps } from "./Input.types";
export default function LoginInput({
label,
type,
name,
value,
errors,
onChange,
...restProps
}: LoginInputProps) {
const [showPassword, setShowPassword] = useState(false);
function isPassword() {
if (type === "password") {
return (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
>
<i
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
></i>
</button>
);
}
return "";
}
return (
<div>
<label
htmlFor={name}
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
{label}
</label>
<div className="relative">
<i className="fa-solid fa-user absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
<input
id={name}
type={showPassword ? "text" : type}
value={value}
onChange={onChange}
placeholder={
type === "password"
? "Mật khẩu"
: "admin / số điện thoại / tên nhân viên"
}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) lg:pl-11 ${errors ? "border-red-400" : "border-(--color-border)"} `}
/>
{isPassword()}
</div>
</div>
);
}
-33
View File
@@ -1,33 +0,0 @@
"use client";
import type { SearchInputProps } from "./Input.types";
export default function SearchInput({
value,
onChange,
onClear,
className = "",
...props
}: SearchInputProps) {
return (
<div className="relative w-full">
<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={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 ${className}`}
{...props}
/>
{value && onClear && (
<button
type="button"
onClick={onClear}
className="absolute top-1/2 right-3 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark text-sm"></i>
</button>
)}
</div>
);
}
+1 -7
View File
@@ -1,9 +1,3 @@
export { default as TextInput } from "./TextInput"; export { default as TextInput } from "./TextInput";
export { default as SearchInput } from "./SearchInput";
export { default as Textarea } from "./Textarea"; export { default as Textarea } from "./Textarea";
export type { export type { TextInputProps, TextareaProps } from "./Input.types";
TextInputProps,
SearchInputProps,
TextareaProps,
LoginInputProps,
} from "./Input.types";
@@ -13,21 +13,20 @@ export default function PaymentSummaryCard({
isCustomer = false, isCustomer = false,
backHref, backHref,
}: PaymentSummaryCardProps) { }: PaymentSummaryCardProps) {
const [isReviewOpen, setIsReviewOpen] = useState(false);
const [isReviewOpen, setIsReviewOpen] = useState(false); const handlePayment = () => {
const handlePayment = () => {
// UI-only: open review modal after "payment" // UI-only: open review modal after "payment"
if (isCustomer) { if (isCustomer) {
setIsReviewOpen(true); setIsReviewOpen(true);
} }
}; };
return ( return (
<aside className="shrink-0 xl:w-85"> <aside className="shrink-0 xl:w-85">
<div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5"> <div className="bg-card sticky top-[calc(var(--spacing-header-height)+1rem)] rounded-2xl border border-(--color-border-light) p-4 md:p-5">
<h2 className="mb-4 text-lg font-bold">Hóa đơn</h2> <h2 className="mb-4 text-lg font-bold">Bill</h2>
<div className="flex items-center justify-between border-b border-(--color-border-light) pb-4"> <div className="flex items-center justify-between border-b border-(--color-border-light) pb-4">
<span className="text-(--color-text-muted)">Tổng cộng</span> <span className="text-(--color-text-muted)">Total</span>
<span className="text-xl font-bold text-(--color-primary)"> <span className="text-xl font-bold text-(--color-primary)">
{formatPrice(totalPrice)} {formatPrice(totalPrice)}
</span> </span>
@@ -41,7 +40,7 @@ export default function PaymentSummaryCard({
size="md" size="md"
variant="primary" variant="primary"
> >
Tiền mặt Cash
</Button> </Button>
<Button <Button
@@ -62,7 +61,7 @@ export default function PaymentSummaryCard({
size="md" size="md"
variant="primary" variant="primary"
> >
Đánh giá Review
</Button> </Button>
)} )}
@@ -78,7 +77,7 @@ export default function PaymentSummaryCard({
variant="secondary" variant="secondary"
className="w-full" className="w-full"
> >
Quay về Return
</Button> </Button>
</Link> </Link>
</div> </div>
+8 -2
View File
@@ -62,8 +62,14 @@ export default function ProductCard({
<Text variant="body2" className="font-bold"> <Text variant="body2" className="font-bold">
{formattedPrice} {formattedPrice}
</Text> </Text>
<Button onClick={onBuy} variant="primary" size="sm" icon="fa-cart-plus"> <Button
Mua onClick={onBuy}
variant="primary"
size="sm"
icon="fa-cart-plus"
aria-label={`Mua ${productName}`}
>
Buy
</Button> </Button>
</div> </div>
</div> </div>
+10 -3
View File
@@ -4,7 +4,10 @@ import type { ShiftSlot } from "@/lib/types";
import type { ShiftCardProps } from "./ShiftCard.types"; import type { ShiftCardProps } from "./ShiftCard.types";
const STATUS_STYLES: Record<ShiftSlot["status"], { bg: string; text: string; label: string }> = { const STATUS_STYLES: Record<
ShiftSlot["status"],
{ bg: string; text: string; label: string }
> = {
available: { available: {
bg: "bg-blue-50 border-blue-200", bg: "bg-blue-50 border-blue-200",
text: "text-blue-700", text: "text-blue-700",
@@ -34,7 +37,11 @@ function formatWage(wage: number): string {
return wage.toLocaleString("vi-VN"); return wage.toLocaleString("vi-VN");
} }
export default function ShiftCard({ shift, compact = false, onClick }: ShiftCardProps) { export default function ShiftCard({
shift,
compact = false,
onClick,
}: ShiftCardProps) {
const style = STATUS_STYLES[shift.status]; const style = STATUS_STYLES[shift.status];
if (compact) { if (compact) {
@@ -86,7 +93,7 @@ export default function ShiftCard({ shift, compact = false, onClick }: ShiftCard
{shift.registeredStaff.length > 0 && ( {shift.registeredStaff.length > 0 && (
<div className="mt-2 border-t border-current/10 pt-2"> <div className="mt-2 border-t border-current/10 pt-2">
<p className="text-[10px] font-medium uppercase tracking-wide opacity-60"> <p className="text-[10px] font-medium tracking-wide uppercase opacity-60">
Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff}) Nhân viên ({shift.registeredStaff.length}/{shift.maxStaff})
</p> </p>
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
+1 -1
View File
@@ -28,7 +28,7 @@ export default function ShopCard({ name, address, image }: ShopCardProps) {
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" 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> <i className="fa-solid fa-book-open text-[10px]"></i>
Xem menu View menu
</Link> </Link>
</div> </div>
@@ -17,6 +17,7 @@ export default function SearchBar({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
aria-label="Tìm kiếm món ă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="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 && ( {value && (
+8 -2
View File
@@ -16,10 +16,16 @@ interface BarChartProps {
* Tooltip auto-flips above/below bar top to stay inside the viewBox. * Tooltip auto-flips above/below bar top to stay inside the viewBox.
*/ */
export function BarChart({ current, previous, height = 200 }: BarChartProps) { export function BarChart({ current, previous, height = 200 }: BarChartProps) {
const [hovered, setHovered] = useState<{ set: "cur" | "prev"; idx: number } | null>(null); const [hovered, setHovered] = useState<{
set: "cur" | "prev";
idx: number;
} | null>(null);
const W = 800; const W = 800;
const H = height; const H = height;
const padL = 56, padR = 16, padT = 16, padB = 40; const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR; const chartW = W - padL - padR;
const chartH = H - padT - padB; const chartH = H - padT - padB;
+100 -22
View File
@@ -18,7 +18,10 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
const [hovered, setHovered] = useState<number | null>(null); const [hovered, setHovered] = useState<number | null>(null);
const W = 800; const W = 800;
const H = height; const H = height;
const padL = 56, padR = 16, padT = 16, padB = 40; const padL = 56,
padR = 16,
padT = 16,
padB = 40;
const chartW = W - padL - padR; const chartW = W - padL - padR;
const chartH = H - padT - padB; const chartH = H - padT - padB;
@@ -66,47 +69,122 @@ export function LineChart({ data, height = 200 }: LineChartProps) {
{gridLines.map((g, i) => ( {gridLines.map((g, i) => (
<g key={i}> <g key={i}>
<line x1={padL} y1={g.y} x2={W - padR} y2={g.y} stroke="#E2C9A8" strokeWidth="1" strokeDasharray={i === yTicks ? "0" : "4 3"} /> <line
<text x={padL - 6} y={g.y + 4} textAnchor="end" fontSize="10" fill="#A08060">{formatCurrency(g.val)}</text> x1={padL}
y1={g.y}
x2={W - padR}
y2={g.y}
stroke="#E2C9A8"
strokeWidth="1"
strokeDasharray={i === yTicks ? "0" : "4 3"}
/>
<text
x={padL - 6}
y={g.y + 4}
textAnchor="end"
fontSize="10"
fill="#A08060"
>
{formatCurrency(g.val)}
</text>
</g> </g>
))} ))}
<path d={areaD} fill="url(#areaGrad)" /> <path d={areaD} fill="url(#areaGrad)" />
<path d={pathD} fill="none" stroke="#6F4E37" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" /> <path
d={pathD}
fill="none"
stroke="#6F4E37"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map((p, i) => {points.map((p, i) =>
i % step === 0 ? ( i % step === 0 ? (
<text key={i} x={p.x} y={H - 8} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.label}</text> <text
key={i}
x={p.x}
y={H - 8}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.label}
</text>
) : null, ) : null,
)} )}
{points.map((p) => ( {points.map((p) => (
<circle <circle
key={p.index} key={p.index}
cx={p.x} cy={p.y} cx={p.x}
cy={p.y}
r={hovered === p.index ? 5 : 3} r={hovered === p.index ? 5 : 3}
fill={hovered === p.index ? "#C8973A" : "#6F4E37"} fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
stroke="#FDF6EC" strokeWidth="2" stroke="#FDF6EC"
strokeWidth="2"
style={{ cursor: "pointer", transition: "r 150ms" }} style={{ cursor: "pointer", transition: "r 150ms" }}
onMouseEnter={() => setHovered(p.index)} onMouseEnter={() => setHovered(p.index)}
/> />
))} ))}
{hovered !== null && (() => { {hovered !== null &&
const p = points[hovered]; (() => {
const tipW = 120, tipH = 48; const p = points[hovered];
const tipX = Math.min(Math.max(p.x - tipW / 2, padL), W - padR - tipW); const tipW = 120,
const aboveY = p.y - tipH - 10; tipH = 48;
const tipY = Math.min(Math.max(aboveY >= padT ? aboveY : p.y + 10, padT), padT + chartH - tipH); const tipX = Math.min(
return ( Math.max(p.x - tipW / 2, padL),
<g> W - padR - tipW,
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="6" fill="#3D2B1F" opacity="0.92" /> );
<text x={tipX + tipW / 2} y={tipY + 16} textAnchor="middle" fontSize="10" fill="#F0D9A8">{p.data.label}</text> const aboveY = p.y - tipH - 10;
<text x={tipX + tipW / 2} y={tipY + 30} textAnchor="middle" fontSize="11" fontWeight="600" fill="#C8973A">{formatCurrency(p.data.revenue)}</text> const tipY = Math.min(
<text x={tipX + tipW / 2} y={tipY + 44} textAnchor="middle" fontSize="10" fill="#A08060">{p.data.orders} đơn</text> Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
</g> padT + chartH - tipH,
); );
})()} return (
<g>
<rect
x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx="6"
fill="#3D2B1F"
opacity="0.92"
/>
<text
x={tipX + tipW / 2}
y={tipY + 16}
textAnchor="middle"
fontSize="10"
fill="#F0D9A8"
>
{p.data.label}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 30}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="#C8973A"
>
{formatCurrency(p.data.revenue)}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 44}
textAnchor="middle"
fontSize="10"
fill="#A08060"
>
{p.data.orders} đơn
</text>
</g>
);
})()}
</svg> </svg>
</div> </div>
); );
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
export interface PieSlice { export interface PieSlice {
label: string; label: string;
+75 -75
View File
@@ -1,122 +1,122 @@
"use client";
import Button from "@/components/atoms/buttons/Button";
import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin"; import ErrorMessageLogin from "@/components/atoms/errors/ErrorMessageLogin";
import LoginInput from "@/components/atoms/inputs/LoginInput";
import { useAuth } from "@/lib/auth-context";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import Button from "@/components/atoms/buttons/Button";
const PHONE_REGEX = /^(0[35789])[0-9]{8}$/;
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
const { login } = useAuth();
const [username, setUsername] = useState(""); const [phone, setPhone] = useState("");
const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({ phone: "", general: "" });
username: "",
password: "",
general: "",
});
const validate = (): boolean => { const validate = (): boolean => {
const newErrors = { username: "", password: "", general: "" }; if (!phone.trim()) {
let isValid = true; setErrors({ phone: "Please enter your phone number", general: "" });
return false;
if (!username.trim()) {
newErrors.username = "Vui lòng nhập tên đăng nhập";
isValid = false;
} }
if (!PHONE_REGEX.test(phone)) {
if (!password.trim()) { setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
newErrors.password = "Vui lòng nhập mật khẩu"; return false;
isValid = false;
} else if (password.length < 4) {
newErrors.password = "Mật khẩu phải có ít nhất 4 ký tự";
isValid = false;
} }
return true;
setErrors(newErrors);
return isValid;
}; };
const handleSubmit = (e: FormEvent) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!validate()) return; if (!validate()) return;
const success = login(username, password); setIsLoading(true);
setErrors({ phone: "", general: "" });
if (success) { try {
router.push("/"); const res = await fetch("/api/sms_otp", {
} else { method: "POST",
setErrors({ headers: { "Content-Type": "application/json" },
username: "", body: JSON.stringify({ phone }),
password: "",
general: "Tên đăng nhập hoặc mật khẩu không đúng",
}); });
const role = (await res.text().catch(() => "")).trim();
if (res.ok && (role === "customer" || role === "manager")) {
sessionStorage.setItem("login_phone", phone);
sessionStorage.setItem("login_role", role);
router.push(role === "manager" ? "/login/password" : "/login/otp");
} else if (res.status === 404) {
setErrors({ phone: "", general: "Phone number not registered" });
} else {
setErrors({ phone: "", general: "An error occurred, please try again" });
}
} catch {
setErrors({ phone: "", general: "Unable to connect, please try again" });
} finally {
setIsLoading(false);
} }
}; };
return ( return (
<div> <div>
{/* Error Message */}
{errors.general && <ErrorMessageLogin message={errors.general} />} {errors.general && <ErrorMessageLogin message={errors.general} />}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
{/* Username Input */} <label
<LoginInput htmlFor="phone"
label="Tên đăng nhập" className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
type="text" >
name="username" Phone number
value={username} </label>
onChange={(e) => { <div className="relative">
setUsername(e.target.value); <i className="fa-solid fa-phone absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block"></i>
setErrors({ ...errors, username: "", general: "" }); <input
}} id="phone"
errors={errors.username} type="tel"
/> value={phone}
{errors.username && ( onChange={(e) => {
<ErrorMessageLogin message={errors.username} type="secondary" /> setPhone(e.target.value);
setErrors({ phone: "", general: "" });
}}
placeholder="0987654321"
disabled={isLoading}
className={`text-foreground focus:ring-opacity-20 w-full rounded-xl border bg-white px-10 py-3 transition-all duration-150 outline-none placeholder:text-(--color-text-muted) focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary) disabled:opacity-60 lg:pl-11 ${errors.phone ? "border-red-400" : "border-(--color-border)"}`}
/>
</div>
{errors.phone && (
<ErrorMessageLogin message={errors.phone} type="secondary" />
)} )}
<p className="mt-2 text-xs text-(--color-text-muted)">
Enter a Vietnamese phone number (10 digits, starting with 0)
</p>
</div> </div>
{/* Password Input */}
<div>
<LoginInput
label="Mật khẩu"
type="password"
name="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({ ...errors, password: "", general: "" });
}}
errors={errors.password}
/>
{errors.password && (
<ErrorMessageLogin message={errors.password} type="secondary" />
)}
</div>
{/* Buttons */}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{/* Login Button */}
<Button <Button
variant="primaryNoBorder" variant="primaryNoBorder"
type="submit" type="submit"
style="login" style="login"
size="lg" size="lg"
disabled={isLoading}
> >
Đăng nhập {isLoading ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Processing...
</>
) : (
"Continue"
)}
</Button> </Button>
{/* Register Button */}
<Link <Link
href="/register" href="/register"
className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98" className="flex w-full items-center justify-center rounded-xl border-2 border-(--color-primary) bg-white py-3 font-semibold text-(--color-primary) no-underline transition-all duration-150 hover:bg-(--color-primary) hover:text-white active:scale-98"
> >
Đăng tài khoản Create account
</Link> </Link>
</div> </div>
</form> </form>
@@ -23,15 +23,15 @@ export default function CategoriesTab() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{categories.length}</strong> danh <strong className="text-foreground">{categories.length}</strong>{" "}
mục categories
</p> </p>
<button <button
onClick={() => setModalCategory("new")} 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" 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> <i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm danh mục</span> <span className="hidden sm:inline">Add category</span>
</button> </button>
</div> </div>
@@ -50,19 +50,19 @@ export default function CategoriesTab() {
<p className="text-foreground truncate font-semibold"> <p className="text-foreground truncate font-semibold">
{cat.name} {cat.name}
</p> </p>
<p className="text-xs text-(--color-text-muted)">{count} món</p> <p className="text-xs text-(--color-text-muted)">{count} items</p>
</div> </div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button <button
onClick={() => setModalCategory(cat)} onClick={() => setModalCategory(cat)}
title="Chỉnh sửa" title="Edit"
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)" 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> <i className="fa-solid fa-pen text-[11px]"></i>
</button> </button>
<button <button
onClick={() => setDeleteTarget(cat)} onClick={() => setDeleteTarget(cat)}
title="Xóa" title="Delete"
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" 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> <i className="fa-solid fa-trash text-[11px]"></i>
@@ -75,7 +75,7 @@ export default function CategoriesTab() {
{categories.length === 0 && ( {categories.length === 0 && (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)"> <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> <i className="fa-solid fa-tag text-4xl opacity-30"></i>
<p className="text-sm">Chưa danh mục nào</p> <p className="text-sm">No categories yet</p>
</div> </div>
)} )}
</div> </div>
@@ -52,7 +52,7 @@ export default function CategoryModal({
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl"> <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"> <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"> <h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa danh mục" : "Thêm danh mục mới"} {isEdit ? "Edit category" : "Add new category"}
</h2> </h2>
<button <button
title="Close" title="Close"
@@ -66,7 +66,7 @@ export default function CategoryModal({
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5"> <form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div> <div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)"> <label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên danh mục <span className="text-red-500">*</span> Category name <span className="text-red-500">*</span>
</label> </label>
<input <input
required required
@@ -74,7 +74,7 @@ export default function CategoryModal({
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} 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" 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ê" placeholder="e.g. Coffee"
/> />
</div> </div>
@@ -0,0 +1,265 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { addManagerMenuItem, gqlFetch } from "@/lib/graphql";
import { useCallback, useEffect, useState } from "react";
interface MenuItem {
id: string;
name: string;
price: number;
}
interface Eatery {
id: string;
ownerId: string;
}
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function MenuItemsTab() {
const { user } = useAuth();
const [eateryId, setEateryId] = useState<string | null>(null);
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
const [newPrice, setNewPrice] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const loadEateryId = useCallback(async (): Promise<string | null> => {
if (!user) return null;
const data = await gqlFetch<{ allEateries: Eatery[] }>(
"{ allEateries { id ownerId } }",
);
return (
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
);
}, [user]);
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
`query($eateryId: String!) {
menuItemsByEatery(eateryId: $eateryId) { id name price }
}`,
{ eateryId: id },
);
return data.menuItemsByEatery ?? [];
}, []);
useEffect(() => {
async function init() {
setLoading(true);
setError(null);
try {
const id = await loadEateryId();
setEateryId(id);
if (id) setMenuItems(await loadMenuItems(id));
else setMenuItems([]);
} catch {
setError("Không thể tải dữ liệu menu. Kiểm tra kết nối backend.");
} finally {
setLoading(false);
}
}
init();
}, [loadEateryId, loadMenuItems]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newPrice) return;
setSubmitting(true);
setSubmitError(null);
try {
const added = await addManagerMenuItem(newName.trim(), parseFloat(newPrice));
if (added) setMenuItems((prev) => [...prev, added]);
setNewName("");
setNewPrice("");
setShowForm(false);
} catch {
setSubmitError("Không thể thêm món. Vui lòng thử lại.");
} finally {
setSubmitting(false);
}
};
const closeForm = () => {
setShowForm(false);
setSubmitError(null);
setNewName("");
setNewPrice("");
};
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
Đang tải menu...
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-red-500">
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
<p className="text-sm">{error}</p>
</div>
);
}
if (!eateryId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
<p className="text-sm">Quán của bạn chưa đưc khởi tạo trong hệ thống.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{menuItems.length}</strong> món trong menu
</p>
<button
onClick={() => setShowForm(true)}
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>
{/* 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-right font-semibold text-(--color-text-secondary)">
Giá
</th>
</tr>
</thead>
<tbody className="divide-y divide-(--color-border-light)">
{menuItems.length === 0 ? (
<tr>
<td
colSpan={2}
className="py-12 text-center text-(--color-text-muted)"
>
<i className="fa-solid fa-bowl-food mb-2 block text-3xl opacity-30"></i>
Chưa món nào trong menu
</td>
</tr>
) : (
menuItems.map((item) => (
<tr
key={item.id}
className="hover:bg-background transition-colors"
>
<td className="px-4 py-3">
<p className="text-foreground font-medium">{item.name}</p>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(item.price)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Add Item Modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-foreground font-bold">Thêm món mới</h2>
<button
onClick={closeForm}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-(--color-text-muted) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Tên món <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nhập tên món..."
required
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-(--color-text-secondary)">
Giá (VNĐ) <span className="text-red-500">*</span>
</label>
<input
type="number"
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="Nhập giá..."
required
min={0}
step={500}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
{submitError && (
<p className="text-sm text-red-500">
<i className="fa-solid fa-circle-exclamation mr-1"></i>
{submitError}
</p>
)}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={closeForm}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
>
Hủy
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) disabled:opacity-60"
>
{submitting ? (
<>
<i className="fa-solid fa-spinner mr-1 animate-spin"></i>
Đang lưu...
</>
) : (
"Thêm món"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+4 -1
View File
@@ -152,7 +152,10 @@ export default function ProductsTab() {
</tr> </tr>
) : ( ) : (
filtered.map((p) => ( filtered.map((p) => (
<tr key={p.id} className="hover:bg-background transition-colors"> <tr
key={p.id}
className="hover:bg-background transition-colors"
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div> <div>
<p className="text-foreground font-medium">{p.name}</p> <p className="text-foreground font-medium">{p.name}</p>
+1 -1
View File
@@ -14,7 +14,7 @@ export default function StatusBadge({ available }: StatusBadgeProps) {
available ? "bg-emerald-500" : "bg-amber-500" available ? "bg-emerald-500" : "bg-amber-500"
}`} }`}
/> />
{available ? "Còn hàng" : "Tạm hết"} {available ? "In stock" : "Out of stock"}
</span> </span>
); );
} }
+1
View File
@@ -6,6 +6,7 @@ export { default as ComboModal } from "./ComboModal";
export { default as ProductsTab } from "./ProductsTab"; export { default as ProductsTab } from "./ProductsTab";
export { default as CategoriesTab } from "./CategoriesTab"; export { default as CategoriesTab } from "./CategoriesTab";
export { default as CombosTab } from "./CombosTab"; export { default as CombosTab } from "./CombosTab";
export { default as MenuItemsTab } from "./MenuItemsTab";
export type { export type {
ProductModalProps, ProductModalProps,
CategoryModalProps, CategoryModalProps,
+13 -13
View File
@@ -49,13 +49,13 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
<i className="fa-solid fa-heart text-(--color-accent)"></i> <i className="fa-solid fa-heart text-(--color-accent)"></i>
</div> </div>
<Heading level={2} id="review-modal-title"> <Heading level={2} id="review-modal-title">
Cảm ơn quý khách Thank you
</Heading> </Heading>
<Text variant="body2" className="mt-2"> <Text variant="body2" className="mt-2">
Chúng tôi trân trọng đánh giá của bạn! We appreciate your feedback!
</Text> </Text>
<Button onClick={handleClose} variant="primary" className="mt-4"> <Button onClick={handleClose} variant="primary" className="mt-4">
Đóng Close
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -65,21 +65,21 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
id="review-modal-title" id="review-modal-title"
className="text-foreground mb-1 text-xl font-bold" className="text-foreground mb-1 text-xl font-bold"
> >
Đánh giá của bạn Your Review
</h2> </h2>
<p className="mb-5 text-sm text-(--color-text-muted)"> <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 Tell us about your experience today
</p> </p>
{/* Star rating */} {/* Star rating */}
<div className="mb-5"> <div className="mb-5">
<p className="mb-2 text-sm font-medium text-(--color-text-secondary)"> <p className="mb-2 text-sm font-medium text-(--color-text-secondary)">
Mức đ hài lòng Satisfaction level
</p> </p>
<div <div
className="flex gap-2" className="flex gap-2"
role="radiogroup" role="radiogroup"
aria-label="Xếp hạng sao" aria-label="Star rating"
> >
{[1, 2, 3, 4, 5].map((star) => { {[1, 2, 3, 4, 5].map((star) => {
const isActive = star <= (hovered || rating); const isActive = star <= (hovered || rating);
@@ -90,7 +90,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
onClick={() => setRating(star)} onClick={() => setRating(star)}
onMouseEnter={() => setHovered(star)} onMouseEnter={() => setHovered(star)}
onMouseLeave={() => setHovered(0)} onMouseLeave={() => setHovered(0)}
aria-label={`${star} sao`} aria-label={`${star} star`}
aria-pressed={rating === star} aria-pressed={rating === star}
className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl" className="text-3xl transition-transform hover:scale-110 active:scale-95 sm:text-4xl"
> >
@@ -108,7 +108,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
{rating > 0 && ( {rating > 0 && (
<p className="mt-1.5 text-xs text-(--color-text-muted)"> <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"][ ["", "Very poor", "Poor", "Average", "Good", "Excellent"][
rating rating
] ]
} }
@@ -120,10 +120,10 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
<div className="mb-6"> <div className="mb-6">
<Textarea <Textarea
id="review-text" id="review-text"
label="Nhận xét (tùy chọn)" label="Comment (optional)"
value={review} value={review}
onChange={(e) => setReview(e.target.value)} onChange={(e) => setReview(e.target.value)}
placeholder="Chia sẻ cảm nhận của bạn về đồ uống, dịch vụ..." placeholder="Share your thoughts on the drinks, service..."
rows={4} rows={4}
/> />
</div> </div>
@@ -137,7 +137,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
className="flex-1" className="flex-1"
icon="fa-arrow-left" icon="fa-arrow-left"
> >
Quay lại Go back
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -146,7 +146,7 @@ export default function ReviewModal({ isOpen, onClose }: ReviewModalProps) {
className="flex-1" className="flex-1"
icon="fa-check" icon="fa-check"
> >
Xác nhận Confirm
</Button> </Button>
</div> </div>
</> </>
@@ -47,6 +47,7 @@ export default function CategorySidebar({
<button <button
onClick={onToggle} onClick={onToggle}
title={isOpen ? "Thu gọn menu" : "Mở rộng menu"} title={isOpen ? "Thu gọn menu" : "Mở rộng menu"}
aria-label={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" 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 <i
@@ -7,7 +7,7 @@ import { useMemo, useState } from "react";
import type { MobileShiftViewProps } from "./ShiftSchedule.types"; import type { MobileShiftViewProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"]; const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function formatDateISO(d: Date): string { function formatDateISO(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
@@ -26,14 +26,27 @@ function isToday(d: Date): boolean {
} }
const MONTH_NAMES = [ const MONTH_NAMES = [
"Tháng 1", "Tháng 2", "Tháng 3", "Tháng 4", "January",
"Tháng 5", "Tháng 6", "Tháng 7", "Tháng 8", "February",
"Tháng 9", "Tháng 10", "Tháng 11", "Tháng 12", "March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]; ];
export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps) { export default function MobileShiftView({
onShiftClick,
}: MobileShiftViewProps) {
const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift(); const { currentDate, shifts, goToNextMonth, goToPrevMonth } = useShift();
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(new Date(2026, 3, 10))); const [selectedDate, setSelectedDate] = useState<string>(
formatDateISO(new Date(2026, 3, 10)),
);
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
@@ -57,9 +70,12 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
const dateStr = formatDateISO(date); const dateStr = formatDateISO(date);
const dayShifts = shifts.filter((s) => s.date === dateStr); const dayShifts = shifts.filter((s) => s.date === dateStr);
const dots: string[] = []; const dots: string[] = [];
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-amber-400"); if (dayShifts.some((s) => s.status === "available"))
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-green-500"); dots.push("bg-amber-400");
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-400"); if (dayShifts.some((s) => s.status === "registered"))
dots.push("bg-green-500");
if (dayShifts.some((s) => s.status === "approved_leave"))
dots.push("bg-purple-400");
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400"); if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-red-400");
return dots; return dots;
}; };
@@ -78,18 +94,18 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
{/* Month navigation */} {/* Month navigation */}
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<button <button
title="Trở lại tháng trước" title="Previous month"
type="button" type="button"
onClick={goToPrevMonth} onClick={goToPrevMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
> >
<i className="fa-solid fa-chevron-left text-xs"></i> <i className="fa-solid fa-chevron-left text-xs"></i>
</button> </button>
<h3 className="text-sm font-bold text-foreground"> <h3 className="text-foreground text-sm font-bold">
{MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()} {MONTH_NAMES[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3> </h3>
<button <button
title="Trở lại tháng sau" title="Next month"
type="button" type="button"
onClick={goToNextMonth} onClick={goToNextMonth}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
@@ -101,7 +117,10 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
{/* Day headers */} {/* Day headers */}
<div className="mb-1 grid grid-cols-7"> <div className="mb-1 grid grid-cols-7">
{DAY_HEADERS.map((day) => ( {DAY_HEADERS.map((day) => (
<div key={day} className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"> <div
key={day}
className="py-1 text-center text-[10px] font-semibold text-(--color-text-muted) uppercase"
>
{day} {day}
</div> </div>
))} ))}
@@ -133,7 +152,7 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
today today
? "bg-(--color-primary) font-bold text-white" ? "bg-(--color-primary) font-bold text-white"
: selected : selected
? "font-bold text-foreground" ? "text-foreground font-bold"
: "text-foreground" : "text-foreground"
}`} }`}
> >
@@ -153,53 +172,68 @@ export default function MobileShiftView({ onShiftClick }: MobileShiftViewProps)
<div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2"> <div className="mt-3 flex flex-wrap justify-center gap-3 border-t border-(--color-border-light) pt-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-amber-400"></span> <span className="h-2 w-2 rounded-full bg-amber-400"></span>
<span className="text-[10px] text-(--color-text-muted)">Còn trống</span> <span className="text-[10px] text-(--color-text-muted)">
Available
</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500"></span> <span className="h-2 w-2 rounded-full bg-green-500"></span>
<span className="text-[10px] text-(--color-text-muted)">Đã ĐK</span> <span className="text-[10px] text-(--color-text-muted)">Registered</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-purple-400"></span> <span className="h-2 w-2 rounded-full bg-purple-400"></span>
<span className="text-[10px] text-(--color-text-muted)">Nghỉ phép</span> <span className="text-[10px] text-(--color-text-muted)">
On leave
</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-red-400"></span> <span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-(--color-text-muted)">Vắng</span> <span className="text-[10px] text-(--color-text-muted)">Absent</span>
</div> </div>
</div> </div>
</div> </div>
{/* Selected day shifts */} {/* Selected day shifts */}
<div> <div>
<h3 className="mb-3 text-sm font-bold text-foreground"> <h3 className="text-foreground mb-3 text-sm font-bold">
{dayOfWeek}, {selectedDateObj.getDate()}/{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()} {dayOfWeek}, {selectedDateObj.getDate()}/
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<span className="ml-2 text-xs font-normal text-(--color-text-muted)"> <span className="ml-2 text-xs font-normal text-(--color-text-muted)">
({selectedShifts.length} ca) ({selectedShifts.length} shift{selectedShifts.length !== 1 ? "s" : ""})
</span> </span>
</h3> </h3>
{selectedShifts.length === 0 ? ( {selectedShifts.length === 0 ? (
<div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center"> <div className="rounded-xl border border-dashed border-(--color-border-light) py-8 text-center">
<i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i> <i className="fa-regular fa-calendar-xmark mb-2 text-2xl text-gray-300"></i>
<p className="text-sm text-(--color-text-muted)">Không ca làm trong ngày này</p> <p className="text-sm text-(--color-text-muted)">
No shifts for this day
</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{DEPARTMENTS.map((dept) => { {DEPARTMENTS.map((dept) => {
const deptShifts = selectedShifts.filter((s) => s.department === dept.id); const deptShifts = selectedShifts.filter(
(s) => s.department === dept.id,
);
if (deptShifts.length === 0) return null; if (deptShifts.length === 0) return null;
return ( return (
<div key={dept.id}> <div key={dept.id}>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i> <i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)"> <span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name} {dept.name}
</span> </span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{deptShifts.map((shift) => ( {deptShifts.map((shift) => (
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} /> <ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))} ))}
</div> </div>
</div> </div>
@@ -5,7 +5,7 @@ import { useMemo } from "react";
import type { MonthlyCalendarProps } from "./ShiftSchedule.types"; import type { MonthlyCalendarProps } from "./ShiftSchedule.types";
const DAY_HEADERS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"]; const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function formatDateISO(d: Date): string { function formatDateISO(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
@@ -23,7 +23,10 @@ function isToday(d: Date): boolean {
); );
} }
export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyCalendarProps) { export default function MonthlyCalendar({
onShiftClick,
onDateSelect,
}: MonthlyCalendarProps) {
const { currentDate, shifts } = useShift(); const { currentDate, shifts } = useShift();
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
@@ -60,7 +63,9 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
const dateStr = formatDateISO(date); const dateStr = formatDateISO(date);
const dayShifts = shifts.filter((s) => s.date === dateStr); const dayShifts = shifts.filter((s) => s.date === dateStr);
const available = dayShifts.filter((s) => s.status === "available").length; const available = dayShifts.filter((s) => s.status === "available").length;
const registered = dayShifts.filter((s) => s.status === "registered").length; const registered = dayShifts.filter(
(s) => s.status === "registered",
).length;
const leave = dayShifts.filter((s) => s.status === "approved_leave").length; const leave = dayShifts.filter((s) => s.status === "approved_leave").length;
const absent = dayShifts.filter((s) => s.status === "absent").length; const absent = dayShifts.filter((s) => s.status === "absent").length;
return { total: dayShifts.length, available, registered, leave, absent }; return { total: dayShifts.length, available, registered, leave, absent };
@@ -87,7 +92,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
return ( return (
<div <div
key={`empty-${i}`} key={`empty-${i}`}
className="min-h-25 border-b border-r border-(--color-border-light) bg-gray-50/30" className="min-h-25 border-r border-b border-(--color-border-light) bg-gray-50/30"
/> />
); );
} }
@@ -100,7 +105,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
type="button" type="button"
key={i} key={i}
onClick={() => onDateSelect?.(formatDateISO(date))} onClick={() => onDateSelect?.(formatDateISO(date))}
className={`min-h-25 cursor-pointer border-b border-r border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${ className={`min-h-25 cursor-pointer border-r border-b border-(--color-border-light) bg-transparent p-2 text-left transition hover:bg-gray-50 ${
today ? "bg-(--color-primary)/5" : "" today ? "bg-(--color-primary)/5" : ""
}`} }`}
> >
@@ -108,7 +113,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${ className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm ${
today today
? "bg-(--color-primary) font-bold text-white" ? "bg-(--color-primary) font-bold text-white"
: "font-medium text-foreground" : "text-foreground font-medium"
}`} }`}
> >
{date.getDate()} {date.getDate()}
@@ -120,7 +125,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-blue-400"></span> <span className="h-2 w-2 rounded-full bg-blue-400"></span>
<span className="text-[10px] text-blue-600"> <span className="text-[10px] text-blue-600">
{summary.available} trống {summary.available} available
</span> </span>
</div> </div>
)} )}
@@ -128,7 +133,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-blue-700"></span> <span className="h-2 w-2 rounded-full bg-blue-700"></span>
<span className="text-[10px] text-blue-800"> <span className="text-[10px] text-blue-800">
{summary.registered} đã ĐK {summary.registered} registered
</span> </span>
</div> </div>
)} )}
@@ -136,7 +141,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-purple-400"></span> <span className="h-2 w-2 rounded-full bg-purple-400"></span>
<span className="text-[10px] text-purple-600"> <span className="text-[10px] text-purple-600">
{summary.leave} nghỉ {summary.leave} on leave
</span> </span>
</div> </div>
)} )}
@@ -144,7 +149,7 @@ export default function MonthlyCalendar({ onShiftClick, onDateSelect }: MonthlyC
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-red-400"></span> <span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-red-600"> <span className="text-[10px] text-red-600">
{summary.absent} vắng {summary.absent} absent
</span> </span>
</div> </div>
)} )}
@@ -6,7 +6,11 @@ import { useEffect, useState } from "react";
import type { ShiftCreateModalProps } from "./ShiftSchedule.types"; import type { ShiftCreateModalProps } from "./ShiftSchedule.types";
export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: ShiftCreateModalProps) { export default function ShiftCreateModal({
isOpen,
onClose,
defaultDate,
}: ShiftCreateModalProps) {
const { createShift } = useShift(); const { createShift } = useShift();
const [date, setDate] = useState(defaultDate ?? "2026-04-10"); const [date, setDate] = useState(defaultDate ?? "2026-04-10");
@@ -31,7 +35,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
// Validate // Validate
if (!date || !startTime || !endTime) { if (!date || !startTime || !endTime) {
setError("Vui lòng điền đầy đủ thông tin."); setError("Please fill in all required fields.");
return; return;
} }
@@ -41,7 +45,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
const endMinutes = eh * 60 + em; const endMinutes = eh * 60 + em;
if (endMinutes <= startMinutes) { if (endMinutes <= startMinutes) {
setError("Giờ kết thúc phải sau giờ bắt đầu."); setError("End time must be after start time.");
return; return;
} }
@@ -72,11 +76,11 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4"> <div className="flex items-center justify-between border-b border-(--color-border-light) px-5 py-4">
<div> <div>
<h2 className="text-base font-bold text-foreground"> <h2 className="text-foreground text-base font-bold">
Tạo ca làm mới Create New Shift
</h2> </h2>
<p className="text-xs text-(--color-text-muted)"> <p className="text-xs text-(--color-text-muted)">
Thêm khung giờ ca làm cho nhân viên Add a shift time slot for staff
</p> </p>
</div> </div>
<button <button
@@ -94,14 +98,14 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
{/* Date */} {/* Date */}
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Ngày Date
</label> </label>
<input <input
title="Date" title="Date"
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
@@ -109,26 +113,26 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Giờ bắt đu Start Time
</label> </label>
<input <input
title="Start Time" title="Start Time"
type="time" type="time"
value={startTime} value={startTime}
onChange={(e) => setStartTime(e.target.value)} onChange={(e) => setStartTime(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)"> <label className="mb-1 block text-xs font-semibold text-(--color-text-secondary)">
Giờ kết thúc End Time
</label> </label>
<input <input
title="End Time" title="End Time"
type="time" type="time"
value={endTime} value={endTime}
onChange={(e) => setEndTime(e.target.value)} onChange={(e) => setEndTime(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
</div> </div>
@@ -142,7 +146,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
title="Department" title="Department"
value={department} value={department}
onChange={(e) => setDepartment(e.target.value)} onChange={(e) => setDepartment(e.target.value)}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
> >
{DEPARTMENTS.map((dept) => ( {DEPARTMENTS.map((dept) => (
<option key={dept.id} value={dept.id}> <option key={dept.id} value={dept.id}>
@@ -165,7 +169,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
max={10} max={10}
value={maxStaff} value={maxStaff}
onChange={(e) => setMaxStaff(Number(e.target.value))} onChange={(e) => setMaxStaff(Number(e.target.value))}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
<div> <div>
@@ -179,7 +183,7 @@ export default function ShiftCreateModal({ isOpen, onClose, defaultDate }: Shift
step={10000} step={10000}
value={wage} value={wage}
onChange={(e) => setWage(Number(e.target.value))} onChange={(e) => setWage(Number(e.target.value))}
className="w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)" className="text-foreground w-full rounded-xl border border-(--color-border-light) px-3 py-2.5 text-sm transition outline-none focus:border-(--color-primary) focus:ring-1 focus:ring-(--color-primary)"
/> />
</div> </div>
</div> </div>
@@ -7,7 +7,11 @@ import { useState } from "react";
import type { ShiftDetailModalProps } from "./ShiftSchedule.types"; import type { ShiftDetailModalProps } from "./ShiftSchedule.types";
export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetailModalProps) { export default function ShiftDetailModal({
shift,
isOpen,
onClose,
}: ShiftDetailModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const { registerShift, unregisterShift, deleteShift } = useShift(); const { registerShift, unregisterShift, deleteShift } = useShift();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -17,7 +21,9 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
const dept = DEPARTMENTS.find((d) => d.id === shift.department); const dept = DEPARTMENTS.find((d) => d.id === shift.department);
const isManager = user?.role === "manager"; const isManager = user?.role === "manager";
const isRegistered = user ? shift.registeredStaff.some((s) => s.id === user.id) : false; const isRegistered = user
? shift.registeredStaff.some((s) => s.id === user.id)
: false;
const isFull = shift.registeredStaff.length >= shift.maxStaff; const isFull = shift.registeredStaff.length >= shift.maxStaff;
const handleRegister = () => { const handleRegister = () => {
@@ -68,10 +74,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */} {/* Backdrop */}
<div <div className="absolute inset-0 bg-black/40" onClick={onClose} />
className="absolute inset-0 bg-black/40"
onClick={onClose}
/>
{/* Modal */} {/* Modal */}
<div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl"> <div className="relative w-full max-w-md rounded-2xl bg-white shadow-xl">
@@ -80,7 +83,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{dept && <i className={`${dept.icon} text-(--color-primary)`}></i>} {dept && <i className={`${dept.icon} text-(--color-primary)`}></i>}
<div> <div>
<h2 className="text-base font-bold text-foreground"> <h2 className="text-foreground text-base font-bold">
Chi tiết ca làm Chi tiết ca làm
</h2> </h2>
<p className="text-xs text-(--color-text-muted)">{dept?.name}</p> <p className="text-xs text-(--color-text-muted)">{dept?.name}</p>
@@ -100,7 +103,9 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
{/* Status badge */} {/* Status badge */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}> <span
className={`rounded-full px-3 py-1 text-xs font-semibold ${statusColor[shift.status]}`}
>
{statusLabel[shift.status]} {statusLabel[shift.status]}
</span> </span>
</div> </div>
@@ -108,30 +113,41 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
{/* Shift info grid */} {/* Shift info grid */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Ngày</p> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
<p className="mt-1 text-sm font-bold text-foreground"> Ngày
{new Date(shift.date + "T00:00:00").toLocaleDateString("vi-VN", { </p>
weekday: "long", <p className="text-foreground mt-1 text-sm font-bold">
day: "2-digit", {new Date(shift.date + "T00:00:00").toLocaleDateString(
month: "2-digit", "vi-VN",
year: "numeric", {
})} weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
},
)}
</p> </p>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Giờ làm</p> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
<p className="mt-1 text-sm font-bold text-foreground"> Giờ làm
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.startTime} {shift.endTime} {shift.startTime} {shift.endTime}
</p> </p>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Thời lượng</p> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
<p className="mt-1 text-sm font-bold text-foreground"> Thời lượng
</p>
<p className="text-foreground mt-1 text-sm font-bold">
{shift.durationHours} giờ {shift.durationHours} giờ
</p> </p>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3"> <div className="rounded-xl bg-gray-50 p-3">
<p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">Lương ca</p> <p className="text-[10px] font-semibold text-(--color-text-muted) uppercase">
Lương ca
</p>
<p className="mt-1 text-sm font-bold text-(--color-primary)"> <p className="mt-1 text-sm font-bold text-(--color-primary)">
{shift.wage.toLocaleString("vi-VN")} VND {shift.wage.toLocaleString("vi-VN")} VND
</p> </p>
@@ -141,10 +157,13 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
{/* Registered staff */} {/* Registered staff */}
<div> <div>
<p className="mb-2 text-xs font-semibold text-(--color-text-secondary)"> <p className="mb-2 text-xs font-semibold text-(--color-text-secondary)">
Nhân viên đã đăng ({shift.registeredStaff.length}/{shift.maxStaff}) Nhân viên đã đăng ({shift.registeredStaff.length}/
{shift.maxStaff})
</p> </p>
{shift.registeredStaff.length === 0 ? ( {shift.registeredStaff.length === 0 ? (
<p className="text-xs italic text-(--color-text-muted)">Chưa ai đăng </p> <p className="text-xs text-(--color-text-muted) italic">
Chưa ai đăng
</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{shift.registeredStaff.map((staff) => ( {shift.registeredStaff.map((staff) => (
@@ -156,7 +175,7 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-(--color-primary)/10"> <div className="flex h-7 w-7 items-center justify-center rounded-full bg-(--color-primary)/10">
<i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i> <i className="fa-solid fa-user text-[10px] text-(--color-primary)"></i>
</div> </div>
<span className="text-sm font-medium text-foreground"> <span className="text-foreground text-sm font-medium">
{staff.name} {staff.name}
</span> </span>
</div> </div>
@@ -193,16 +212,19 @@ export default function ShiftDetailModal({ shift, isOpen, onClose }: ShiftDetail
{/* Footer actions */} {/* Footer actions */}
<div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4"> <div className="flex gap-2 border-t border-(--color-border-light) px-5 py-4">
{!isRegistered && !isFull && shift.status !== "approved_leave" && shift.status !== "absent" && ( {!isRegistered &&
<button !isFull &&
type="button" shift.status !== "approved_leave" &&
onClick={handleRegister} shift.status !== "absent" && (
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:opacity-90" <button
> type="button"
<i className="fa-solid fa-calendar-plus mr-2"></i> onClick={handleRegister}
Đăng ca 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:opacity-90"
</button> >
)} <i className="fa-solid fa-calendar-plus mr-2"></i>
Đăng ca
</button>
)}
{isRegistered && ( {isRegistered && (
<button <button
type="button" type="button"
@@ -8,11 +8,21 @@ import { useMemo, useState } from "react";
import type { WeeklyScheduleProps } from "./ShiftSchedule.types"; import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
const MONTH_NAMES_EN = [ const MONTH_NAMES_EN = [
"January", "February", "March", "April", "May", "June", "January",
"July", "August", "September", "October", "November", "December", "February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]; ];
const DAY_LABELS = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"]; const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function formatDateShort(d: Date): string { function formatDateShort(d: Date): string {
@@ -40,10 +50,18 @@ export default function WeeklySchedule({
onCreateShift, onCreateShift,
mobileCalendarHeader = false, mobileCalendarHeader = false,
}: WeeklyScheduleProps) { }: WeeklyScheduleProps) {
const { currentDate, getWeekDates, getShiftsForDate, goToNextWeek, goToPrevWeek } = useShift(); const {
currentDate,
getWeekDates,
getShiftsForDate,
goToNextWeek,
goToPrevWeek,
} = useShift();
const weekDates = getWeekDates(); const weekDates = getWeekDates();
const [selectedDate, setSelectedDate] = useState<string>(formatDateISO(weekDates[0] ?? currentDate)); const [selectedDate, setSelectedDate] = useState<string>(
formatDateISO(weekDates[0] ?? currentDate),
);
const statusDotsByDate = useMemo(() => { const statusDotsByDate = useMemo(() => {
const map: Record<string, string[]> = {}; const map: Record<string, string[]> = {};
@@ -51,10 +69,14 @@ export default function WeeklySchedule({
const dateStr = formatDateISO(date); const dateStr = formatDateISO(date);
const dayShifts = getShiftsForDate(dateStr); const dayShifts = getShiftsForDate(dateStr);
const dots: string[] = []; const dots: string[] = [];
if (dayShifts.some((s) => s.status === "available")) dots.push("bg-sky-300"); if (dayShifts.some((s) => s.status === "available"))
if (dayShifts.some((s) => s.status === "registered")) dots.push("bg-blue-600"); dots.push("bg-sky-300");
if (dayShifts.some((s) => s.status === "approved_leave")) dots.push("bg-purple-400"); if (dayShifts.some((s) => s.status === "registered"))
if (dayShifts.some((s) => s.status === "absent")) dots.push("bg-rose-400"); dots.push("bg-blue-600");
if (dayShifts.some((s) => s.status === "approved_leave"))
dots.push("bg-purple-400");
if (dayShifts.some((s) => s.status === "absent"))
dots.push("bg-rose-400");
map[dateStr] = dots.slice(0, 3); map[dateStr] = dots.slice(0, 3);
}); });
return map; return map;
@@ -72,7 +94,7 @@ export default function WeeklySchedule({
<div className="rounded-xl border border-(--color-border-light) bg-white p-3"> <div className="rounded-xl border border-(--color-border-light) bg-white p-3">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<button <button
title="Tuần trước" title="Previous week"
type="button" type="button"
onClick={goToPrevWeek} onClick={goToPrevWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
@@ -83,7 +105,7 @@ export default function WeeklySchedule({
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()} {MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3> </h3>
<button <button
title="Tuần sau" title="Next week"
type="button" type="button"
onClick={goToNextWeek} onClick={goToNextWeek}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
@@ -106,7 +128,9 @@ export default function WeeklySchedule({
active ? "bg-(--color-primary)/10" : "bg-transparent" active ? "bg-(--color-primary)/10" : "bg-transparent"
}`} }`}
> >
<span className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}> <span
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
>
{DAY_LABELS_EN[i]} {DAY_LABELS_EN[i]}
</span> </span>
<span <span
@@ -122,7 +146,10 @@ export default function WeeklySchedule({
</span> </span>
<div className="flex min-h-2 items-center gap-0.5"> <div className="flex min-h-2 items-center gap-0.5">
{dots.map((dot, idx) => ( {dots.map((dot, idx) => (
<span key={idx} className={`h-1.5 w-1.5 rounded-full ${dot}`} /> <span
key={idx}
className={`h-1.5 w-1.5 rounded-full ${dot}`}
/>
))} ))}
</div> </div>
</button> </button>
@@ -133,19 +160,30 @@ export default function WeeklySchedule({
<div className="space-y-3"> <div className="space-y-3">
{DEPARTMENTS.map((dept) => { {DEPARTMENTS.map((dept) => {
const deptShifts = getShiftsForDate(selectedDateStr).filter((s) => s.department === dept.id); const deptShifts = getShiftsForDate(selectedDateStr).filter(
(s) => s.department === dept.id,
);
if (deptShifts.length === 0 && !onCreateShift) return null; if (deptShifts.length === 0 && !onCreateShift) return null;
return ( return (
<div key={dept.id} className="rounded-xl border border-(--color-border-light) bg-white p-3"> <div
key={dept.id}
className="rounded-xl border border-(--color-border-light) bg-white p-3"
>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i> <i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)"> <span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name} {dept.name}
</span> </span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{deptShifts.map((shift) => ( {deptShifts.map((shift) => (
<ShiftCard key={shift.id} shift={shift} onClick={onShiftClick} /> <ShiftCard
key={shift.id}
shift={shift}
onClick={onShiftClick}
/>
))} ))}
{onCreateShift && ( {onCreateShift && (
<button <button
@@ -154,7 +192,7 @@ export default function WeeklySchedule({
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)" className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
> >
<i className="fa-solid fa-plus mr-1"></i> <i className="fa-solid fa-plus mr-1"></i>
Thêm ca Add shift
</button> </button>
)} )}
</div> </div>
@@ -174,13 +212,13 @@ export default function WeeklySchedule({
<table className="w-full min-w-225 border-collapse"> <table className="w-full min-w-225 border-collapse">
<thead> <thead>
<tr> <tr>
<th className="w-28 border-b border-r border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase"> <th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
Bộ phận Department
</th> </th>
{weekDates.map((date, i) => ( {weekDates.map((date, i) => (
<th <th
key={i} key={i}
className={`border-b border-r border-(--color-border-light) px-2 py-3 text-center text-xs ${ className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
isToday(date) isToday(date)
? "bg-(--color-primary)/10 font-bold text-(--color-primary)" ? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
: "bg-gray-50 font-semibold text-(--color-text-muted)" : "bg-gray-50 font-semibold text-(--color-text-muted)"
@@ -197,9 +235,11 @@ export default function WeeklySchedule({
<tbody> <tbody>
{DEPARTMENTS.map((dept) => ( {DEPARTMENTS.map((dept) => (
<tr key={dept.id}> <tr key={dept.id}>
<td className="border-b border-r border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top"> <td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<i className={`${dept.icon} text-xs text-(--color-primary)`}></i> <i
className={`${dept.icon} text-xs text-(--color-primary)`}
></i>
<span className="text-xs font-semibold text-(--color-text-secondary)"> <span className="text-xs font-semibold text-(--color-text-secondary)">
{dept.name} {dept.name}
</span> </span>
@@ -213,7 +253,7 @@ export default function WeeklySchedule({
return ( return (
<td <td
key={i} key={i}
className={`border-b border-r border-(--color-border-light) p-1.5 align-top ${ className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
isToday(date) ? "bg-(--color-primary)/5" : "" isToday(date) ? "bg-(--color-primary)/5" : ""
}`} }`}
> >
@@ -233,7 +273,7 @@ export default function WeeklySchedule({
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)" className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
> >
<i className="fa-solid fa-plus mr-1"></i> <i className="fa-solid fa-plus mr-1"></i>
Thêm ca Add shift
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -23,7 +23,7 @@ export default function ShopGrid({
return ( return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)"> <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> <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> <p className="text-base font-medium">No shops found matching your search</p>
</div> </div>
); );
} }
@@ -1,13 +0,0 @@
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>
);
}
@@ -1,3 +0,0 @@
export interface AuthLayoutProps {
children: React.ReactNode;
}
@@ -1,2 +0,0 @@
export { default as AuthLayout } from "./AuthLayout";
export type { AuthLayoutProps } from "./AuthLayout.types";
-4
View File
@@ -2,10 +2,6 @@
export { MainLayout } from "./main-layout"; export { MainLayout } from "./main-layout";
export type { MainLayoutProps } from "./main-layout"; export type { MainLayoutProps } from "./main-layout";
// Auth Layout
export { AuthLayout } from "./auth-layout";
export type { AuthLayoutProps } from "./auth-layout";
// Feed Layout // Feed Layout
export { FeedLayout } from "./feed-layout"; export { FeedLayout } from "./feed-layout";
export type { FeedLayoutProps } from "./feed-layout"; export type { FeedLayoutProps } from "./feed-layout";
@@ -13,16 +13,16 @@ import type { ManagerLayoutProps } from "./ManagerLayout.types";
* Redirects non-managers away; shows loading state while auth resolves. * Redirects non-managers away; shows loading state while auth resolves.
*/ */
export default function ManagerLayout({ children }: ManagerLayoutProps) { export default function ManagerLayout({ children }: ManagerLayoutProps) {
const { user } = useAuth(); const { user, isInitialized } = useAuth();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (user !== null && user.role !== "manager") { if (isInitialized && user !== null && user.role !== "manager") {
router.replace("/"); router.replace("/");
} }
}, [user, router]); }, [user, isInitialized, router]);
if (user === null) { if (!isInitialized || user === null) {
return ( return (
<div className="bg-background flex min-h-screen items-center justify-center"> <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)"> <div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: frontend-container - name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.1.0 image: git.demonkernel.io.vn/foodsurf/frontend:1.2.3
ports: ports:
- containerPort: 3000 - containerPort: 3000
resources: resources:
+9 -9
View File
@@ -48,7 +48,7 @@ export default function Footer() {
<ul className="flex flex-col gap-2 text-sm opacity-80"> <ul className="flex flex-col gap-2 text-sm opacity-80">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i> <i className="fa-solid fa-location-dot mt-0.5 w-4 shrink-0 text-center text-(--color-accent)"></i>
<span>Đa chỉ: {SHOP_INFO.address}</span> <span>Address: {SHOP_INFO.address}</span>
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i> <i className="fa-solid fa-phone w-4 shrink-0 text-center text-(--color-accent)"></i>
@@ -56,7 +56,7 @@ export default function Footer() {
href={`tel:${SHOP_INFO.phone}`} href={`tel:${SHOP_INFO.phone}`}
className="transition-colors duration-150 hover:text-(--color-accent)" className="transition-colors duration-150 hover:text-(--color-accent)"
> >
Số điện thoại: {SHOP_INFO.phone} Phone: {SHOP_INFO.phone}
</a> </a>
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
@@ -83,7 +83,7 @@ export default function Footer() {
{/* ── 2. Social links ── */} {/* ── 2. Social links ── */}
<div className="col-span-1"> <div className="col-span-1">
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase"> <h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
Kết nối Follow Us
</h3> </h3>
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
<li> <li>
@@ -129,20 +129,20 @@ export default function Footer() {
{/* ── 3. WiFi card ── */} {/* ── 3. WiFi card ── */}
<div className="col-span-1"> <div className="col-span-1">
<h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase"> <h3 className="mb-4 text-sm font-bold tracking-wider text-(--color-accent) uppercase">
WiFi Miễn Phí Free WiFi
</h3> </h3>
<div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4"> <div className="border-opacity-50 bg-opacity-30 rounded-xl border border-(--color-primary-light) bg-(--color-primary-dark) p-4">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i> <i className="fa-solid fa-wifi shrink-0 text-lg text-(--color-accent)"></i>
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
Kết nối miễn phí Connect for free
</span> </span>
</div> </div>
{/* Stacked label + value rows — no overflow risk */} {/* Stacked label + value rows — no overflow risk */}
<div className="flex flex-col gap-3 text-sm"> <div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs tracking-wide uppercase opacity-60"> <span className="text-xs tracking-wide uppercase opacity-60">
Tên mạng Network name
</span> </span>
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)"> <span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold break-all text-(--color-accent)">
{SHOP_INFO.wifi.name} {SHOP_INFO.wifi.name}
@@ -150,7 +150,7 @@ export default function Footer() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs tracking-wide uppercase opacity-60"> <span className="text-xs tracking-wide uppercase opacity-60">
Mật khẩu Password
</span> </span>
<span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)"> <span className="border-opacity-30 rounded border border-(--color-accent) px-2 py-1 font-mono font-bold tracking-wider break-all text-(--color-accent)">
{SHOP_INFO.wifi.password} {SHOP_INFO.wifi.password}
@@ -170,9 +170,9 @@ export default function Footer() {
© {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved. © {new Date().getFullYear()} {SHOP_INFO.name}. All rights reserved.
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
Đưc vận hành{" "} Powered by{" "}
<i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "} <i className="fa-solid fa-heart mx-1 text-(--color-accent)"></i>{" "}
bằng Drinkool Drinkool
</span> </span>
</div> </div>
</div> </div>
+9 -7
View File
@@ -72,11 +72,11 @@ export default function Header() {
/* Guest: sign-in button */ /* Guest: sign-in button */
<button <button
onClick={handleAuthClick} onClick={handleAuthClick}
title="Đăng nhập" title="Sign in"
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95" className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary) px-5 py-2.5 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary-dark) active:scale-95"
> >
<i className="fa-solid fa-right-to-bracket"></i> <i className="fa-solid fa-right-to-bracket"></i>
<span className="hidden sm:inline">Đăng nhập</span> <span className="hidden sm:inline">Sign in</span>
</button> </button>
) : user.role === "manager" ? ( ) : user.role === "manager" ? (
/* Manager: dashboard link + logout */ /* Manager: dashboard link + logout */
@@ -90,7 +90,8 @@ export default function Header() {
</Link> </Link>
<button <button
onClick={handleAuthClick} onClick={handleAuthClick}
title="Đăng xuất" title="Sign out"
aria-label="Sign out"
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95" className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
> >
<i className="fa-solid fa-right-from-bracket text-base"></i> <i className="fa-solid fa-right-from-bracket text-base"></i>
@@ -104,11 +105,12 @@ export default function Header() {
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white" className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
> >
<i className="fa-solid fa-calendar-check text-base"></i> <i className="fa-solid fa-calendar-check text-base"></i>
<span className="hidden sm:inline">Ca làm</span> <span className="hidden sm:inline">My Shifts</span>
</Link> </Link>
<button <button
onClick={handleAuthClick} onClick={handleAuthClick}
title="Nhấn để đăng xuất" title="Click to sign out"
aria-label="Sign out"
className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95" className="bg-background flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-border) px-4 py-2 text-sm font-semibold text-(--color-text-secondary) transition-all duration-150 hover:border-(--color-primary-light) hover:bg-(--color-border-light) active:scale-95"
> >
{/* Avatar circle */} {/* Avatar circle */}
@@ -122,14 +124,14 @@ export default function Header() {
/* Customer: phone icon + label */ /* Customer: phone icon + label */
<button <button
onClick={handleAuthClick} onClick={handleAuthClick}
title={`Khách hàng - ${user.phone || ""} - Nhấn để đăng xuất`} title={`Customer - ${user.phone || ""} - Click to sign out`}
className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95" className="flex cursor-pointer items-center gap-2.5 rounded-xl border-none bg-(--color-primary-light) px-4 py-2 text-sm font-semibold text-white transition-all duration-150 hover:bg-(--color-primary) active:scale-95"
> >
{/* Customer icon */} {/* Customer icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-xs text-(--color-primary-light)">
<i className="fa-solid fa-user"></i> <i className="fa-solid fa-user"></i>
</div> </div>
<span className="hidden sm:inline">Khách hàng</span> <span className="hidden sm:inline">Customer</span>
</button> </button>
)} )}
</div> </div>
+29 -114
View File
@@ -12,95 +12,18 @@ import type { User } from "./types";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
login: (username: string, password: string) => boolean; setUser: (user: User | null) => void;
isInitialized: boolean;
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
logout: () => void; logout: () => void;
registerPhone: string | null;
setRegisterPhone: (phone: string | null) => void;
completeRegistration: (phone: string) => void;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Mock user database
const MOCK_AUTH_DB = {
// Admin
admin: {
username: "admin",
password: "admin",
user: {
id: 1,
name: "Quản lý",
role: "manager" as const,
avatar: null,
phone: "0912345678",
},
},
// Staff (username and password are their names)
"Nguyễn Văn An": {
username: "Nguyễn Văn An",
password: "Nguyễn Văn An",
user: {
id: 2,
name: "Nguyễn Văn An",
role: "staff" as const,
avatar: null,
phone: "0901234567",
},
},
"Trần Thị Bình": {
username: "Trần Thị Bình",
password: "Trần Thị Bình",
user: {
id: 3,
name: "Trần Thị Bình",
role: "staff" as const,
avatar: null,
phone: "0902345678",
},
},
"Lê Văn Cường": {
username: "Lê Văn Cường",
password: "Lê Văn Cường",
user: {
id: 4,
name: "Lê Văn Cường",
role: "staff" as const,
avatar: null,
phone: "0903456789",
},
},
// Customers (username is phone number, password is custom)
"0987654321": {
username: "0987654321",
password: "user1",
user: {
id: 5,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0987654321",
},
},
"0976543210": {
username: "0976543210",
password: "user1",
user: {
id: 6,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0976543210",
},
},
};
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [registerPhone, setRegisterPhone] = useState<string | null>(null); const [isInitialized, setIsInitialized] = useState(false);
// Load user from localStorage on mount
useEffect(() => { useEffect(() => {
const savedUser = localStorage.getItem("coffee-shop-user"); const savedUser = localStorage.getItem("coffee-shop-user");
if (savedUser) { if (savedUser) {
@@ -110,18 +33,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error("Failed to parse saved user", e); console.error("Failed to parse saved user", e);
} }
} }
setIsInitialized(true);
}, []); }, []);
const login = (username: string, password: string): boolean => { const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB]; const isPhone = /^0\d{9}$/.test(username.trim());
const role = isPhone ? "customer" : "manager";
if (authEntry && authEntry.password === password) { try {
setUser(authEntry.user); const response = await fetch("/api/login", {
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user)); method: "POST",
return true; headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: username, password, role }),
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
localStorage.setItem("coffee-shop-user", JSON.stringify(userData));
return { ok: true };
} else {
console.error("Đăng nhập thất bại:", response.status);
return { ok: false, status: response.status };
}
} catch (error) {
console.error("Lỗi kết nối API:", error);
return { ok: false };
} }
return false;
}; };
const logout = () => { const logout = () => {
@@ -129,37 +67,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem("coffee-shop-user"); localStorage.removeItem("coffee-shop-user");
}; };
const completeRegistration = (phone: string) => {
// Create new customer account
const newUser: User = {
id: Date.now(),
name: "Khách hàng",
role: "customer",
avatar: null,
phone,
};
// Add to mock database (in real app, this would be API call)
(MOCK_AUTH_DB as any)[phone] = {
username: phone,
password: "user1", // Default password for new customers
user: newUser,
};
setUser(newUser);
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
setRegisterPhone(null);
};
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
setUser,
isInitialized,
login, login,
logout, logout,
registerPhone,
setRegisterPhone,
completeRegistration,
}} }}
> >
{children} {children}
+96 -112
View File
@@ -15,9 +15,9 @@ import type {
// ===== SHOP INFORMATION ===== // ===== SHOP INFORMATION =====
export const SHOP_INFO: ShopInfo = { export const SHOP_INFO: ShopInfo = {
name: "Coffee Shop", name: "Coffee Shop",
tagline: "Hương vị đậm đà Khoảnh khắc thư giãn", tagline: "Rich Flavors Moments of Relaxation",
logo: "/imgs/logo.png", logo: "/imgs/logo.png",
address: "123 Đường Nguyn Huệ, Quận 1, TP. Hồ Chí Minh", address: "123 Nguyen Hue Street, District 1, Ho Chi Minh City",
phone: "0901 234 567", phone: "0901 234 567",
managerPhone: "0912 345 678", managerPhone: "0912 345 678",
email: "contact@coffeeshop.vn", email: "contact@coffeeshop.vn",
@@ -25,7 +25,7 @@ export const SHOP_INFO: ShopInfo = {
name: "CoffeeShop_Free", name: "CoffeeShop_Free",
password: "coffee2024", password: "coffee2024",
}, },
openHours: "07:00 22:00 (Thứ 2 Chủ nhật)", openHours: "07:00 22:00 (Monday Sunday)",
}; };
// ===== SOCIAL LINKS ===== // ===== SOCIAL LINKS =====
@@ -38,15 +38,15 @@ export const SOCIAL_LINKS: SocialLinks = {
// ===== MENU CATEGORIES ===== // ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type // Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [ export const MENU_CATEGORIES: MenuCategory[] = [
{ id: "all", name: "Tất cả", icon: "fa-solid fa-border-all" }, { id: "all", name: "All", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Cà Phê", icon: "fa-solid fa-mug-hot" }, { id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "T", icon: "fa-solid fa-leaf" }, { id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Sữa Chua", icon: "fa-solid fa-jar" }, { id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Nước Ép", icon: "fa-solid fa-blender" }, { id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" }, { id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
{ {
id: "giai-khat", id: "giai-khat",
name: "Giải Khát / Ăn Vặt", name: "Refreshments / Snacks",
icon: "fa-solid fa-ice-cream", icon: "fa-solid fa-ice-cream",
}, },
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" }, { id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
@@ -57,182 +57,182 @@ export const MENU_CATEGORIES: MenuCategory[] = [
export const MOCK_PRODUCTS: Product[] = [ export const MOCK_PRODUCTS: Product[] = [
{ {
id: 1, id: 1,
name: "Cà Phê Đen", name: "Black Coffee",
category: "cafe", category: "cafe",
price: 25000, price: 25000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Cà phê đen truyền thống, đậm đà hương vị Việt Nam, pha phin thủ công.", "Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
available: true, available: true,
}, },
{ {
id: 2, id: 2,
name: "Cà Phê Sữa", name: "Milk Coffee",
category: "cafe", category: "cafe",
price: 30000, price: 30000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Cà phê sữa đặc thơm ngon, béo ngậy, kết hợp hoàn hảo giữa cà phê và sữa đặc.", "Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
available: true, available: true,
}, },
{ {
id: 3, id: 3,
name: "Bạc Xỉu", name: "White Coffee",
category: "cafe", category: "cafe",
price: 32000, price: 32000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Bạc xỉu nhẹ nhàng, ít cà phê nhiều sữa, thích hợp cho người mới uống cà phê.", "Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
available: true, available: true,
}, },
{ {
id: 4, id: 4,
name: "Cà Phê Trứng", name: "Egg Coffee",
category: "cafe", category: "cafe",
price: 45000, price: 45000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Cà phê trứng đặc sản Hà Nội, lớp kem trứng mịn màng phủ trên nền cà phê đậm đà.", "A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
available: true, available: true,
}, },
{ {
id: 5, id: 5,
name: "Trà Đào Cam Sả", name: "Peach Orange Lemongrass Tea",
category: "tra", category: "tra",
price: 35000, price: 35000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Trà đào thơm mát kết hợp cam tươi và sả, thanh mát và giải nhiệt tuyệt vời.", "Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
available: true, available: true,
}, },
{ {
id: 6, id: 6,
name: "Trà Xanh Matcha", name: "Matcha Green Tea",
category: "tra", category: "tra",
price: 40000, price: 40000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Matcha Nhật Bản nguyên chất, vị đắng nhẹ đặc trưng, thơm mát và bổ dưỡng.", "Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
available: true, available: true,
}, },
{ {
id: 7, id: 7,
name: "Trà Vải Hoa Nhài", name: "Lychee Jasmine Tea",
category: "tra", category: "tra",
price: 38000, price: 38000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Trà vải thanh ngọt kết hợp hương hoa nhài dịu dàng, thư giãn tâm hồn.", "Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
available: true, available: true,
}, },
{ {
id: 8, id: 8,
name: "Sữa Chua Trân Châu", name: "Yogurt with Tapioca Pearls",
category: "sua-chua", category: "sua-chua",
price: 38000, price: 38000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Sữa chua mịn màng kết hợp trân châu đen dẻo dai, chua ngọt hài hòa.", "Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
available: true, available: true,
}, },
{ {
id: 9, id: 9,
name: "Sữa Chua Dâu", name: "Strawberry Yogurt",
category: "sua-chua", category: "sua-chua",
price: 40000, price: 40000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Sữa chua mát lạnh với dâu tươi ngọt chua, giàu vitamin và khoáng chất.", "Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
available: true, available: true,
}, },
{ {
id: 10, id: 10,
name: "Nước Ép Cam", name: "Fresh Orange Juice",
category: "nuoc-ep", category: "nuoc-ep",
price: 35000, price: 35000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Nước ép cam tươi nguyên chất, giàu vitamin C, tốt cho sức khỏe.", "100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
available: true, available: true,
}, },
{ {
id: 11, id: 11,
name: "Nước Ép Dưa Hấu", name: "Watermelon Juice",
category: "nuoc-ep", category: "nuoc-ep",
price: 30000, price: 30000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Nước ép dưa hấu mát lạnh, giải nhiệt tức thì trong những ngày hè oi bức.", "Ice-cold watermelon juice — instantly refreshing on hot summer days.",
available: true, available: true,
}, },
{ {
id: 12, id: 12,
name: "Latte Caramel", name: "Caramel Latte",
category: "latte", category: "latte",
price: 45000, price: 45000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Latte caramel ngọt ngào, thơm béo với lớp foam sữa mịn và sốt caramel.", "Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
available: true, available: true,
}, },
{ {
id: 13, id: 13,
name: "Latte Vanilla", name: "Vanilla Latte",
category: "latte", category: "latte",
price: 45000, price: 45000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Latte vanilla nhẹ nhàng, hương thơm dịu dàng từ vanilla tự nhiên.", "Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
available: true, available: true,
}, },
{ {
id: 14, id: 14,
name: "Bánh Mì Nướng Bơ", name: "Toasted Butter Bread",
category: "giai-khat", category: "giai-khat",
price: 20000, price: 20000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Bánh mì nướng giòn rụm, phết bơ thơm và mứt dâu, ăn kèm cà phê tuyệt vời.", "Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
available: true, available: true,
}, },
{ {
id: 15, id: 15,
name: "Bánh Flan", name: "Caramel Flan",
category: "giai-khat", category: "giai-khat",
price: 25000, price: 25000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Bánh flan mềm mịn, ngọt ngào với lớp caramel vàng óng, tan chảy trong miệng.", "Silky smooth flan with a golden caramel topping that melts in your mouth.",
available: true, available: true,
}, },
{ {
id: 16, id: 16,
name: "Trân Châu Đen", name: "Black Tapioca Pearls",
category: "topping", category: "topping",
price: 10000, price: 10000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Trân châu đen dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.", "Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
available: true, available: true,
}, },
{ {
id: 17, id: 17,
name: "Thạch Cà Phê", name: "Coffee Jelly",
category: "topping", category: "topping",
price: 10000, price: 10000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Thạch cà phê mát lạnh, thêm hương vị đặc biệt cho đồ uống của bạn.", "Cool coffee jelly that adds a distinctive flavor boost to your drink.",
available: true, available: true,
}, },
{ {
id: 18, id: 18,
name: "Trân Châu Trắng", name: "White Tapioca Pearls",
category: "topping", category: "topping",
price: 10000, price: 10000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
description: description:
"Trân châu trắng dẻo dai, thêm vào bất kỳ đồ uống nào để tăng thêm hương vị.", "Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
available: true, available: true,
}, },
]; ];
@@ -241,8 +241,8 @@ export const MOCK_PRODUCTS: Product[] = [
export const MOCK_COMBOS: Combo[] = [ export const MOCK_COMBOS: Combo[] = [
{ {
id: 1, id: 1,
name: "Combo Cà Phê Đôi", name: "Double Coffee Combo",
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.", description: "2 black coffees + 2 toasted butter breads, save 15%.",
price: 75000, price: 75000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
items: [ items: [
@@ -253,8 +253,8 @@ export const MOCK_COMBOS: Combo[] = [
}, },
{ {
id: 2, id: 2,
name: "Combo Trà Sữa Nhóm", name: "Group Tea Combo",
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.", description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
price: 130000, price: 130000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
items: [ items: [
@@ -265,8 +265,8 @@ export const MOCK_COMBOS: Combo[] = [
}, },
{ {
id: 3, id: 3,
name: "Combo Buổi Sáng", name: "Morning Combo",
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.", description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
price: 48000, price: 48000,
image: "/imgs/products/placeholder.jpg", image: "/imgs/products/placeholder.jpg",
items: [ items: [
@@ -354,34 +354,34 @@ export const MOCK_REVENUE_DAILY: RevenueDataPoint[] = [
// Weekly revenue (last 12 weeks) // Weekly revenue (last 12 weeks)
export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [ export const MOCK_REVENUE_WEEKLY: RevenueDataPoint[] = [
{ label: "T1/W1", revenue: 8200000, orders: 295 }, { label: "Jan/W1", revenue: 8200000, orders: 295 },
{ label: "T1/W2", revenue: 9450000, orders: 340 }, { label: "Jan/W2", revenue: 9450000, orders: 340 },
{ label: "T1/W3", revenue: 10100000, orders: 362 }, { label: "Jan/W3", revenue: 10100000, orders: 362 },
{ label: "T1/W4", revenue: 8750000, orders: 315 }, { label: "Jan/W4", revenue: 8750000, orders: 315 },
{ label: "T2/W1", revenue: 9200000, orders: 330 }, { label: "Feb/W1", revenue: 9200000, orders: 330 },
{ label: "T2/W2", revenue: 10500000, orders: 378 }, { label: "Feb/W2", revenue: 10500000, orders: 378 },
{ label: "T2/W3", revenue: 11200000, orders: 400 }, { label: "Feb/W3", revenue: 11200000, orders: 400 },
{ label: "T2/W4", revenue: 9800000, orders: 352 }, { label: "Feb/W4", revenue: 9800000, orders: 352 },
{ label: "T3/W1", revenue: 10400000, orders: 374 }, { label: "Mar/W1", revenue: 10400000, orders: 374 },
{ label: "T3/W2", revenue: 11800000, orders: 424 }, { label: "Mar/W2", revenue: 11800000, orders: 424 },
{ label: "T3/W3", revenue: 12500000, orders: 448 }, { label: "Mar/W3", revenue: 12500000, orders: 448 },
{ label: "T3/W4", revenue: 10900000, orders: 392 }, { label: "Mar/W4", revenue: 10900000, orders: 392 },
]; ];
// Monthly revenue (last 12 months) // Monthly revenue (last 12 months)
export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [ export const MOCK_REVENUE_MONTHLY: RevenueDataPoint[] = [
{ label: "T4/2025", revenue: 42000000, orders: 1512 }, { label: "Apr/2025", revenue: 42000000, orders: 1512 },
{ label: "T5/2025", revenue: 45500000, orders: 1638 }, { label: "May/2025", revenue: 45500000, orders: 1638 },
{ label: "T6/2025", revenue: 48000000, orders: 1728 }, { label: "Jun/2025", revenue: 48000000, orders: 1728 },
{ label: "T7/2025", revenue: 52000000, orders: 1872 }, { label: "Jul/2025", revenue: 52000000, orders: 1872 },
{ label: "T8/2025", revenue: 49500000, orders: 1782 }, { label: "Aug/2025", revenue: 49500000, orders: 1782 },
{ label: "T9/2025", revenue: 46800000, orders: 1685 }, { label: "Sep/2025", revenue: 46800000, orders: 1685 },
{ label: "T10/2025", revenue: 51200000, orders: 1843 }, { label: "Oct/2025", revenue: 51200000, orders: 1843 },
{ label: "T11/2025", revenue: 55000000, orders: 1980 }, { label: "Nov/2025", revenue: 55000000, orders: 1980 },
{ label: "T12/2025", revenue: 62000000, orders: 2232 }, { label: "Dec/2025", revenue: 62000000, orders: 2232 },
{ label: "T1/2026", revenue: 44000000, orders: 1584 }, { label: "Jan/2026", revenue: 44000000, orders: 1584 },
{ label: "T2/2026", revenue: 47500000, orders: 1710 }, { label: "Feb/2026", revenue: 47500000, orders: 1710 },
{ label: "T3/2026", revenue: 53500000, orders: 1926 }, { label: "Mar/2026", revenue: 53500000, orders: 1926 },
]; ];
// Yearly revenue (last 5 years) // Yearly revenue (last 5 years)
@@ -397,7 +397,7 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
{ {
productId: 12, productId: 12,
name: "Latte Caramel", name: "Caramel Latte",
category: "latte", category: "latte",
unitsSold: 487, unitsSold: 487,
revenue: 21915000, revenue: 21915000,
@@ -408,7 +408,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 6, productId: 6,
name: "Trà Xanh Matcha", name: "Matcha Green Tea",
category: "tra", category: "tra",
unitsSold: 412, unitsSold: 412,
revenue: 16480000, revenue: 16480000,
@@ -419,7 +419,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 5, productId: 5,
name: "Trà Đào Cam Sả", name: "Peach Orange Lemongrass Tea",
category: "tra", category: "tra",
unitsSold: 398, unitsSold: 398,
revenue: 13930000, revenue: 13930000,
@@ -430,7 +430,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 13, productId: 13,
name: "Latte Vanilla", name: "Vanilla Latte",
category: "latte", category: "latte",
unitsSold: 356, unitsSold: 356,
revenue: 16020000, revenue: 16020000,
@@ -441,7 +441,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 4, productId: 4,
name: "Cà Phê Trứng", name: "Egg Coffee",
category: "cafe", category: "cafe",
unitsSold: 340, unitsSold: 340,
revenue: 15300000, revenue: 15300000,
@@ -452,7 +452,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 2, productId: 2,
name: "Cà Phê Sữa", name: "Milk Coffee",
category: "cafe", category: "cafe",
unitsSold: 325, unitsSold: 325,
revenue: 9750000, revenue: 9750000,
@@ -463,7 +463,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 3, productId: 3,
name: "Bạc Xỉu", name: "White Coffee",
category: "cafe", category: "cafe",
unitsSold: 298, unitsSold: 298,
revenue: 9536000, revenue: 9536000,
@@ -474,7 +474,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 1, productId: 1,
name: "Cà Phê Đen", name: "Black Coffee",
category: "cafe", category: "cafe",
unitsSold: 285, unitsSold: 285,
revenue: 7125000, revenue: 7125000,
@@ -485,7 +485,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 9, productId: 9,
name: "Sữa Chua Dâu", name: "Strawberry Yogurt",
category: "sua-chua", category: "sua-chua",
unitsSold: 267, unitsSold: 267,
revenue: 10680000, revenue: 10680000,
@@ -496,7 +496,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 10, productId: 10,
name: "Nước Ép Cam", name: "Fresh Orange Juice",
category: "nuoc-ep", category: "nuoc-ep",
unitsSold: 241, unitsSold: 241,
revenue: 8435000, revenue: 8435000,
@@ -507,7 +507,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 8, productId: 8,
name: "Sữa Chua Trân Châu", name: "Yogurt with Tapioca Pearls",
category: "sua-chua", category: "sua-chua",
unitsSold: 228, unitsSold: 228,
revenue: 8664000, revenue: 8664000,
@@ -518,7 +518,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 7, productId: 7,
name: "Trà Vải Hoa Nhài", name: "Lychee Jasmine Tea",
category: "tra", category: "tra",
unitsSold: 215, unitsSold: 215,
revenue: 8170000, revenue: 8170000,
@@ -529,7 +529,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 15, productId: 15,
name: "Bánh Flan", name: "Caramel Flan",
category: "giai-khat", category: "giai-khat",
unitsSold: 198, unitsSold: 198,
revenue: 4950000, revenue: 4950000,
@@ -540,7 +540,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 11, productId: 11,
name: "Nước Ép Dưa Hấu", name: "Watermelon Juice",
category: "nuoc-ep", category: "nuoc-ep",
unitsSold: 182, unitsSold: 182,
revenue: 5460000, revenue: 5460000,
@@ -551,7 +551,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 14, productId: 14,
name: "Bánh Mì Nướng Bơ", name: "Toasted Butter Bread",
category: "giai-khat", category: "giai-khat",
unitsSold: 175, unitsSold: 175,
revenue: 3500000, revenue: 3500000,
@@ -562,7 +562,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 16, productId: 16,
name: "Trân Châu Đen", name: "Black Tapioca Pearls",
category: "topping", category: "topping",
unitsSold: 456, unitsSold: 456,
revenue: 4560000, revenue: 4560000,
@@ -573,7 +573,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 17, productId: 17,
name: "Thạch Cà Phê", name: "Coffee Jelly",
category: "topping", category: "topping",
unitsSold: 389, unitsSold: 389,
revenue: 3890000, revenue: 3890000,
@@ -584,7 +584,7 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
{ {
productId: 18, productId: 18,
name: "Trân Châu Trắng", name: "White Tapioca Pearls",
category: "topping", category: "topping",
unitsSold: 342, unitsSold: 342,
revenue: 3420000, revenue: 3420000,
@@ -595,22 +595,6 @@ export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
}, },
]; ];
// ===== MOCK USERS (for UI demo replace with real auth) =====
export const MOCK_USERS: Record<string, User> = {
manager: {
id: 1,
name: "Nguyễn Văn An",
role: "manager",
avatar: null,
},
staff: {
id: 2,
name: "Trần Thị Bình",
role: "staff",
avatar: null,
},
};
// ===== SHIFT / SCHEDULE DATA ===== // ===== SHIFT / SCHEDULE DATA =====
export const DEPARTMENTS: Department[] = [ export const DEPARTMENTS: Department[] = [
@@ -635,9 +619,9 @@ function generateMockShifts(): ShiftSlot[] {
]; ];
const staffPool = [ const staffPool = [
{ id: 2, name: "Nguyễn Văn An" }, { id: 2, name: "Alex Nguyen" },
{ id: 3, name: "Trần Thị Bình" }, { id: 3, name: "Binh Tran" },
{ id: 4, name: "Lê Văn Cường" }, { id: 4, name: "Cuong Le" },
]; ];
// Generate shifts from April 6 to April 26, 2026 // Generate shifts from April 6 to April 26, 2026
+45
View File
@@ -0,0 +1,45 @@
import type { Eatery } from "./types";
export async function gqlFetch<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("/api/eatery/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
return res.json() as Promise<T>;
}
export async function addManagerMenuItem(
name: string,
price: number,
): Promise<{ id: string; name: string; price: number } | null> {
const data = await gqlFetch<{
addMenuItem: { id: string; name: string; price: number } | null;
}>(
`mutation($name: String!, $price: Float!) {
addMenuItem(name: $name, price: $price) { id name price }
}`,
{ name, price },
);
return data.addMenuItem;
}
export async function fetchManagerEatery(): Promise<Eatery | null> {
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
query {
eateriesByOwner {
id
ownerId
name
isVerified
}
}
`);
return data.eateriesByOwner;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Combo, ComboItem, MenuCategory, Product } from "./types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products" | "combos" | "categories"; export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
interface ManagerContextType { interface ManagerContextType {
// Data // Data
+34 -6
View File
@@ -1,6 +1,12 @@
"use client"; "use client";
import { ReactNode, createContext, useCallback, useContext, useState } from "react"; import {
ReactNode,
createContext,
useCallback,
useContext,
useState,
} from "react";
import { MOCK_SHIFT_SLOTS } from "./constants"; import { MOCK_SHIFT_SLOTS } from "./constants";
import type { ShiftSlot, ShiftStatus } from "./types"; import type { ShiftSlot, ShiftStatus } from "./types";
@@ -24,7 +30,11 @@ interface ShiftContextType {
goToToday: () => void; goToToday: () => void;
// Shift actions // Shift actions
registerShift: (shiftId: string, staffId: number, staffName: string) => { success: boolean; error?: string }; registerShift: (
shiftId: string,
staffId: number,
staffName: string,
) => { success: boolean; error?: string };
unregisterShift: (shiftId: string, staffId: number) => void; unregisterShift: (shiftId: string, staffId: number) => void;
createShift: (shift: Omit<ShiftSlot, "id">) => void; createShift: (shift: Omit<ShiftSlot, "id">) => void;
updateShift: (shift: ShiftSlot) => void; updateShift: (shift: ShiftSlot) => void;
@@ -34,7 +44,13 @@ interface ShiftContextType {
getShiftsForDate: (date: string) => ShiftSlot[]; getShiftsForDate: (date: string) => ShiftSlot[];
getShiftsForWeek: (weekStart: Date) => ShiftSlot[]; getShiftsForWeek: (weekStart: Date) => ShiftSlot[];
getWeekDates: () => Date[]; getWeekDates: () => Date[];
hasConflict: (date: string, startTime: string, endTime: string, staffId: number, excludeShiftId?: string) => boolean; hasConflict: (
date: string,
startTime: string,
endTime: string,
staffId: number,
excludeShiftId?: string,
) => boolean;
getWeeklyBudget: () => number; getWeeklyBudget: () => number;
} }
@@ -198,7 +214,15 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
return { success: false, error: "Bạn đã đăng ký ca này rồi." }; return { success: false, error: "Bạn đã đăng ký ca này rồi." };
} }
if (hasConflict(shift.date, shift.startTime, shift.endTime, staffId, shiftId)) { if (
hasConflict(
shift.date,
shift.startTime,
shift.endTime,
staffId,
shiftId,
)
) {
return { return {
success: false, success: false,
error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.", error: "Xung đột lịch! Bạn đã có ca làm trùng giờ trong ngày này.",
@@ -210,7 +234,10 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
s.id === shiftId s.id === shiftId
? { ? {
...s, ...s,
registeredStaff: [...s.registeredStaff, { id: staffId, name: staffName }], registeredStaff: [
...s.registeredStaff,
{ id: staffId, name: staffName },
],
status: "registered" as ShiftStatus, status: "registered" as ShiftStatus,
} }
: s, : s,
@@ -230,7 +257,8 @@ export function ShiftProvider({ children }: { children: ReactNode }) {
return { return {
...s, ...s,
registeredStaff: updated, registeredStaff: updated,
status: updated.length === 0 ? ("available" as ShiftStatus) : s.status, status:
updated.length === 0 ? ("available" as ShiftStatus) : s.status,
}; };
}), }),
); );
+13 -1
View File
@@ -52,6 +52,14 @@ export interface SocialLinks {
website: string; website: string;
} }
// ===== EATERY (GRAPHQL) TYPES =====
export interface Eatery {
id: string;
ownerId: string;
name: string;
isVerified: boolean;
}
// ===== SHOP (QUÁN NƯỚC) TYPES ===== // ===== SHOP (QUÁN NƯỚC) TYPES =====
export interface Shop { export interface Shop {
id: number; id: number;
@@ -116,7 +124,11 @@ export interface Combo {
} }
// ===== SHIFT / SCHEDULE TYPES ===== // ===== SHIFT / SCHEDULE TYPES =====
export type ShiftStatus = "available" | "registered" | "approved_leave" | "absent"; export type ShiftStatus =
| "available"
| "registered"
| "approved_leave"
| "absent";
export interface RegisteredStaff { export interface RegisteredStaff {
id: number; id: number;
+9
View File
@@ -19,6 +19,15 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
async rewrites() {
const gatewayUrl = "http://localhost:32080";
return [
{
source: "/api/:path*",
destination: `${gatewayUrl}/api/:path*`,
},
];
},
}; };
export default nextConfig; export default nextConfig;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "temp", "name": "temp",
"version": "1.1.0", "version": "1.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "temp", "name": "temp",
"version": "1.1.0", "version": "1.2.3",
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.37", "@types/node": "^20.19.37",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp", "name": "temp",
"version": "1.1.0", "version": "1.2.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -29,7 +29,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.1.7",
"prettier": "^3.8.1", "prettier": "^3.8.3",
"prettier-plugin-embed": "^0.5.1", "prettier-plugin-embed": "^0.5.1",
"prettier-plugin-groovy": "^0.2.1", "prettier-plugin-groovy": "^0.2.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
+289 -254
View File
File diff suppressed because it is too large Load Diff
-135
View File
@@ -1,135 +0,0 @@
# Feature Prompt: Register Shift (Đăng Ký Ca Làm)
## Tổng Quan
Xây dựng tính năng **Register Shift** cho phép nhân viên xem và đăng ký ca làm việc theo khung thời gian do quản lý tạo ra. Tính năng hỗ trợ cả hai vai trò: **Manager****Staff**, với giao diện responsive thích ứng theo kích thước màn hình.
---
## Mục Đích
- Cho phép nhân viên xem danh sách các khung thời gian ca làm khả dụng và đăng ký vào ca mà họ muốn làm.
- Quản lý (Manager) có toàn quyền tạo, chỉnh sửa và xóa các khung thời gian ca làm.
- Nhân viên có thể thấy ca làm của đồng nghiệp để tránh xung đột hoặc điều chỉnh lịch phù hợp.
- Cả Manager và Staff đều có thể chỉnh sửa hoặc hủy ca đã đăng ký của nhân viên.
---
## Vai Trò & Phân Quyền
### Manager
- Tạo, chỉnh sửa, xóa khung thời gian ca làm (shift slots).
- Xem lịch làm việc của toàn bộ nhân viên theo tuần hoặc tháng.
- Phê duyệt hoặc từ chối yêu cầu đăng ký ca.
- Xóa hoặc thay đổi ca làm của bất kỳ nhân viên nào.
- Gán ca làm cho nhân viên cụ thể nếu cần.
### Staff (Nhân Viên)
- Xem danh sách ca làm khả dụng trong tuần hoặc tháng.
- Đăng ký vào ca mà mình muốn làm.
- Xem ca đã được đăng ký bởi các nhân viên khác.
- Hủy hoặc chỉnh sửa ca đã đăng ký của bản thân (trong giới hạn thời gian cho phép).
---
## Yêu Cầu Giao Diện
### Desktop (≥ 1024px) — Dạng Bảng Lịch Tuần/Tháng
- Hiển thị lịch làm việc dạng **table theo tuần** (mặc định), với khả năng chuyển sang **xem theo tháng**.
- Mỗi cột đại diện cho một ngày trong tuần (MonSun).
- Mỗi hàng đại diện cho một nhân viên hoặc một khung giờ.
- Các ca làm được hiển thị dưới dạng **card màu** trong ô tương ứng, gồm:
- Tên ca / Khung giờ (ví dụ: `08:00 12:00`)
- Số giờ làm & mức lương dự kiến (ví dụ: `4h · 120k`)
- Trạng thái: `Available`, `Registered`, `Approved Leave`, `Absent`
- **Bộ lọc tuần/tháng** ở góc trên, cho phép điều hướng qua lại giữa các tuần/tháng.
- Hiển thị **tổng ngân sách tuần** (Weekly Budget) ở đầu bảng.
- Nhóm nhân viên theo **department/role** (ví dụ: Bar Staff, Janitors,...).
- Mỗi ca làm có thể click để xem chi tiết, chỉnh sửa hoặc xóa.
#### Chế Độ Xem Theo Tháng (Month View)
- Hiển thị dạng **calendar grid** (lưới 7 cột × ~5 hàng).
- Mỗi ô ngày hiển thị số ca đã đăng ký hoặc dấu chấm màu trạng thái (tương tự ảnh tham khảo).
- Cho phép nhân viên và manager tính toán ngày có thể nghỉ hoặc đăng ký ca xa hơn trong tháng.
---
### Mobile (< 768px) — Dạng Lịch Dọc + Dot Indicator
- Hiển thị **calendar theo tháng dạng nhỏ gọn** (compact calendar) ở đầu màn hình.
- Các ngày có ca làm được đánh dấu bằng **dot màu** phía dưới số ngày:
- 🟡 Vàng: Ca đang mở / khả dụng
- 🟢 Xanh lá: Ca đã được đăng ký
- ⚫ Xám: Không có ca
- Ngày hiện tại được highlight bằng vòng tròn (dark circle).
- Khi chọn một ngày, hiển thị danh sách ca làm của ngày đó bên dưới.
- Mỗi ca làm hiển thị dưới dạng **card dọc**:
- Khung giờ, số giờ, mức lương
- Tên nhân viên đã đăng ký (nếu có)
- Nút **Đăng ký** hoặc **Hủy đăng ký**
- Điều hướng tháng bằng nút `<``>` ở hai bên tiêu đề tháng.
---
## Trạng Thái Ca Làm (Shift Status)
| Trạng Thái | Màu | Mô Tả |
|----------------|-----------------|-----------------------------------|
| Available | Xanh dương nhạt | Ca đang mở, chưa có ai đăng ký |
| Registered | Xanh dương đậm | Nhân viên đã đăng ký ca này |
| Approved Leave | Tím/Lavender | Nhân viên đã được duyệt nghỉ phép |
| Absent | Đỏ/Hồng | Nhân viên vắng mặt không có lý do |
---
## Luồng Chức Năng Chính
### Staff — Đăng Ký Ca
1. Truy cập màn hình Register Shift.
2. Chọn tuần hoặc tháng muốn xem.
3. Xem các ca làm khả dụng (Available).
4. Click vào ca → Xem thông tin chi tiết (giờ, lương, số người đã đăng ký).
5. Nhấn **Register** để đăng ký ca.
6. Ca chuyển sang trạng thái **Registered**.
### Manager — Tạo Ca Làm Mới
1. Vào giao diện dashboard của manager.
2. Vào giao diện lịch của nhóm/department.
3. Click vào ô ngày muốn tạo ca.
4. Nhập thông tin: Khung giờ bắt đầu kết thúc, số lượng nhân viên cần, mức lương.
5. Lưu → Ca hiển thị trên lịch với trạng thái **Available**.
### Xóa / Chỉnh Sửa Ca
- **Manager**: Có thể chỉnh sửa hoặc xóa bất kỳ ca nào của bất kỳ nhân viên nào.
- **Staff**: Chỉ có thể hủy ca của bản thân, trong phạm vi thời gian cho phép (ví dụ: trước 24h so với giờ bắt đầu ca).
---
## Dữ Liệu & Logic
### Shift Slot Object
```json
{
"id": "shift_001",
"date": "2025-04-22",
"startTime": "08:00",
"endTime": "12:00",
"durationHours": 3.5,
"wage": 80,
"department": "Bar Staff",
"maxStaff": 2,
"registeredStaff": [
{ "id": "staff_01", "name": "Carol Saragosa" }
],
"status": "registered"
}
```
---
## Ghi Chú Bổ Sung
- Nhân viên **không được đăng ký 2 ca trùng giờ** trong cùng một ngày.
- Hệ thống cần hiển thị **cảnh báo xung đột** khi nhân viên cố đăng ký ca bị trùng lịch.
- Cần hỗ trợ **thông báo (notification)** khi ca làm bị thay đổi hoặc bị hủy bởi manager.