diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..31d0ac8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +* text=auto eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c805d2c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev] + workflow_dispatch: + +jobs: + build: + name: Build and Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + # Backend: install deps only (no build script) + - name: Backend install + working-directory: backend + run: | + npm ci || npm install + + # Frontend web: install + build + - name: Frontend install + working-directory: frontend + run: | + npm ci || npm install + - name: Frontend build + working-directory: frontend + run: | + npm run build + + # Admin web: install + build + - name: Admin install + working-directory: admin + run: | + npm ci || npm install + - name: Admin build + working-directory: admin + run: | + npm run build + + # Users Admin web: install + build + - name: Users Admin install + working-directory: users-admin + run: | + npm ci || npm install + - name: Users Admin build + working-directory: users-admin + run: | + npm run build + + # Mobile (Expo): install + lint only (skip heavy builds) + - name: Mobile install + working-directory: mobile + run: | + npm ci || npm install + - name: Mobile lint (if configured) + working-directory: mobile + run: | + npm run lint --if-present diff --git a/.gitignore b/.gitignore index 1daaee2..065459d 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* +.continue/ diff --git a/README.md b/README.md index 244fed7..15c8421 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,72 @@ # Hangry -Hangry is a full-stack web application built using the MERN (MongoDB, Express.js, React, Node.js) stack. It aims to provide users with a smooth and interactive experience to explore and order food when they're feeling "hangry" (hungry + angry). +Hangry là hệ sinh thái đặt đồ ăn đa kênh xây trên MERN + Expo. Người dùng có thể duyệt menu, thêm giỏ, thanh toán (Stripe/COD) và theo dõi đơn; đội vận hành có portal để quản lý món, đơn và người dùng. ## Tech Stack +MongoDB · Express.js · React (Vite) · Node.js · React Native (Expo) · Stripe · Cloudinary/Uploads · JWT -

- MongoDB - Express.js - React - Node.js - JavaScript - HTML5 - CSS3 - Stripe - Render -

- -## Table of Contents - +## Mục Lục - [Live Link](#live-link) -- [Features](#features) +- [Tính Năng](#tính-năng) - [Tech Stack](#tech-stack) -- [Installation](#installation) -- [Usage](#usage) -- [API Endpoints](#api-endpoints) -- [Contributing](#contributing) +- [Cài Đặt](#cài-đặt) +- [Sử Dụng](#sử-dụng) +- [API Chính](#api-chính) +- [Đóng Góp](#đóng-góp) - [License](#license) -## Live-Link - -- You can view the live link [here.](https://hangry-frontend.onrender.com/) - -## Features - -- User authentication and authorization (JWT-based). -- Explore a variety of food items from different categories. -- Add items to cart and manage orders. -- Interactive UI with real-time updates. -- Fully responsive design. -- Backend RESTful API for handling data. - -## Tech Stack - -- **Frontend**: React.js -- **Backend**: Node.js, Express.js -- **Database**: MongoDB -- **Authentication**: JWT (JSON Web Tokens) -- **UI Library**: Material-UI / Bootstrap (mention if any) - -## Installation - -1. Clone the repository: - - ```bash - git clone https://github.com/aayushai/Hangry.git - ``` - -2. Navigate to the project directory: - - ```bash - cd Hangry - ``` - -3. Install dependencies for both frontend and backend: - - ```bash - # Install backend dependencies - cd backend - npm install - - # Install frontend dependencies - cd ../frontend - npm install - ``` - -4. Set up environment variables: - - - Create a `.env` file in the `backend` folder and configure the following variables: - ```bash - MONGO_URI= - JWT_SECRET= - ``` - -5. Run the application: - - - Start the backend: - ```bash - cd backend - npm run dev - ``` - - - Start the frontend: - ```bash - cd ../frontend - npm start - ``` - - The app should now be running on `http://localhost:3000`. - -## Usage - -- Register or login to your account. -- Browse food categories and add items to your cart. -- Review and place orders. -- View and manage your profile and order history. - -## API Endpoints - -Here are some key API endpoints for the backend (located in the `backend/routes` folder): - -- **User Routes**: - - `POST /api/users/login`: User login. - - `POST /api/users/register`: User registration. - -- **Food Routes**: - - `GET /api/food`: Get all food items. - - `POST /api/food`: Add a new food item (Admin only). - -- **Order Routes**: - - `POST /api/orders`: Create a new order. - - `GET /api/orders/:id`: Get order by ID. - -## Contributing - -If you'd like to contribute to this project, please follow these steps: - -1. Fork the repository. -2. Create your feature branch (`git checkout -b feature/AmazingFeature`). -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`). -4. Push to the branch (`git push origin feature/AmazingFeature`). -5. Open a pull request. +## Live Link +- Web khách hàng: https://hangry-frontend.onrender.com +- Backend mẫu: https://hangry-backend.onrender.com + +## Tính Năng +- Đăng ký/đăng nhập JWT, phân quyền user/admin. +- Duyệt danh mục, thêm/giảm giỏ, thanh toán Stripe hoặc COD. +- Portal admin thêm món (upload ảnh), duyệt menu, cập nhật trạng thái đơn. +- Console quản lý người dùng (lọc, khóa/mở, đổi role, xóa). +- Ứng dụng di động Expo tabs: Home, Cart, Orders, Profile. + +## Cài Đặt +```bash +git clone https://github.com/TNTDanh/Software_Hangry.git +cd Software_Hangry + +cd backend && npm install +cd ../frontend && npm install +cd ../admin && npm install +cd ../users-admin && npm install +cd ../mobile && npm install +``` + +### Biến môi trường +- `backend/.env`: `PORT`, `MONGO_URI`, `JWT_SECRET`, `CLIENT_URL`, `FRONTEND_URL`, `STRIPE_SECRET_KEY`, các khóa Cloudinary (nếu dùng). +- `frontend/.env`, `admin/.env`, `users-admin/.env`: `VITE_API_URL=http://localhost:4000`. +- `mobile/src/api/client.ts` & `mobile/src/until/image.ts`: cập nhật `API_URL` phù hợp (VD: `http://10.0.2.2:4000` trên Android emulator). + +### Chạy dự án +| Module | Lệnh | +| ------------- | --------------------------- | +| Backend | `cd backend && npm run server` | +| Web khách | `cd frontend && npm run dev` | +| Admin portal | `cd admin && npm run dev` | +| User console | `cd users-admin && npm run dev`| +| Mobile (Expo) | `cd mobile && npx expo start` | + +## Sử Dụng +1. Mở web/app → đăng ký/đăng nhập. +2. Duyệt menu, lọc danh mục, thêm món vào giỏ. +3. Đặt đơn (Stripe redirect hoặc COD) và theo dõi trạng thái. +4. Admin dùng portal để thêm món, chỉnh đơn; console để quản lý người dùng. + +## API Chính +- **User:** `POST /api/user/register`, `POST /api/user/login`, `GET /api/user/list`, `POST /api/user/update`, `POST /api/user/remove`. +- **Food:** `POST /api/food/add`, `GET /api/food/list`, `POST /api/food/remove`. +- **Cart:** `POST /api/cart/add`, `/remove`, `/get`. +- **Order:** `POST /api/order/place`, `/place-cod`, `/verify`, `/userorders`, `GET /api/order/list`, `POST /api/order/status`. + +## Đóng Góp +1. Fork repo & tạo nhánh `feat/`. +2. Commit, push, mở Pull Request về nhánh `dev`. ## License - -This project is licensed under the MIT License. +Dự án phát hành theo [MIT](LICENSE). diff --git a/admin/index.html b/admin/index.html index d9b8885..dc9777c 100644 --- a/admin/index.html +++ b/admin/index.html @@ -4,7 +4,7 @@ - D&P's Admin + D&P RESTAURANT
diff --git a/admin/public/header_img.png b/admin/public/header_img.png new file mode 100644 index 0000000..128c666 Binary files /dev/null and b/admin/public/header_img.png differ diff --git a/admin/src/App.jsx b/admin/src/App.jsx index ce4a544..bf92d1b 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -1,33 +1,63 @@ -import React from 'react' -import Navbar from './components/Navbar/Navbar' -import Sidebar from './components/Sidebar/Sidebar' -import { Routes,Route } from 'react-router-dom' -import Add from './pages/Add/Add' -import List from './pages/List/List' -import Orders from './pages/Orders/Orders' -import { ToastContainer } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; +import React from "react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import Navbar from "./components/Navbar/Navbar"; +import Sidebar from "./components/Sidebar/Sidebar"; +import Add from "./pages/Add/Add"; +import List from "./pages/List/List"; +import Orders from "./pages/Orders/Orders"; +import Login from "./pages/Login/Login"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import useAuth from "./auth/useAuth.jsx"; +import useUI from "./ui/useUI.jsx"; const App = () => { + const url = import.meta.env.VITE_API_URL || "http://localhost:5000"; + const { token, role, logout } = useAuth(); + const { t } = useUI(); - // const url = "http://localhost:4000" - const url = "https://hangry-backend.onrender.com" + if (!token) { + return ( + <> + + + + ); + } + + const allowedRoles = ["admin", "restaurantOwner"]; + if (!allowedRoles.includes(role)) { + return ( + <> + +
+
+

{t("noPermission")}

+

Vui lòng đăng nhập bằng tài khoản admin hoặc chủ nhà hàng.

+ +
+
+ + ); + } return ( -
- - -
+
+ +
- - - }/> - }/> - }/> - + +
+ + } /> + } /> + } /> + } /> + +
- ) -} + ); +}; -export default App \ No newline at end of file +export default App; diff --git a/admin/src/assets/assets.js b/admin/src/assets/assets.js index 252d1fe..ade9009 100644 --- a/admin/src/assets/assets.js +++ b/admin/src/assets/assets.js @@ -14,4 +14,4 @@ export const assets ={ parcel_icon } -export const url = 'https://hangry-backend.onrender.com' \ No newline at end of file +export const url = import.meta.env.VITE_API_URL || "http://localhost:5000"; diff --git a/admin/src/assets/bag_icon.png b/admin/src/assets/bag_icon.png new file mode 100644 index 0000000..c5d6653 Binary files /dev/null and b/admin/src/assets/bag_icon.png differ diff --git a/admin/src/auth/useAuth.jsx b/admin/src/auth/useAuth.jsx new file mode 100644 index 0000000..1200753 --- /dev/null +++ b/admin/src/auth/useAuth.jsx @@ -0,0 +1,68 @@ +import { createContext, useContext, useEffect, useMemo, useState } from "react"; + +const STORAGE_KEY = "admin_auth"; + +const AuthContext = createContext(null); + +const loadFromStorage = () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { token: "", role: "admin", restaurantIds: [] }; + const parsed = JSON.parse(raw); + return { + token: parsed.token || "", + role: parsed.role || "admin", + restaurantIds: parsed.restaurantIds || [], + }; + } catch { + return { token: "", role: "admin", restaurantIds: [] }; + } +}; + +export const buildAuthHeaders = (token) => + token + ? { token, Authorization: `Bearer ${token}` } + : {}; + +export const AuthProvider = ({ children }) => { + const [{ token, role, restaurantIds }, setAuth] = useState(loadFromStorage); + + const login = (payload) => { + const data = { + token: payload.token, + role: payload.role || "admin", + restaurantIds: payload.restaurantIds || [], + }; + setAuth(data); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + }; + + const logout = () => { + setAuth({ token: "", role: "admin", restaurantIds: [] }); + localStorage.removeItem(STORAGE_KEY); + }; + + // sync across tabs + useEffect(() => { + const handler = () => setAuth(loadFromStorage()); + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, []); + + const value = useMemo( + () => ({ token, role, restaurantIds, login, logout }), + [token, role, restaurantIds] + ); + + return {children}; +}; + +const useAuth = () => { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +}; + +export default useAuth; diff --git a/admin/src/components/Navbar/Navbar.css b/admin/src/components/Navbar/Navbar.css index 08d7684..4f28493 100644 --- a/admin/src/components/Navbar/Navbar.css +++ b/admin/src/components/Navbar/Navbar.css @@ -1,212 +1,80 @@ -.navbar{ - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 4%; -} -.navbar .logo{ - width: max(10%,80px); -} - -.navbar .profile{ - width: 40px; -} - -#visual-toggle-button{ - width: 140px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - position: relative; - border: 1px solid var(--border-color); - border-radius: 5px; - cursor: pointer; -} -/* checkbox */ -#visual-toggle{ - appearance: none; - display: none; -} - -.svgIcon-toggle{ - width: 25px; -} - -.moon-svg{ - width: 13px; - position: absolute; - transition: all .3s; -} -.sun-svg{ - opacity: 0; - transition: all .3s; -} -.lightmode .sun-svg{ - filter: brightness(0); - transform: rotate(180deg); - opacity: 1; -} -.lightmode .moon-svg{ - opacity: 0; -} -.animate .moon-svg{ - transform: rotate(360deg); - transition: all .3s; -} - -.lightcolors .logo { - filter: none; -} - - -:root .addd { - filter: var(--logos-filter); -} - -.lightcolors .addd { - filter: none; -} - -:root .listt { - filter: var(--logos-filter); -} - -.lightcolors .listt { - filter: none; -} - -:root .orderr { - filter: var(--logos-filter); -} - -.lightcolors .orderr { - filter: none; -} - -:root p{ - color: var(--light-text); -} - -.lightcolors p{ - filter: none; -} - -:root .list-table-format.title{ - background-color: var(--body-color); -} - -.lightcolors .list-table-format.title{ - filter: none; -} - -:root .add-product-name input:focus, -:root .add-product-description textarea:focus { - outline: black; -} - - - -.lightmode .add-product-name input:focus, -.lightmode .add-product-description textarea:focus { - outline: black; -} - - -/* DARK MODE FOR PRODUCTNAME & PRODUCTDESCRIPTION */ -:root .add-product-name input, -:root .add-product-description textarea { - background-color: var(--background-root); - border: var(--border-root); - padding: var(--padding-root); - color: white; /* Add this line to set text color to white */ -} - -/* LIGHT MODE FOR PRODUCTNAME & PRODUCTDESCRIPTION */ -.lightcolors .add-product-name input, -.lightcolors .add-product-description textarea { - background-color: white; /* Background color for light mode */ - border: 2px solid gray; /* Border color for light mode */ - color: black; -} - -:root .add-product-name input::placeholder, -:root .add-product-description textarea::placeholder { - color: gray; /* Set the placeholder text color to white */ -} - -.lightcolors .add-product-name input::placeholder, -.lightcolors .add-product-description textarea::placeholder { - color: black; -} - - -:root .inputclasa, -:root .selectt { - color: white; - border: 1px solid white; - background-color: black; -} - - -.lightcolors .inputclasa, -.lightcolors .selectt { - border: 1px solid gray; - background-color: transparent; - color: black; -} - -:root .inputclasa::placeholder, -:root .selectt::placeholder { - color: gray; -} - - - - -:root .profile { - filter: var(--logos-filter); -} - -.lightcolors .profile { - filter: none; -} - - -:root { - --input-color: black; /* Define custom color for input elements */ - --background-root: black; - --border-root: 2px solid white; - --padding-root: 10px; - --upload: brightness(1) invert(1); /* Added a custom variable */ - --logos-filter: brightness(0) invert(1); /* Added a custom variable */ - --sidebar: black; - --border-color: #27272a; - --nav-color-one: #0d0d179a; - --nav-color-two: #0d0618; - --body-color: #121212; - --dark-text: #101010; - --light-text: #ffffff; - --light-text-secondary: #b9b9b9; - --light-text-desc: #d8d8d8; -} - -/* For lightcolors */ -.lightcolors { - --sidebar: #fff0ed; - --body-color: #FFFFFF; - --dark-text: #fff; - --light-text: #101010; - --nav-color-two: #f6f6f2; - --nav-color-one: #f1f1f17e; - --border-color: #ddd; - --light-text-secondary: #464853; - --light-text-desc: #020202; - --cards-text-color: #fff; /* White text color for cards container */ -} - -/* Reset filter for images with class "image" in light mode */ -.lightcolors label img.image { - filter: none; /* Reset filter for images with class "image" in light mode */ -} - +.admin-navbar{ + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 4%; + background: linear-gradient(120deg, var(--nav-color-one), var(--nav-color-two)); + border-bottom: 1px solid var(--panel-border); + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1200; + height: var(--nav-height); + backdrop-filter: blur(8px); +} + +.nav-left{ + display: flex; + align-items: center; + gap: 12px; +} + +.logo{ + width: 120px; +} + +.brand{ + display: flex; + flex-direction: column; + line-height: 1.1; + color: var(--text-primary); +} +.brand-top{ + font-weight: 800; + font-size: 15px; +} +.brand-sub{ + font-size: 13px; + color: var(--text-secondary); +} + +.nav-right{ + display: flex; + align-items: center; + gap: 10px; +} + +.pill-btn{ + border: 1px solid var(--panel-border); + background: var(--panel-color); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + transition: all .2s ease; +} +.pill-btn.ghost{ + background: rgba(255,255,255,0.05); +} +.pill-btn:hover{ + filter: brightness(1.05); + box-shadow: 0 8px 18px rgba(0,0,0,0.2); +} +.pill-btn:active{ + transform: translateY(1px); +} + +.logout-btn{ + border: 1px solid rgba(255,255,255,0.18); + background: linear-gradient(180deg, var(--accent), #f87171); + color: #fff; + padding: 10px 14px; + border-radius: 12px; + cursor: pointer; + font-weight: 800; + box-shadow: 0 6px 14px rgba(0,0,0,.35); +} +.logout-btn:hover{ filter: brightness(1.03); } +.logout-btn:active{ transform: translateY(1px); } diff --git a/admin/src/components/Navbar/Navbar.jsx b/admin/src/components/Navbar/Navbar.jsx index e28591f..8ffa93d 100644 --- a/admin/src/components/Navbar/Navbar.jsx +++ b/admin/src/components/Navbar/Navbar.jsx @@ -1,56 +1,50 @@ -import React, { useEffect } from 'react' +import React from 'react' import './Navbar.css' -import {assets} from '../../assets/assets' +import { assets } from '../../assets/assets' +import useAuth from '../../auth/useAuth.jsx' +import useUI from '../../ui/useUI.jsx' +import { toast } from 'react-toastify' const Navbar = () => { - useEffect(() => { - const toggle = document.getElementById('visual-toggle'); + const { logout, role } = useAuth() + const { theme, toggleTheme, lang, toggleLang, t } = useUI() - // Function to apply the stored mode preference - function applyModePreference() { - const mode = localStorage.getItem('mode'); - if (mode === 'light') { - toggle.checked = true; - document.body.classList.add('lightcolors'); - document.getElementById('visual-toggle-button').classList.add('lightmode'); - } else { - toggle.checked = false; - document.body.classList.remove('lightcolors'); - document.getElementById('visual-toggle-button').classList.remove('lightmode'); - } + const handleLogout = () => { + if (window.confirm(t("confirmLogout"))) { + logout() + toast.success(t("loggedOut")) } + } - // Call the function to apply the mode preference on page load - applyModePreference(); + const modeIcon = theme === 'light' ? '🌞' : '🌙' + const nextLang = lang === 'vi' ? 'EN' : 'VI' - toggle.addEventListener('change', function() { - if (toggle.checked) { - localStorage.setItem('mode', 'light'); - document.body.classList.add('lightcolors'); - document.getElementById('visual-toggle-button').classList.add('lightmode'); - } else { - localStorage.setItem('mode', 'dark'); - document.body.classList.remove('lightcolors'); - document.getElementById('visual-toggle-button').classList.remove('lightmode'); - } - }); - }, []); // Empty dependency array to run the effect only once return ( -
-
- - +
+
+ Hangry Admin +
+

{t("adminPortal")}

+ ({role}) +
-
- +
+ + +
-
+
) } diff --git a/admin/src/components/Sidebar/Sidebar.css b/admin/src/components/Sidebar/Sidebar.css index d9ee9a3..6cda0d5 100644 --- a/admin/src/components/Sidebar/Sidebar.css +++ b/admin/src/components/Sidebar/Sidebar.css @@ -1,36 +1,44 @@ .sidebar{ - width: 18%; - min-height: 100vh; - border: 1.5px solid #a9a9a9; - border-top: 0; - font-size: max(1vw, 10px); + position: fixed; + top: var(--nav-height); + left: 0; + height: calc(100vh - var(--nav-height)); + width: var(--sidebar-width); + border-right: 1px solid var(--panel-border); + background: var(--panel-color); + padding: 18px 12px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 8px 0 24px rgba(0,0,0,0.12); } -.sidebar-options{ - padding-top: 50px; - padding-left: 20%; - display: flex; - flex-direction: column; - gap: 20px; + +.sidebar-item{ + display: flex; + align-items: center; + gap: 12px; + padding: 12px 12px; + border-radius: 12px; + color: var(--text-primary); + transition: all .2s ease; + font-weight: 700; } -.sidebar-option{ - display: flex; - align-items: center; - gap: 12px; - border: 1px solid #a9a9a9; - border-right: 0; - padding: 8px 10px; - border-radius: 3px 0px 0px 3px; - cursor: pointer; +.sidebar-item img{ + width: 22px; + height: 22px; } - -.sidebar-option.active{ - background-color: var(--sidebar); - border-color: tomato; +.sidebar-item:hover{ + background: rgba(255,255,255,0.05); +} +.sidebar-item.active{ + background: linear-gradient(120deg, rgba(255,122,95,0.18), rgba(139,92,246,0.15)); + border: 1px solid rgba(255,255,255,0.06); + box-shadow: 0 10px 24px rgba(0,0,0,0.18); } - -@media (max-width:900px) { - .sidebar-option p{ - display: none; - } -} \ No newline at end of file +.sidebar-note{ + margin-top: auto; + padding: 10px 12px; + color: var(--text-secondary); + font-size: 13px; +} diff --git a/admin/src/components/Sidebar/Sidebar.jsx b/admin/src/components/Sidebar/Sidebar.jsx index 3d690a5..1a6a314 100644 --- a/admin/src/components/Sidebar/Sidebar.jsx +++ b/admin/src/components/Sidebar/Sidebar.jsx @@ -1,27 +1,35 @@ import React from 'react' import './Sidebar.css' +import { Link, useLocation } from 'react-router-dom' import { assets } from '../../assets/assets' -import { NavLink } from 'react-router-dom' +import useUI from '../../ui/useUI.jsx' + +const Sidebar = ({ role }) => { + const location = useLocation() + const { t } = useUI() + const isActive = (path) => location.pathname.startsWith(path) -const Sidebar = () => { return ( -
-
- - -

Add Items

-
- - -

List Items

-
- - -

Orders

-
+
+ )} + ) } -export default Sidebar \ No newline at end of file +export default Sidebar diff --git a/admin/src/index.css b/admin/src/index.css index 2a39c69..fea2f91 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -1,15 +1,46 @@ -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); + +:root { + --body-color: #0c0f17; + --body-elevated: linear-gradient(145deg, rgba(255,255,255,0.04), rgba(255,255,255,0)); + --panel-color: #111624; + --panel-border: #1f2333; + --nav-color-one: #141a2b; + --nav-color-two: #0f1420; + --text-primary: #f5f7ff; + --text-secondary: #98a0b6; + --accent: #ff7a5f; + --accent-2: #8b5cf6; + --shadow-strong: 0 20px 60px rgba(0,0,0,0.35); + --nav-height: 72px; + --sidebar-width: 240px; +} + +[data-theme='light'] { + --body-color: #f8fafc; + --body-elevated: #ffffff; + --panel-color: #ffffff; + --panel-border: #e5e7eb; + --nav-color-one: #ffffff; + --nav-color-two: #f2f4f8; + --text-primary: #0f172a; + --text-secondary: #4b5563; + --accent: #2563eb; + --accent-2: #10b981; + --shadow-strong: 0 12px 32px rgba(0,0,0,0.12); +} *{ padding: 0; margin: 0; box-sizing: border-box; - font-family: Outfit; + font-family: 'Space Grotesk', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } body{ min-height: 100vh; - background-color: var(--body-color); + background: var(--body-color); + color: var(--text-primary); } a{ @@ -20,15 +51,83 @@ a{ hr{ border: none; height: 1px; - background-color: #a9a9a9; + background-color: var(--panel-border); +} + +.admin-app{ + min-height: 100vh; + background: radial-gradient(circle at 20% 20%, rgba(139,92,246,0.12), transparent 35%), + radial-gradient(circle at 80% 0%, rgba(255,122,95,0.16), transparent 35%), + var(--body-color); } .app-content{ display: flex; + align-items: flex-start; + margin-top: var(--nav-height); + padding-left: var(--sidebar-width); +} + +.app-main{ + flex: 1; + padding: 32px; + min-height: calc(100vh - var(--nav-height)); + color: var(--text-primary); + overflow-x: hidden; } .flex-col{ display: flex; flex-direction: column; gap: 10px; -} \ No newline at end of file +} +.muted{ + color: var(--text-secondary); +} + +.unauthorized{ + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(circle at 30% 30%, rgba(255,122,95,0.15), transparent 40%), + radial-gradient(circle at 70% 70%, rgba(139,92,246,0.12), transparent 35%), + var(--body-color); + color: var(--text-primary); + padding: 32px; +} +.unauth-card{ + max-width: 520px; + width: 100%; + background: var(--panel-color); + border: 1px solid var(--panel-border); + border-radius: 16px; + padding: 28px; + text-align: center; + box-shadow: var(--shadow-strong); +} +.unauth-title{ + font-size: 20px; + font-weight: 800; + margin-bottom: 8px; +} +.unauth-sub{ + color: var(--text-secondary); + margin-bottom: 18px; +} +.unauth-card button{ + padding: 12px 18px; + border-radius: 12px; + border: none; + background: linear-gradient(180deg, var(--accent), #f87171); + color: #fff; + font-weight: 800; + cursor: pointer; + box-shadow: 0 10px 26px rgba(0,0,0,0.25); +} +.unauth-card button:hover{ + filter: brightness(1.05); +} +.unauth-card button:active{ + transform: translateY(1px); +} diff --git a/admin/src/main.jsx b/admin/src/main.jsx index 5aae6a1..266d06b 100644 --- a/admin/src/main.jsx +++ b/admin/src/main.jsx @@ -3,9 +3,15 @@ import ReactDOM from 'react-dom/client' import App from './App.jsx' import './index.css' import {BrowserRouter} from 'react-router-dom' +import { AuthProvider } from './auth/useAuth.jsx' +import { UIProvider } from './ui/useUI.jsx' ReactDOM.createRoot(document.getElementById('root')).render( - - - + + + + + + + ) diff --git a/admin/src/pages/Add/Add.css b/admin/src/pages/Add/Add.css index 344e8f0..eb3d01a 100644 --- a/admin/src/pages/Add/Add.css +++ b/admin/src/pages/Add/Add.css @@ -1,40 +1,180 @@ .add{ - width: 70%; - margin-left: max(5vw, 25px); - margin-top: 50px; - color: var(--light-text); - font-size: 16px; + width: 100%; + max-width: 1040px; + margin: 0 auto; + padding-top: 24px; + color: var(--text-primary); + font-size: 16px; } -.add form{ - gap: 20px; + +.add-hero{ + margin-bottom: 12px; } -.add-img-upload img{ - width: 120px; +.add-hero h2{ + margin: 0; + font-size: 26px; } -.add-product-name .add-product-description{ - width: max(40%,280px); +.add-hero .muted{ + color: var(--text-secondary); + margin-top: 6px; } -.add-product-name input, .add-product-description textarea{ - padding: 10px; + +.add-form{ + display: flex; + flex-direction: column; + gap: 18px; + background: var(--panel-color); + border: 1px solid var(--panel-border); + border-radius: 16px; + padding: 22px; + box-shadow: var(--shadow-strong); } +.add-section{ + display: grid; + grid-template-columns: 240px 1fr; + gap: 22px; +} + +.label{ + font-weight: 800; + color: var(--text-primary); + margin-bottom: 8px; +} + +.add-img-upload{ + display: flex; + flex-direction: column; + gap: 10px; +} + +.upload-box{ + display: inline-flex; + align-items: center; + justify-content: center; + width: 220px; + height: 220px; + border: 1px dashed var(--panel-border); + border-radius: 14px; + background: rgba(255,255,255,0.02); + cursor: pointer; + transition: background-color .2s ease, box-shadow .2s ease, transform .05s ease, border-color .2s ease; +} + +.upload-box:hover{ + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.4); + box-shadow: 0 8px 18px rgba(0,0,0,0.2); +} + +.preview{ + width: 92%; + height: 92%; + object-fit: cover; + border-radius: 12px; +} + +.hint{ + font-style: italic; + color: var(--text-secondary); + font-size: 12px; +} + +/* Fields */ +.add-fields{ + display: flex; + flex-direction: column; + gap: 14px; +} + +.add-product-name input, +.add-product-description textarea, +.add-product-name select, +.selectt{ + width: 100%; + padding: 12px; + border-radius: 10px; + border: 1px solid var(--panel-border); + background-color: var(--body-color); + color: var(--text-primary); + resize: vertical; + transition: border-color .15s ease, box-shadow .15s ease; +} + +.add-product-name input:focus, +.add-product-description textarea:focus, +.add-product-name select:focus{ + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,122,95,0.22); +} + +/* Category + Price row */ .add-category-price{ - display: flex; - gap: 30px; + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.add-category select, +.add-price input{ + min-width: 200px; + padding: 12px; + border-radius: 10px; + border: 1px solid var(--panel-border); + background: var(--body-color); + color: var(--text-primary); + transition: border-color .15s ease, box-shadow .15s ease; } -.add-category-price select, .add-category-price input { - max-width: 120px; - padding: 10px; + +.add-category select:focus, +.add-price input:focus{ + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,122,95,0.22); } + +/* Submit button */ .add-btn{ - max-width: 120px; - border: none; - padding: 10px; - background-color: black; - color: white; - cursor: pointer; + width: 200px; + border: none; + padding: 14px 14px; + background: linear-gradient(180deg, var(--accent), #f87171); + color: white; + font-weight: 900; + border-radius: 12px; + cursor: pointer; + transition: filter .2s ease, box-shadow .2s ease, transform .05s ease; + box-shadow: 0 10px 24px rgba(0,0,0,0.25); +} + +.add-btn:hover{ + filter: brightness(1.03); +} + +.add-btn:active{ + transform: translateY(1px); +} + +.add-btn:disabled{ + opacity: .6; + cursor: not-allowed; + filter: none; + box-shadow: none; } -label img[src*="assets/upload_area"] { - filter: var(--upload); -} \ No newline at end of file +/* Responsive */ +@media (max-width: 900px){ + .add{ + width: 100%; + margin-left: 0; + padding: 0 16px; + } + .add-section{ + grid-template-columns: 1fr; + } + .upload-box{ + width: 200px; + height: 200px; + } +} diff --git a/admin/src/pages/Add/Add.jsx b/admin/src/pages/Add/Add.jsx index 60bfb0a..37e5b5e 100644 --- a/admin/src/pages/Add/Add.jsx +++ b/admin/src/pages/Add/Add.jsx @@ -1,92 +1,237 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import './Add.css' import { assets } from '../../assets/assets' -import axios from "axios" +import axios from 'axios' import { toast } from 'react-toastify' +import useAuth, { buildAuthHeaders } from '../../auth/useAuth.jsx' +import useUI from '../../ui/useUI.jsx' +const Add = ({ url }) => { + const { token, role, restaurantIds } = useAuth() + const { t } = useUI() + const [image, setImage] = useState(null) + const [loading, setLoading] = useState(false) + const [restaurants, setRestaurants] = useState([]) + const [selectedRestaurant, setSelectedRestaurant] = useState('') + const [data, setData] = useState({ + name: '', + nameEn: '', + description: '', + descriptionVi: '', + price: '', + category: 'Salad', + }) -const Add = ({url}) => { + const API_BASE = url || import.meta.env.VITE_API_URL || "http://localhost:5000"; - const [image,setImage] = useState(false); - const [data,setData] = useState({ - name:"", - description:"", - price:"", - category:"Salad" - }) - - const onChangeHandler = (event) => { - const name = event.target.name; - const value = event.target.value; - setData(data=>({...data,[name]:value})) + const fetchRestaurants = async () => { + try { + const headers = buildAuthHeaders(token) + const params = {}; + if (role === 'restaurantOwner' && restaurantIds?.length) { + // backend filters via token; optional cityId can be added here + } + const res = await axios.get(`${API_BASE}/api/restaurant/list`, { params, headers }) + const list = res.data?.data || [] + setRestaurants(list) + if (!selectedRestaurant && list.length) setSelectedRestaurant(list[0]._id) + } catch (err) { + toast.error(err?.response?.data?.message || "Cannot fetch restaurants") } + } - const onSubmitHandler = async (event) =>{ - event.preventDefault(); - const formData = new FormData(); - formData.append("name",data.name) - formData.append("description",data.description) - formData.append("price",Number(data.price)) - formData.append("category",data.category) - formData.append("image",image) - const response = await axios.post(`${url}/api/food/add`,formData) - if (response.data.success) { - setData({ - name:"", - description:"", - price:"", - category:"Salad" - }) - setImage(false) - toast.success(response.data.message) - } - else{ - toast.error(response.data.message) - } - } + useEffect(() => { + fetchRestaurants() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const onChangeHandler = (event) => { + const { name, value } = event.target + setData((prev) => ({ ...prev, [name]: value })) + } + + const onFileChange = (e) => { + const file = e.target.files?.[0] + setImage(file || null) + } + + const onSubmitHandler = async (event) => { + event.preventDefault() + const priceNum = Number(data.price) + if (!selectedRestaurant) return toast.error('Please select a restaurant') + if (!data.name.trim()) return toast.error('Please enter product name') + if (!data.nameEn.trim()) return toast.error('Please enter English name') + if (!data.description.trim()) return toast.error('Please enter EN description') + if (!data.descriptionVi.trim()) return toast.error('Please enter VN description') + if (Number.isNaN(priceNum) || priceNum <= 0) return toast.error('Price must be a positive number') + if (!image) return toast.error('Please upload an image') + + const formData = new FormData() + formData.append('name', data.name.trim()) + formData.append('nameEn', data.nameEn.trim()) + formData.append('description', data.description.trim()) + formData.append('descriptionVi', data.descriptionVi.trim()) + formData.append('price', priceNum) + formData.append('category', data.category) + formData.append('restaurantId', selectedRestaurant) + formData.append('image', image) + + setLoading(true) + try { + const headers = buildAuthHeaders(token) + const response = await axios.post(`${API_BASE}/api/food/add`, formData, { headers }) + if (response.data?.success) { + setData({ name: '', nameEn: '', description: '', descriptionVi: '', price: '', category: 'Salad' }) + setImage(null) + toast.success(response.data.message || 'Added') + } else { + toast.error(response.data?.message || 'Add failed') + } + } catch (err) { + toast.error(err?.response?.data?.message || err.message || 'Network error') + } finally { + setLoading(false) + } + } return ( -
-
-
-

Upload Image

- - setImage(e.target.files[0])} type="file" id="image" hidden required/> +
+
+

{t("addItem")}

+

{t("addPage")}

+

{t("uploadImage")} + {t("category")}

+
+ + +
+
+

{t("uploadImage")}

+ + + PNG/JPG up to ~2MB +
+ +
+
+

{t("restaurant")}

+
-
-

Product name

- + +
+

{t("productNameVi")}

+
-
-

Product Description

- + +
+

{t("productNameEn")}

+
+ +
+

{t("descriptionEn")}

+