chore: hand off generated uau39m app to repair harness
4
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
.env
|
||||
26
frontend/components.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"catalogVersion": "shadcn-radix-v1",
|
||||
"style": "radix",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"lucide-react": "^0.378.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
60
frontend/design/DESIGN.md
Normal file
@ -0,0 +1,60 @@
|
||||
# EatIt design preview
|
||||
|
||||
**Personality:** Savor & Dispatch / Urban Spice
|
||||
|
||||
Rich culinary warmth meets high-precision logistical routing.
|
||||
|
||||
**Design read:** EatIt requires a sharp, operational marketplace visual system that handles high-tempo interactions across three distinct user roles: hunger-driven customers, fast-paced kitchen operators, and mobile couriers. The design balances appetizing food editorial aesthetics on public and discovery views with dense, high-contrast tactical utility for ticket management and live map dispatch.
|
||||
|
||||
## Taste dials
|
||||
- Design variance: 7/10
|
||||
- Motion intensity: 5/10
|
||||
- Visual density: 6/10
|
||||
|
||||
## System
|
||||
- Direction: Warm architectural bistro aesthetic combined with clean telemetry control surfaces.
|
||||
- Default mode: light
|
||||
- Display font: Outfit, sans-serif
|
||||
- Body font: Plus Jakarta Sans, sans-serif
|
||||
- Landing composition: Asymmetric hero with full-bleed culinary visual framing on the right, bold left-aligned manifesto callout, followed by a 3-column interactive pathway section breaking down Customer, Kitchen, and Driver roles.
|
||||
|
||||
## Surfaces
|
||||
|
||||
### landing
|
||||
Navigation: Sticky top bar with EatIt wordmark, operational status indicator, public navigation links, and M-Auth Sign In primary action button.
|
||||
- Public Marketplace Overview (/): Introduce the three-party marketplace network connecting diners, kitchens, and couriers, and provide direct authentication access.
|
||||
- hero-split: Discovery Catalog Grid - Limestone background with primary dark slate text, paprika accent button, and high-contrast telemetry card wrapper.
|
||||
- role-pathways: 3-column interactive role matrix - Elevated white surface cards over limestone base with subtle slate borders and distinct semantic badges.
|
||||
- workflow-timeline: Telemetry Map Split-View - Warm limestone band with structured line dividers and step badges in accent paprika and slate tones.
|
||||
- auth-callout: Dense Admin Metric Matrix - Dark slate inverse surface (#16181B) with bright white text and warm paprika CTA button.
|
||||
|
||||
### application
|
||||
Navigation: Top navigation bar for customer discovery and orders; workspace navbar with role status toggles for kitchen operators and delivery drivers.
|
||||
- Role Onboarding (/app/onboarding): Assign marketplace participant role (Customer, Restaurant Owner, Delivery Partner) upon M-Auth check-in.
|
||||
- role-selection-grid: Centered Selection Grid - Solid white card surfaces over warm limestone base with 1px slate borders.
|
||||
- Customer Discovery & Menu Catalog (/app): Discover local restaurants, search specific dishes, filter food categories, and add meals to cart.
|
||||
- discovery-filter-strip: Discovery Catalog Grid - Limestone sticky bar with subtle 1px border separator.
|
||||
- restaurant-grid-section: Discovery Catalog Grid - Crisp white elevated cards with rounded imagery and crisp paprika badges.
|
||||
- Order Tracking & Telemetry (/app/orders): Follow real-time order lifecycle progress from kitchen prep to live courier map tracking.
|
||||
- active-order-telemetry: Telemetry Map Split-View - High-contrast ticket panel with dark map view frame and crisp status badges.
|
||||
- Kitchen Ticket Management (/app/restaurant): Manage store profile, menu items availability, and incoming order preparation workflow.
|
||||
- kanban-ticket-queue: Kitchen Kanban Ticket Board - High density cards with bold color-coded column headers and thick action buttons.
|
||||
- Delivery Partner Dispatch (/app/delivery): Toggle driver availability, claim open delivery requests, and execute door-to-door drop-offs.
|
||||
- driver-dispatch-board: Telemetry Map Split-View - Crisp white panel overlay on dark map base with bright high-contrast action buttons.
|
||||
|
||||
### admin
|
||||
Navigation: Sidebar-driven operational dashboard with persistent header token status indicator, quick tab switching between System Metrics, User Directory, and Live Marketplace Monitoring.
|
||||
- Marketplace Supervision Panel (/admin): Provide platform operators real-time system metrics monitoring, user directory management, and full order network oversight.
|
||||
- admin-auth-header: Dense Admin Metric Matrix - Dark slate header bar (#16181B) with crisp 1px subtle slate border and contrast badge.
|
||||
- system-metrics-overview: Dense Admin Metric Matrix - Crisp white cards on limestone background with slate metric typography and warm paprika callout accents.
|
||||
- live-orders-monitoring: Telemetry Map Split-View - High-density data grid with zebra striping, explicit WCAG AA status pills, and monospaced order identifiers.
|
||||
- user-directory-management: Discovery Catalog Grid - Clean tabular design with distinct role badges (Paprika for Customer, Slate for Kitchen, Emerald for Driver).
|
||||
|
||||
## Generated image placements
|
||||
- landing-01-hero-split: landing / / hero-split
|
||||
- landing-02-role-pathways: landing / / role-pathways
|
||||
- landing-03-workflow-timeline: landing / / workflow-timeline
|
||||
- landing-04-auth-callout: landing / / auth-callout
|
||||
- application-01-role-selection-grid: application /app/onboarding / role-selection-grid
|
||||
- application-02-discovery-filter-strip: application /app / discovery-filter-strip
|
||||
- admin-01-admin-auth-header: admin /admin / admin-auth-header
|
||||
93
frontend/design/asset-manifest.json
Normal file
@ -0,0 +1,93 @@
|
||||
[
|
||||
{
|
||||
"id": "landing-01-hero-split",
|
||||
"src": "/assets/landing-01-hero-split.png",
|
||||
"alt": "Hero food photography with dark gradient contrast overlay for EatIt",
|
||||
"role": "Hero food photography with dark gradient contrast overlay",
|
||||
"width": 1600,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/",
|
||||
"sectionId": "hero-split",
|
||||
"usage": "Limestone background with primary dark slate text, paprika accent button, and high-contrast telemetry card wrapper.",
|
||||
"cropFocus": "Keep the primary subject in the central-right safe area so concise hero copy can sit to the left"
|
||||
},
|
||||
{
|
||||
"id": "landing-02-role-pathways",
|
||||
"src": "/assets/landing-02-role-pathways.png",
|
||||
"alt": "Role pathway visual cards showing kitchen and delivery operational context for EatIt",
|
||||
"role": "Role pathway visual cards showing kitchen and delivery operational context",
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/",
|
||||
"sectionId": "role-pathways",
|
||||
"usage": "Elevated white surface cards over limestone base with subtle slate borders and distinct semantic badges.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "landing-03-workflow-timeline",
|
||||
"src": "/assets/landing-03-workflow-timeline.png",
|
||||
"alt": "Illustrate the step-by-step unskippable lifecycle of an order from placement to for EatIt",
|
||||
"role": "Illustrate the step-by-step unskippable lifecycle of an order from placement to",
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/",
|
||||
"sectionId": "workflow-timeline",
|
||||
"usage": "Warm limestone band with structured line dividers and step badges in accent paprika and slate tones.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "landing-04-auth-callout",
|
||||
"src": "/assets/landing-04-auth-callout.png",
|
||||
"alt": "Provide final conversion gateway encouraging users to sign in and choose their for EatIt",
|
||||
"role": "Provide final conversion gateway encouraging users to sign in and choose their",
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/",
|
||||
"sectionId": "auth-callout",
|
||||
"usage": "Dark slate inverse surface (#16181B) with bright white text and warm paprika CTA button.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "application-01-role-selection-grid",
|
||||
"src": "/assets/application-01-role-selection-grid.png",
|
||||
"alt": "Role vector icons for EatIt",
|
||||
"role": "Role vector icons",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"surfaceId": "application",
|
||||
"screenRoute": "/app/onboarding",
|
||||
"sectionId": "role-selection-grid",
|
||||
"usage": "Solid white card surfaces over warm limestone base with 1px slate borders.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "application-02-discovery-filter-strip",
|
||||
"src": "/assets/application-02-discovery-filter-strip.png",
|
||||
"alt": "Filter and search local dining options visual for EatIt",
|
||||
"role": "Filter and search local dining options visual",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"surfaceId": "application",
|
||||
"screenRoute": "/app",
|
||||
"sectionId": "discovery-filter-strip",
|
||||
"usage": "Limestone sticky bar with subtle 1px border separator.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "admin-01-admin-auth-header",
|
||||
"src": "/assets/admin-01-admin-auth-header.png",
|
||||
"alt": "Verify operator authority with secure admin token and display session state. for EatIt",
|
||||
"role": "Verify operator authority with secure admin token and display session state.",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"surfaceId": "admin",
|
||||
"screenRoute": "/admin",
|
||||
"sectionId": "admin-auth-header",
|
||||
"usage": "Dark slate header bar (#16181B) with crisp 1px subtle slate border and contrast badge.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
}
|
||||
]
|
||||
1175
frontend/design/design-contract.json
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EatIt</title>
|
||||
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="uau39m"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3287
frontend/package-lock.json
generated
Normal file
49
frontend/package.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "eatit-uau39m",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"zod": "^3.22.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"sonner": "latest",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"axios": "^1.6.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"framer-motion": "^10.16.4",
|
||||
"recharts": "^2.13.0",
|
||||
"react-is": "^18.3.1",
|
||||
"lucide-react": "^0.378.0",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"vite": "^4.5.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"typescript": "^5.2.2",
|
||||
"@cashfreepayments/cashfree-js": "latest",
|
||||
"leaflet": "^1.9.4",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"socket.io-client": "4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/node": "^20.11.0"
|
||||
}
|
||||
}
|
||||
1
frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
BIN
frontend/public/assets/admin-01-admin-auth-header.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/application-01-role-selection-grid.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
frontend/public/assets/application-02-discovery-filter-strip.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/assets/landing-01-hero-split.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/assets/landing-02-role-pathways.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
frontend/public/assets/landing-03-workflow-timeline.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/assets/landing-04-auth-callout.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
3
frontend/src/App.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function App() {
|
||||
return <main className="min-h-screen" />;
|
||||
}
|
||||
97
frontend/src/api.ts
Normal file
@ -0,0 +1,97 @@
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||
? "http://127.0.0.1:8787"
|
||||
: "";
|
||||
|
||||
export function resolveMediaUrl(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
return value.startsWith('/api/media/') ? API_URL + value : value;
|
||||
}
|
||||
|
||||
function resolveMediaUrls(value: unknown): unknown {
|
||||
if (typeof value === 'string') return resolveMediaUrl(value);
|
||||
if (Array.isArray(value)) return value.map(resolveMediaUrls);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [key, resolveMediaUrls(nested)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL
|
||||
});
|
||||
|
||||
export const adminApi = axios.create({
|
||||
baseURL: API_URL
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
// Force the /api prefix on all internal API calls that forgot it
|
||||
if (config.url && !config.url.startsWith('/api') && !config.url.startsWith('http')) {
|
||||
config.url = `/api${config.url.startsWith('/') ? '' : '/'}${config.url}`;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('user_auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = undefined;
|
||||
}
|
||||
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||
config.headers['x-mworld-origin'] = currentOrigin;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
response.data = resolveMediaUrls(response.data);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Prevent infinite page reloads on check-in failures
|
||||
const isCheckIn = error.config?.url?.includes('/user/check-in');
|
||||
if (!isCheckIn) {
|
||||
const hadToken = localStorage.getItem('user_auth_token');
|
||||
localStorage.removeItem('user_auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
if (hadToken) window.location.reload();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
if (config.url && !config.url.startsWith('http')) {
|
||||
if (config.url.startsWith('/api/admin')) {
|
||||
// Already namespaced.
|
||||
} else if (config.url.startsWith('/api/')) {
|
||||
config.url = `/api/admin${config.url.slice('/api'.length)}`;
|
||||
} else {
|
||||
config.url = `/api/admin${config.url.startsWith('/') ? '' : '/'}${config.url}`;
|
||||
}
|
||||
}
|
||||
const token = sessionStorage.getItem('admin_auth_token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = undefined;
|
||||
}
|
||||
config.headers['x-mworld-origin'] = window.location.origin;
|
||||
return config;
|
||||
});
|
||||
|
||||
adminApi.interceptors.response.use(
|
||||
(response) => {
|
||||
response.data = resolveMediaUrls(response.data);
|
||||
return response;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
export default api;
|
||||
117
frontend/src/components/AdminTokenGate.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
|
||||
import React, { FormEvent, useEffect, useState } from 'react';
|
||||
import { KeyRound, Loader2, LockKeyhole, LogOut } from 'lucide-react';
|
||||
import { adminApi } from '../api';
|
||||
|
||||
export default function AdminTokenGate({ children }: { children: React.ReactNode }) {
|
||||
const siteTitle = "EatIt";
|
||||
const [token, setToken] = useState('');
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const tokenParamNames = ['agentic_token', 'mAuthAgenticToken', 'mAgenticAuthToken', 'mAgenticAuth'];
|
||||
|
||||
const normalizeAgenticToken = (value: string | null): string => {
|
||||
let normalized = String(value || '').trim().replace(/^["']|["']$/g, '').trim();
|
||||
normalized = normalized.replace(/^Bearer[ ]+/i, '').trim();
|
||||
if (normalized.toLowerCase().startsWith('http://') || normalized.toLowerCase().startsWith('https://')) {
|
||||
try {
|
||||
const pastedUrl = new URL(normalized);
|
||||
const hashParams = new URLSearchParams(pastedUrl.hash.startsWith('#') ? pastedUrl.hash.slice(1) : '');
|
||||
normalized = tokenParamNames.map(name => pastedUrl.searchParams.get(name) || hashParams.get(name)).find(Boolean) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (/%[0-9a-f]{2}/i.test(normalized)) {
|
||||
try { normalized = decodeURIComponent(normalized); } catch { /* keep the original token */ }
|
||||
}
|
||||
return normalized.trim();
|
||||
};
|
||||
|
||||
const readAgenticHandoff = () => {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : '');
|
||||
const handoff = tokenParamNames.map(name => url.searchParams.get(name) || hashParams.get(name)).find(Boolean) || null;
|
||||
if (handoff) {
|
||||
tokenParamNames.forEach(name => {
|
||||
url.searchParams.delete(name);
|
||||
hashParams.delete(name);
|
||||
});
|
||||
url.hash = hashParams.toString() ? '#' + hashParams.toString() : '';
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
return normalizeAgenticToken(handoff);
|
||||
};
|
||||
|
||||
const verify = async (candidate: string) => {
|
||||
const normalized = normalizeAgenticToken(candidate);
|
||||
setToken(normalized);
|
||||
if (!normalized) {
|
||||
setError('A valid M-Auth agentic token is required.');
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
setChecking(true);
|
||||
setError('');
|
||||
sessionStorage.setItem('admin_auth_token', normalized);
|
||||
try {
|
||||
await adminApi.get('/api/admin/verify-token');
|
||||
setVerified(true);
|
||||
} catch (requestError: any) {
|
||||
const status = Number(requestError?.response?.status || 0);
|
||||
setVerified(false);
|
||||
if (status === 401 || status === 403) {
|
||||
sessionStorage.removeItem('admin_auth_token');
|
||||
setError('That M-Auth agentic token is invalid or expired. Open the admin panel from Fullstack Builder again.');
|
||||
} else {
|
||||
setError('M-Auth could not be reached right now. Your token has been kept; retry in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const saved = readAgenticHandoff() || normalizeAgenticToken(sessionStorage.getItem('admin_auth_token'));
|
||||
if (saved) verify(saved);
|
||||
else setChecking(false);
|
||||
}, []);
|
||||
|
||||
if (verified) {
|
||||
return <div className="min-h-screen bg-background text-foreground">
|
||||
<div className="fixed right-4 top-4 z-50">
|
||||
<button
|
||||
type="button"
|
||||
title="Lock admin panel"
|
||||
onClick={() => { sessionStorage.removeItem('admin_auth_token'); setVerified(false); setToken(''); }}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <main className="flex min-h-screen items-center justify-center bg-background px-5 text-foreground">
|
||||
<section className="w-full max-w-md border border-border bg-background p-7 shadow-xl">
|
||||
<div className="mb-7 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground"><LockKeyhole className="h-5 w-5" /></div>
|
||||
<div><h1 className="text-xl font-semibold">{siteTitle} Admin</h1><p className="mt-1 text-sm text-muted-foreground">Restricted operator access</p></div>
|
||||
</div>
|
||||
<form onSubmit={(event: FormEvent) => { event.preventDefault(); verify(token); }} className="space-y-4">
|
||||
<label className="block text-sm font-medium" htmlFor="admin-token">M-Auth agentic token</label>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<input id="admin-token" type="password" autoComplete="off" value={token} onChange={event => setToken(event.target.value)} className="h-10 w-full border border-input bg-background pl-10 pr-3 text-sm outline-none focus:border-primary" />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<button disabled={checking || !token.trim()} className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-50">
|
||||
{checking && <Loader2 className="h-4 w-4 animate-spin" />} Unlock admin panel
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
298
frontend/src/components/CartDrawer.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CreditCard, Loader2, MapPin, Minus, Plus, ShoppingBag, Trash2, X } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { load } from '@cashfreepayments/cashfree-js';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
menu_item_id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
special_instructions?: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface CartDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
restaurantId: string;
|
||||
restaurantName: string;
|
||||
items: CartItem[];
|
||||
onUpdateQuantity: (menuItemId: string, delta: number) => void;
|
||||
onClearCart: () => void;
|
||||
onOrderPlaced?: (orderId: string) => void;
|
||||
}
|
||||
|
||||
export default function CartDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
restaurantId,
|
||||
restaurantName,
|
||||
items,
|
||||
onUpdateQuantity,
|
||||
onClearCart,
|
||||
onOrderPlaced,
|
||||
}: CartDrawerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [deliveryAddress, setDeliveryAddress] = useState('123 Culinary Way, Suite 400');
|
||||
const [coords, setCoords] = useState<{ lat: number; lng: number }>({ lat: 37.7749, lng: -122.4194 });
|
||||
const [isLocating, setIsLocating] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const deliveryFee = items.length > 0 ? 5.0 : 0;
|
||||
const totalAmount = subtotal + deliveryFee;
|
||||
|
||||
const handleFindLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation is not supported by your browser');
|
||||
return;
|
||||
}
|
||||
setIsLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setCoords({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
setDeliveryAddress(`GPS Location (${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)})`);
|
||||
setIsLocating(false);
|
||||
},
|
||||
() => {
|
||||
setError('Unable to fetch current position');
|
||||
setIsLocating(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!String((deliveryAddress) ?? '').trim()) {
|
||||
setError('Please enter a valid delivery address');
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
setError('Your shopping cart is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
restaurant_id: restaurantId,
|
||||
delivery_address: String((deliveryAddress) ?? '').trim(),
|
||||
delivery_latitude: coords.lat,
|
||||
delivery_longitude: coords.lng,
|
||||
items: items.map((i) => ({
|
||||
menu_item_id: i.menu_item_id,
|
||||
quantity: i.quantity,
|
||||
special_instructions: i.special_instructions || '',
|
||||
})),
|
||||
return_url: `${window.location.origin}/app/orders`,
|
||||
};
|
||||
|
||||
const res = await api.post('/api/orders', payload);
|
||||
const orderData = res.data?.order;
|
||||
const paymentSessionId = res.data?.payment_session_id;
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
onClearCart();
|
||||
onClose();
|
||||
|
||||
if (paymentSessionId) {
|
||||
try {
|
||||
const cashfree = await load({ mode: 'sandbox' });
|
||||
await cashfree.checkout({ paymentSessionId, redirectTarget: '_self' });
|
||||
} catch (sdkError) {
|
||||
console.warn('Cashfree payment SDK direct redirect fallback:', sdkError);
|
||||
if (onOrderPlaced && orderData?.id) {
|
||||
onOrderPlaced(orderData.id);
|
||||
} else {
|
||||
window.location.href = '/app/orders';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (onOrderPlaced && orderData?.id) {
|
||||
onOrderPlaced(orderData.id);
|
||||
} else {
|
||||
window.location.href = '/app/orders';
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Order creation error:', err);
|
||||
setError(err.response?.data?.msg || err.message || 'Failed to place order. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-xs"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 bottom-0 z-50 flex w-full max-w-md flex-col bg-[#F8F7F4] shadow-2xl border-l border-[#E2E1DA] font-['Plus_Jakarta_Sans',sans-serif]"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#E2E1DA] bg-white px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingBag className="h-5 w-5 text-[#B93812]" />
|
||||
<h2 className="font-['Outfit',sans-serif] text-lg font-bold text-[#0F172A]">
|
||||
Your Order
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1.5 text-gray-500 hover:bg-[#F0EFEA] hover:text-black transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#E5E4DD]/50 text-gray-400">
|
||||
<ShoppingBag className="h-8 w-8" />
|
||||
</div>
|
||||
<p className="font-['Outfit',sans-serif] text-lg font-semibold text-[#0F172A]">
|
||||
Your cart is empty
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select items from {restaurantName || 'a restaurant'} to start your meal order.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-[#E2E1DA] bg-white/50 px-5 py-2.5 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Ordering from <span className="text-[#0F172A]">{restaurantName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between rounded-lg border border-[#E2E1DA] bg-white p-3.5 shadow-2xs"
|
||||
>
|
||||
<div className="flex-1 pr-3">
|
||||
<p className="font-medium text-[#0F172A] text-sm">{item.name}</p>
|
||||
<p className="mt-0.5 text-xs text-[#B93812] font-semibold">
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
{item.special_instructions && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
“{item.special_instructions}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center rounded-md border border-[#E2E1DA] bg-[#F0EFEA]">
|
||||
<button
|
||||
onClick={() => onUpdateQuantity(item.menu_item_id, -1)}
|
||||
className="p-1 text-gray-600 hover:text-black hover:bg-[#E5E4DD] rounded-l-md"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="w-7 text-center text-xs font-semibold text-[#0F172A]">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onUpdateQuantity(item.menu_item_id, 1)}
|
||||
className="p-1 text-gray-600 hover:text-black hover:bg-[#E5E4DD] rounded-r-md"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button> </div>
|
||||
<button
|
||||
onClick={() => onUpdateQuantity(item.menu_item_id, -item.quantity)}
|
||||
className="text-gray-400 hover:text-red-600 p-1 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-6 rounded-lg border border-[#E2E1DA] bg-white p-4 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5 text-[#B93812]" /> Delivery Address
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFindLocation}
|
||||
disabled={isLocating}
|
||||
className="text-[11px] text-[#B93812] hover:underline flex items-center gap-1"
|
||||
>
|
||||
{isLocating && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Locate Me
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={deliveryAddress}
|
||||
onChange={(e) => setDeliveryAddress(e.target.value)}
|
||||
placeholder="Enter full delivery address..."
|
||||
className="w-full rounded-md border border-[#E2E1DA] bg-[#F8F7F4] px-3 py-2 text-xs font-medium text-[#0F172A] focus:border-[#0F172A] focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-xs text-red-700 border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#E2E1DA] bg-white p-5 space-y-3 shadow-lg">
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Items Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Delivery Fee</span>
|
||||
<span>${deliveryFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-[#E2E1DA] font-semibold text-sm text-[#0F172A]">
|
||||
<span>Total Due</span>
|
||||
<span className="text-[#B93812]">${totalAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={isSubmitting}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#B93812] py-3 px-4 font-['Outfit',sans-serif] text-sm font-semibold text-white shadow-md hover:bg-[#962d0e] disabled:opacity-50 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Processing Order...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<span>Pay & Place Order (${totalAmount.toFixed(2)})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
160
frontend/src/components/ImageUploader.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Loader2, Upload, X } from 'lucide-react';
|
||||
import { api, resolveMediaUrl } from '../api';
|
||||
|
||||
export interface ImageUploaderProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
label?: string;
|
||||
endpoint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageUploader({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
endpoint = '/api/upload',
|
||||
className = '',
|
||||
}: ImageUploaderProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Image must be smaller than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await api.post(endpoint, formData, {
|
||||
headers: {
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.data && res.data.url) {
|
||||
onChange(res.data.url);
|
||||
} else {
|
||||
setError('Failed to upload image. Please try again.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.msg || err?.message || 'Error uploading file');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const triggerSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const previewUrl = value ? resolveMediaUrl(value) : null;
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-xs font-semibold text-slate-700 tracking-tight">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{previewUrl ? (
|
||||
<div className="relative group rounded-xl overflow-hidden border border-slate-200 bg-slate-50 aspect-video max-h-48 w-full flex items-center justify-center">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={triggerSelect}
|
||||
disabled={isUploading}
|
||||
className="px-3 py-1.5 bg-white/90 hover:bg-white text-slate-900 text-xs font-semibold rounded-lg shadow-sm transition-colors"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={isUploading}
|
||||
className="p-1.5 bg-red-600/90 hover:bg-red-600 text-white rounded-lg shadow-sm transition-colors"
|
||||
title="Remove image"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 bg-slate-900/60 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={triggerSelect}
|
||||
className={`border-2 border-dashed rounded-xl p-4 text-center cursor-pointer transition-colors flex flex-col items-center justify-center gap-2 ${
|
||||
isUploading
|
||||
? 'border-amber-400 bg-amber-50/50'
|
||||
: 'border-slate-300 hover:border-amber-500 hover:bg-amber-50/20 bg-slate-50/50'
|
||||
}`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
|
||||
) : (
|
||||
<div className="p-2.5 bg-amber-100 text-amber-700 rounded-full">
|
||||
<Upload className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs">
|
||||
<span className="font-semibold text-slate-800">
|
||||
{isUploading ? 'Uploading image...' : 'Click to upload image'}
|
||||
</span>
|
||||
<p className="text-slate-500 text-[11px] mt-0.5">
|
||||
PNG, JPG, WEBP up to 10MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 font-medium">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageUploader;
|
||||
181
frontend/src/components/MapView.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
export interface MapMarker {
|
||||
id?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label?: string;
|
||||
type?: 'restaurant' | 'customer' | 'driver' | 'default';
|
||||
}
|
||||
|
||||
interface MapViewProps {
|
||||
center: [number, number];
|
||||
zoom?: number;
|
||||
markers?: MapMarker[];
|
||||
polyline?: Array<[number, number]>;
|
||||
className?: string;
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
autoBounds?: boolean;
|
||||
}
|
||||
|
||||
const createCustomIcon = (type: MapMarker['type'] = 'default') => {
|
||||
let bgColor = '#0F172A';
|
||||
let badgeText = '📍';
|
||||
|
||||
if (type === 'restaurant') {
|
||||
bgColor = '#B93812';
|
||||
badgeText = '🍳';
|
||||
} else if (type === 'customer') {
|
||||
bgColor = '#1D4ED8';
|
||||
badgeText = '🏠';
|
||||
} else if (type === 'driver') {
|
||||
bgColor = '#15803D';
|
||||
badgeText = '🛵';
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-map-pin',
|
||||
html: `<div style="background-color: ${bgColor}; color: white; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); border: 2px solid white;">${badgeText}</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
};
|
||||
|
||||
export default function MapView({
|
||||
center,
|
||||
zoom = 13,
|
||||
markers = [],
|
||||
polyline = [],
|
||||
className,
|
||||
onMapClick,
|
||||
autoBounds = false,
|
||||
}: MapViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markerLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const polylineLayerRef = useRef<L.Polyline | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
}).setView(center, zoom);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
markerLayerRef.current = L.layerGroup().addTo(map);
|
||||
mapRef.current = map;
|
||||
|
||||
if (onMapClick) {
|
||||
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
});
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapRef.current && !autoBounds) {
|
||||
mapRef.current.setView(center, zoom);
|
||||
}
|
||||
}, [center[0], center[1], zoom, autoBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
const layer = markerLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
|
||||
markers.forEach((m) => {
|
||||
if (typeof m.lat !== 'number' || typeof m.lng !== 'number' || !Number.isFinite(m.lat) || !Number.isFinite(m.lng)) {
|
||||
return;
|
||||
}
|
||||
const icon = m.type ? createCustomIcon(m.type) : defaultIcon;
|
||||
const marker = L.marker([m.lat, m.lng], { icon });
|
||||
if (m.label) {
|
||||
marker.bindPopup(`<div style="font-family: sans-serif; font-size: 13px; font-weight: 500;">${m.label}</div>`);
|
||||
}
|
||||
layer.addLayer(marker);
|
||||
});
|
||||
}, [markers]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
if (polylineLayerRef.current) {
|
||||
polylineLayerRef.current.remove();
|
||||
polylineLayerRef.current = null;
|
||||
}
|
||||
|
||||
const validPolyline = polyline.filter(
|
||||
(pt) => Array.isArray(pt) && pt.length === 2 && Number.isFinite(pt[0]) && Number.isFinite(pt[1])
|
||||
);
|
||||
|
||||
if (validPolyline.length > 0) {
|
||||
const line = L.polyline(validPolyline, {
|
||||
color: '#B93812',
|
||||
weight: 4,
|
||||
opacity: 0.85,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}).addTo(map);
|
||||
polylineLayerRef.current = line;
|
||||
}
|
||||
}, [polyline]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !autoBounds) return;
|
||||
|
||||
const boundsPoints: Array<[number, number]> = [];
|
||||
|
||||
markers.forEach((m) => {
|
||||
if (Number.isFinite(m.lat) && Number.isFinite(m.lng)) {
|
||||
boundsPoints.push([m.lat, m.lng]);
|
||||
}
|
||||
});
|
||||
|
||||
polyline.forEach((pt) => {
|
||||
if (Array.isArray(pt) && pt.length === 2 && Number.isFinite(pt[0]) && Number.isFinite(pt[1])) {
|
||||
boundsPoints.push(pt);
|
||||
}
|
||||
});
|
||||
|
||||
if (boundsPoints.length > 0) {
|
||||
const bounds = L.latLngBounds(boundsPoints);
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 });
|
||||
}
|
||||
}, [markers, polyline, autoBounds]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className || 'h-[360px] w-full rounded-xl border border-[#E2E1DA] overflow-hidden shadow-sm'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowRight, ChefHat, Compass, LogOut, ShoppingBag, Sparkles, Truck, User, UtensilsCrossed } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export function Navbar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
|
||||
const handleSignOut = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const getRoleBadge = () => {
|
||||
if (!user?.role) return null;
|
||||
switch (user.role) {
|
||||
case 'customer':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-200">
|
||||
<ShoppingBag className="w-3 h-3" />
|
||||
Diner
|
||||
</span>
|
||||
);
|
||||
case 'restaurant':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||
<ChefHat className="w-3 h-3" />
|
||||
Kitchen Partner
|
||||
</span>
|
||||
);
|
||||
case 'delivery_agent':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||
<Truck className="w-3 h-3" />
|
||||
Courier
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getNavLinks = () => {
|
||||
if (!isAuthenticated) {
|
||||
return [
|
||||
{ name: 'How It Works', path: '/#how-it-works', icon: Compass },
|
||||
{ name: 'For Kitchens', path: '/#kitchens', icon: ChefHat },
|
||||
{ name: 'For Couriers', path: '/#couriers', icon: Truck },
|
||||
];
|
||||
}
|
||||
|
||||
if (!user?.role) {
|
||||
return [{ name: 'Select Role', path: '/app/onboarding', icon: Sparkles }];
|
||||
}
|
||||
|
||||
switch (user.role) {
|
||||
case 'customer':
|
||||
return [
|
||||
{ name: 'Explore Food', path: '/app', icon: Compass },
|
||||
{ name: 'My Orders', path: '/app/orders', icon: ShoppingBag },
|
||||
];
|
||||
case 'restaurant':
|
||||
return [
|
||||
{ name: 'Kitchen Workspace', path: '/app/restaurant', icon: ChefHat },
|
||||
];
|
||||
case 'delivery_agent':
|
||||
return [
|
||||
{ name: 'Courier Dispatch', path: '/app/delivery', icon: Truck },
|
||||
];
|
||||
default:
|
||||
return [{ name: 'Setup Role', path: '/app/onboarding', icon: Sparkles }];
|
||||
}
|
||||
};
|
||||
|
||||
const navLinks = getNavLinks();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-[#E2E1DA] bg-[#FFFFFF]/95 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link
|
||||
to={isAuthenticated && user?.role ? (user.role === 'restaurant' ? '/app/restaurant' : user.role === 'delivery_agent' ? '/app/delivery' : '/app') : '/'}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#0F172A] text-white shadow-sm transition-transform group-hover:scale-105">
|
||||
<UtensilsCrossed className="h-5 w-5 text-[#B93812]" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-display text-xl font-bold tracking-tight text-[#0F172A]">
|
||||
EatIt
|
||||
</span>
|
||||
<span className="-mt-1 text-[10px] font-medium text-slate-500 tracking-wider uppercase">
|
||||
BiteRoute
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navLinks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
location.pathname === item.path ||
|
||||
(item.path !== '/' && item.path !== '/#how-it-works' && location.pathname.startsWith(item.path));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
isActive
|
||||
? 'bg-[#F0EFEA] text-[#0F172A] font-semibold'
|
||||
: 'text-slate-600 hover:text-[#0F172A] hover:bg-[#F8F7F4]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', isActive ? 'text-[#B93812]' : 'text-slate-400')} />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-2 bg-[#F8F7F4] border border-[#E2E1DA] rounded-lg px-3 py-1.5">
|
||||
<User className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-xs font-medium text-slate-700 max-w-[120px] truncate">
|
||||
{user?.full_name || user?.email?.split('@')[0] || 'User'}
|
||||
</span>
|
||||
{getRoleBadge()}
|
||||
</div>
|
||||
|
||||
{!user?.role && (
|
||||
<Link
|
||||
to="/app/onboarding"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-[#B93812] text-white hover:bg-[#962d0e] transition-colors shadow-sm"
|
||||
>
|
||||
<span>Choose Role</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-600 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors border border-transparent hover:border-red-200"
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-[#0F172A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0F172A]"
|
||||
>
|
||||
<span>Sign In with M-Auth</span>
|
||||
<ArrowRight className="w-4 h-4 text-[#B93812]" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
191
frontend/src/components/ReviewModal.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CheckCircle2, Loader2, MessageSquare, Star, X } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export interface ReviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
orderId: string;
|
||||
restaurantName?: string;
|
||||
driverName?: string;
|
||||
onSubmitted?: () => void;
|
||||
}
|
||||
|
||||
export default function ReviewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
orderId,
|
||||
restaurantName = 'the restaurant',
|
||||
driverName = 'your driver',
|
||||
onSubmitted,
|
||||
}: ReviewModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [restaurantRating, setRestaurantRating] = useState(5);
|
||||
const [driverRating, setDriverRating] = useState(5);
|
||||
const [comment, setComment] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post(`/api/orders/${orderId}/rating`, {
|
||||
restaurant_rating: restaurantRating,
|
||||
driver_rating: driverRating,
|
||||
comment: String((comment) ?? '').trim(),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
|
||||
setIsSuccess(true);
|
||||
setTimeout(() => {
|
||||
if (onSubmitted) onSubmitted();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
console.error('Rating submission failed:', err);
|
||||
setError(err.response?.data?.msg || err.message || 'Failed to submit rating. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const RenderStarSelector = (
|
||||
value: number,
|
||||
onChange: (val: number) => void,
|
||||
label: string
|
||||
) => (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs font-medium text-[#0F172A]">
|
||||
<span>{label}</span>
|
||||
<span className="font-bold text-[#B93812]">{value} / 5 Stars</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-[#F0EFEA] p-2.5 rounded-lg border border-[#E2E1DA]">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => onChange(star)}
|
||||
className="p-1 text-yellow-400 hover:scale-110 transition-transform cursor-pointer focus:outline-hidden"
|
||||
>
|
||||
<Star
|
||||
className={`h-6 w-6 ${star <= value ? 'fill-yellow-400 text-yellow-400' : 'fill-none text-gray-300'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs"
|
||||
/>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="w-full max-w-md rounded-xl border border-[#E2E1DA] bg-[#F8F7F4] p-6 shadow-2xl font-['Plus_Jakarta_Sans',sans-serif] relative overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-md p-1.5 text-gray-400 hover:bg-[#F0EFEA] hover:text-black transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="font-['Outfit',sans-serif] text-xl font-bold text-[#0F172A]">
|
||||
Thank You for Your Feedback!
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Your ratings help improve kitchen quality and courier delivery speed.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[#B93812]">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Post-Delivery Feedback</span>
|
||||
</div>
|
||||
<h3 className="mt-1 font-['Outfit',sans-serif] text-xl font-bold text-[#0F172A]">
|
||||
How was your meal & delivery?
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Rate your experience for Order #{orderId.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{RenderStarSelector(restaurantRating, setRestaurantRating, `Restaurant Quality (${restaurantName})`)}
|
||||
{RenderStarSelector(driverRating, setDriverRating, `Courier Service (${driverName})`)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-[#0F172A]">
|
||||
Comments or Suggestions (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Tell us about food temperature, packaging, or driver friendliness..."
|
||||
className="w-full rounded-lg border border-[#E2E1DA] bg-white p-3 text-xs text-[#0F172A] focus:border-[#0F172A] focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-2.5 text-xs text-red-700 border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-md border border-[#E2E1DA] bg-white py-2.5 text-xs font-semibold text-[#0F172A] hover:bg-[#F0EFEA] transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-md bg-[#B93812] py-2.5 text-xs font-semibold text-white shadow-sm hover:bg-[#962d0e] disabled:opacity-50 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Submitting...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Submit Review</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/StarRating.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number;
|
||||
onChange?: (rating: number) => void;
|
||||
size?: number;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
size = 18,
|
||||
readonly = false,
|
||||
className,
|
||||
showLabel = false,
|
||||
}: StarRatingProps) {
|
||||
const [hover, setHover] = useState(0);
|
||||
const interactive = !readonly && !!onChange;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5', className)}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = (hover || value) >= star;
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={!interactive}
|
||||
onClick={() => interactive && onChange!(star)}
|
||||
onMouseEnter={() => interactive && setHover(star)}
|
||||
onMouseLeave={() => interactive && setHover(0)}
|
||||
className={cn(
|
||||
'transition-all duration-150 p-0.5 rounded focus:outline-none focus:ring-1 focus:ring-[#B93812]/40',
|
||||
interactive ? 'cursor-pointer hover:scale-110' : 'cursor-default'
|
||||
)}
|
||||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
size={size}
|
||||
className={cn(
|
||||
'transition-colors duration-150',
|
||||
filled
|
||||
? 'fill-amber-400 text-amber-500'
|
||||
: 'fill-slate-100 text-slate-300'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="text-xs font-semibold text-slate-700 ml-0.5">
|
||||
{Number(value || 0).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StarRating;
|
||||
280
frontend/src/components/admin/AdminOrderModal.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Clock, CreditCard, MapPin, Package, Store, Truck, User, X } from 'lucide-react';
|
||||
import { adminApi } from '../../api';
|
||||
|
||||
export interface OrderItemDetail {
|
||||
id?: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
special_instructions?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminOrderData {
|
||||
id: string;
|
||||
customer_email: string;
|
||||
restaurant_id: string;
|
||||
restaurant_name?: string;
|
||||
delivery_agent_email?: string | null;
|
||||
delivery_address: string;
|
||||
delivery_latitude?: number | null;
|
||||
delivery_longitude?: number | null;
|
||||
subtotal: number;
|
||||
delivery_fee: number;
|
||||
total_amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
order_items?: OrderItemDetail[];
|
||||
items?: OrderItemDetail[];
|
||||
current_latitude?: number | null;
|
||||
current_longitude?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminOrderModalProps {
|
||||
order: AdminOrderData | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }> = {
|
||||
placed: { bg: 'bg-amber-50', text: 'text-amber-800', border: 'border-amber-200' },
|
||||
accepted: { bg: 'bg-blue-50', text: 'text-blue-800', border: 'border-blue-200' },
|
||||
preparing: { bg: 'bg-indigo-50', text: 'text-indigo-800', border: 'border-indigo-200' },
|
||||
ready_for_pickup: { bg: 'bg-purple-50', text: 'text-purple-800', border: 'border-purple-200' },
|
||||
assigned: { bg: 'bg-cyan-50', text: 'text-cyan-800', border: 'border-cyan-200' },
|
||||
picked_up: { bg: 'bg-sky-50', text: 'text-sky-800', border: 'border-sky-200' },
|
||||
delivered: { bg: 'bg-emerald-50', text: 'text-emerald-800', border: 'border-emerald-200' },
|
||||
cancelled: { bg: 'bg-rose-50', text: 'text-rose-800', border: 'border-rose-200' },
|
||||
};
|
||||
|
||||
export default function AdminOrderModal({ order, isOpen, onClose }: AdminOrderModalProps) {
|
||||
const [detailedOrder, setDetailedOrder] = useState<AdminOrderData | null>(order);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailedOrder(order);
|
||||
if (isOpen && order?.id) {
|
||||
setLoading(true);
|
||||
adminApi
|
||||
.get(`/api/orders/${order.id}`)
|
||||
.then((res) => {
|
||||
if (res.data?.success && res.data?.order) {
|
||||
setDetailedOrder((prev) => ({
|
||||
...(prev || order),
|
||||
...res.data.order,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback to initial order object
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, order?.id]);
|
||||
|
||||
if (!isOpen || !order) return null;
|
||||
|
||||
const currentData = detailedOrder || order;
|
||||
const rawItems = currentData.order_items || currentData.items || [];
|
||||
const itemsList = Array.isArray(rawItems) ? rawItems : [];
|
||||
|
||||
const statusStyle = STATUS_COLORS[currentData.status] || {
|
||||
bg: 'bg-slate-100',
|
||||
text: 'text-slate-800',
|
||||
border: 'border-slate-300',
|
||||
};
|
||||
|
||||
const formattedDate = currentData.created_at
|
||||
? new Date(currentData.created_at).toLocaleString()
|
||||
: 'N/A';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative flex max-h-[90vh] w-full max-w-3xl flex-col rounded-xl border border-[#E2E1DA] bg-white shadow-2xl overflow-hidden font-sans text-slate-900">
|
||||
|
||||
<div className="flex items-center justify-between border-b border-[#E2E1DA] bg-[#16181B] px-6 py-4 text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#B93812] text-white">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-display text-lg font-bold tracking-tight">
|
||||
Order Inspection
|
||||
</h3>
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
#{currentData.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300">Placed: {formattedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${statusStyle.bg} ${statusStyle.text} ${statusStyle.border}`}
|
||||
>
|
||||
{currentData.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-[#F8F7F4]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-2 text-xs font-medium text-slate-500 gap-2">
|
||||
<Clock className="h-4 w-4 animate-spin text-[#B93812]" />
|
||||
Fetching full order telemetry...
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<div className="rounded-lg border border-[#E2E1DA] bg-white p-4 space-y-2 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[#B93812] font-semibold text-xs uppercase tracking-wider">
|
||||
<User className="h-4 w-4" />
|
||||
Customer
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 truncate">
|
||||
{currentData.customer_email || 'N/A'}
|
||||
</p>
|
||||
<div className="flex items-start gap-1.5 text-xs text-slate-600">
|
||||
<MapPin className="h-3.5 w-3.5 text-slate-400 shrink-0 mt-0.5" />
|
||||
<span className="line-clamp-2">{currentData.delivery_address || 'No address specified'}</span>
|
||||
</div>
|
||||
{Number.isFinite(currentData.delivery_latitude) && Number.isFinite(currentData.delivery_longitude) && (
|
||||
<p className="text-[11px] font-mono text-slate-400">
|
||||
GPS: {Number(currentData.delivery_latitude).toFixed(4)}, {Number(currentData.delivery_longitude).toFixed(4)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-lg border border-[#E2E1DA] bg-white p-4 space-y-2 shadow-sm">
|
||||
<div className="flex items-center gap-2 font-semibold text-xs uppercase tracking-wider text-slate-700">
|
||||
<Store className="h-4 w-4 text-[#B93812]" />
|
||||
Kitchen / Restaurant
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 truncate">
|
||||
{currentData.restaurant_name || 'Kitchen Partner'}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-slate-500 truncate">
|
||||
ID: {currentData.restaurant_id || 'N/A'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-700 font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Verified Merchant
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-lg border border-[#E2E1DA] bg-white p-4 space-y-2 shadow-sm">
|
||||
<div className="flex items-center gap-2 font-semibold text-xs uppercase tracking-wider text-slate-700">
|
||||
<Truck className="h-4 w-4 text-sky-600" />
|
||||
Courier Agent
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 truncate">
|
||||
{currentData.delivery_agent_email || 'Unassigned'}
|
||||
</p>
|
||||
{currentData.delivery_agent_email ? (
|
||||
<p className="text-xs text-slate-600">Active Courier Assigned</p>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> Awaiting Courier Lock
|
||||
</p>
|
||||
)}
|
||||
{Number.isFinite(currentData.current_latitude) && Number.isFinite(currentData.current_longitude) && (
|
||||
<p className="text-[11px] font-mono text-sky-700 font-medium">
|
||||
Live GPS: {Number(currentData.current_latitude).toFixed(4)}, {Number(currentData.current_longitude).toFixed(4)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-lg border border-[#E2E1DA] bg-white overflow-hidden shadow-sm">
|
||||
<div className="border-b border-[#E2E1DA] bg-slate-50 px-4 py-2.5 font-semibold text-xs uppercase tracking-wider text-slate-700">
|
||||
Ordered Items ({itemsList.length})
|
||||
</div>
|
||||
{itemsList.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-500">
|
||||
No individual item details loaded for this order summary.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#E2E1DA]">
|
||||
{itemsList.map((item, idx) => (
|
||||
<div key={item.id || idx} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-slate-900">
|
||||
{item.quantity}x {item.item_name}
|
||||
</p>
|
||||
{item.special_instructions && (
|
||||
<p className="text-xs text-slate-500 italic">
|
||||
Note: {item.special_instructions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-mono text-slate-900 font-medium">
|
||||
${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}
|
||||
</span>
|
||||
<p className="text-[11px] text-slate-400 font-mono">
|
||||
${Number(item.unit_price || 0).toFixed(2)} each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-lg border border-[#E2E1DA] bg-white p-4 space-y-2 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-700 mb-2">
|
||||
<CreditCard className="h-4 w-4 text-[#B93812]" />
|
||||
Financial Breakdown
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-slate-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-mono text-slate-900">${Number(currentData.subtotal || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Delivery Fee</span>
|
||||
<span className="font-mono text-slate-900">${Number(currentData.delivery_fee || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#E2E1DA] flex justify-between font-bold text-sm text-slate-900">
|
||||
<span>Total Amount Paid</span>
|
||||
<span className="font-mono text-[#B93812] text-base">${Number(currentData.total_amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#E2E1DA] bg-white px-6 py-3">
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
System ID: {currentData.id}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-xs font-semibold text-white hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Close Inspector
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/components/customer/CartDrawer.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { load } from '@cashfreepayments/cashfree-js';
|
||||
import { AlertCircle, CreditCard, Loader2, MapPin, Minus, Plus, ShoppingBag, Trash2, X } from 'lucide-react';
|
||||
|
||||
interface CartItem {
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
image_url?: string;
|
||||
};
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CartDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
items: CartItem[];
|
||||
restaurant: { id: string; name: string; address?: string } | null;
|
||||
onUpdateQuantity: (itemId: string, delta: number) => void;
|
||||
onClearCart: () => void;
|
||||
}
|
||||
|
||||
export default function CartDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
items,
|
||||
restaurant,
|
||||
onUpdateQuantity,
|
||||
onClearCart,
|
||||
}: CartDrawerProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [address, setAddress] = useState<string>('742 Evergreen Terrace, Springfield');
|
||||
const [lat] = useState<number>(37.7749);
|
||||
const [lng] = useState<number>(-122.4194);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const subtotal = items.reduce((sum, i) => sum + Number(i.item.price) * i.quantity, 0);
|
||||
const deliveryFee = items.length > 0 ? 5.0 : 0.0;
|
||||
const totalAmount = subtotal + deliveryFee;
|
||||
|
||||
const orderMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!restaurant || items.length === 0) throw new Error('Cart is empty');
|
||||
if (!String((address) ?? '').trim()) throw new Error('Please enter a delivery address');
|
||||
|
||||
const payload = {
|
||||
restaurant_id: restaurant.id,
|
||||
delivery_address: String((address) ?? '').trim(),
|
||||
delivery_latitude: lat,
|
||||
delivery_longitude: lng,
|
||||
return_url: `${window.location.origin}/app/orders`,
|
||||
items: items.map((i) => ({
|
||||
menu_item_id: i.item.id,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
};
|
||||
|
||||
const res = await api.post('/api/orders', payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customer-orders'] });
|
||||
const sessionId = data?.payment_session_id;
|
||||
if (sessionId) {
|
||||
try {
|
||||
const cashfree = await load({ mode: 'sandbox' });
|
||||
await cashfree.checkout({ paymentSessionId: sessionId, redirectTarget: '_self' });
|
||||
} catch (err) {
|
||||
console.error('Cashfree SDK error:', err);
|
||||
onClearCart();
|
||||
onClose();
|
||||
navigate('/app/orders');
|
||||
}
|
||||
} else {
|
||||
onClearCart();
|
||||
onClose();
|
||||
navigate('/app/orders');
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setErrorMsg(err?.response?.data?.msg || err?.message || 'Failed to place order');
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/50 backdrop-blur-xs">
|
||||
<div className="flex h-full w-full max-w-md flex-col bg-surface shadow-2xl border-l border-border">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingBag className="h-5 w-5 text-accent" />
|
||||
<h3 className="font-display font-bold text-foreground text-lg">Your Meal Basket</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-mutedForeground hover:bg-surfaceSecondary hover:text-foreground transition"
|
||||
aria-label="Close cart"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="m-4 flex items-center gap-2 rounded-xl bg-rose-500/10 p-3 text-xs text-rose-600 border border-rose-500/20">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restaurant && (
|
||||
<div className="bg-surfaceSecondary p-3 px-4 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-mutedForeground tracking-wider">Ordering From</p>
|
||||
<p className="font-display font-semibold text-foreground text-sm">{restaurant.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClearCart}
|
||||
className="text-xs text-rose-600 hover:underline flex items-center gap-1 font-medium"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-center text-mutedForeground">
|
||||
<ShoppingBag className="h-12 w-12 stroke-1 opacity-40 mb-2" />
|
||||
<p className="font-medium text-foreground">Your cart is currently empty</p>
|
||||
<p className="text-xs text-mutedForeground mt-1">Browse restaurants and add delicious dishes!</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map(({ item, quantity }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-surface p-3 shadow-xs"
|
||||
>
|
||||
<div className="flex-1 pr-2">
|
||||
<h5 className="font-medium text-foreground text-sm">{item.name}</h5>
|
||||
<p className="font-mono text-xs font-semibold text-accent mt-0.5">
|
||||
${(Number(item.price) * quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-surfaceSecondary p-1">
|
||||
<button
|
||||
onClick={() => onUpdateQuantity(item.id, -1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-surface text-foreground"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-5 text-center font-mono text-xs font-bold">{quantity}</span>
|
||||
<button
|
||||
onClick={() => onUpdateQuantity(item.id, 1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-surface text-foreground"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-border bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1 flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5 text-accent" /> Delivery Drop-off Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Enter full delivery address"
|
||||
className="w-full rounded-lg border border-border bg-surfaceSecondary px-3 py-2 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-mutedForeground border-t border-border pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-mono font-medium text-foreground">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Delivery Fee</span>
|
||||
<span className="font-mono font-medium text-foreground">${deliveryFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold text-foreground border-t border-border pt-2">
|
||||
<span>Total Amount</span>
|
||||
<span className="font-mono text-accent text-base">${totalAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => orderMutation.mutate()}
|
||||
disabled={orderMutation.isPending || !String((address) ?? '').trim()}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-accent py-3 text-sm font-bold text-accentForeground shadow-md transition hover:opacity-90 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{orderMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Processing Order...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="h-4 w-4" /> Pay & Place Order (${totalAmount.toFixed(2)})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/customer/OrderRatingModal.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { StarRating } from '../StarRating';
|
||||
import { Send, Star, Store, Truck, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface OrderRatingModalProps {
|
||||
orderId: string | null;
|
||||
restaurantName?: string;
|
||||
driverName?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function OrderRatingModal({
|
||||
orderId,
|
||||
restaurantName = 'the restaurant',
|
||||
driverName = 'the delivery courier',
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: OrderRatingModalProps) {
|
||||
const [restaurantRating, setRestaurantRating] = useState(5);
|
||||
const [driverRating, setDriverRating] = useState(5);
|
||||
const [comment, setComment] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const ratingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!orderId) return;
|
||||
const res = await api.post(`/api/orders/${orderId}/rating`, {
|
||||
restaurant_rating: restaurantRating,
|
||||
driver_rating: driverRating,
|
||||
comment: String((comment) ?? '').trim(),
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Thank you! Your feedback has been submitted.');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-orders'] });
|
||||
if (orderId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['order-detail', orderId] });
|
||||
}
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.msg || 'Failed to submit rating. Please try again.');
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen || !orderId) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-border bg-white p-6 shadow-2xl">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="absolute right-4 top-4 rounded-full p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-50 text-amber-600 border border-amber-200">
|
||||
<Star size={24} className="fill-amber-400 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-bold text-slate-900">How was your meal?</h3>
|
||||
<p className="text-xs text-slate-500">Rate your experience with food and delivery</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
ratingMutation.mutate();
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="rounded-xl border border-slate-100 bg-slate-50/50 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Store size={18} className="text-accent" />
|
||||
<span className="font-semibold text-sm text-slate-800">{restaurantName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-3">Rate food quality and kitchen preparation</p>
|
||||
<StarRating value={restaurantRating} onChange={setRestaurantRating} size={28} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-100 bg-slate-50/50 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Truck size={18} className="text-indigo-600" />
|
||||
<span className="font-semibold text-sm text-slate-800">{driverName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-3">Rate delivery speed and care</p>
|
||||
<StarRating value={driverRating} onChange={setDriverRating} size={28} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">
|
||||
Comments or Feedback (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Tell us what you loved or how we can improve..."
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-border bg-white p-3 text-sm text-slate-900 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-xl border border-border py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={ratingMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-accent py-2.5 text-sm font-semibold text-white shadow-md hover:bg-accent/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Send size={16} />
|
||||
{ratingMutation.isPending ? 'Submitting...' : 'Submit Review'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
frontend/src/components/customer/StoreDetailModal.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, resolveMediaUrl } from '../../api';
|
||||
import { Clock, MapPin, Minus, Plus, ShoppingBag, Sparkles, Store, X } from 'lucide-react';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
ingredients?: string;
|
||||
price: number;
|
||||
image_url?: string;
|
||||
is_available: number | boolean;
|
||||
}
|
||||
|
||||
interface Restaurant {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
address: string;
|
||||
image_url?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface StoreDetailModalProps {
|
||||
restaurantId: string | null;
|
||||
onClose: () => void;
|
||||
cartItems: { item: MenuItem; quantity: number }[];
|
||||
onAddToCart: (item: MenuItem, quantity: number) => void;
|
||||
onOpenCart?: () => void;
|
||||
}
|
||||
|
||||
export default function StoreDetailModal({
|
||||
restaurantId,
|
||||
onClose,
|
||||
cartItems,
|
||||
onAddToCart,
|
||||
onOpenCart,
|
||||
}: StoreDetailModalProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
|
||||
const { data: restaurantRes, isLoading: isRestLoading } = useQuery({
|
||||
queryKey: ['restaurant', restaurantId],
|
||||
queryFn: () => api.get(`/api/restaurants/${restaurantId}`),
|
||||
enabled: Boolean(restaurantId),
|
||||
});
|
||||
|
||||
const { data: menuRes, isLoading: isMenuLoading } = useQuery({
|
||||
queryKey: ['menu-items', restaurantId],
|
||||
queryFn: () => api.get(`/api/restaurants/${restaurantId}/menu-items`),
|
||||
enabled: Boolean(restaurantId),
|
||||
});
|
||||
|
||||
if (!restaurantId) return null;
|
||||
|
||||
const restaurant: Restaurant | null = restaurantRes?.data?.restaurant ?? null;
|
||||
const menuItems: MenuItem[] = Array.isArray(menuRes?.data?.menu_items)
|
||||
? menuRes.data.menu_items
|
||||
: [];
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(menuItems.map((i) => i.category || 'General').filter(Boolean)))];
|
||||
|
||||
const filteredItems = activeCategory === 'all'
|
||||
? menuItems
|
||||
: menuItems.filter((i) => (i.category || 'General') === activeCategory);
|
||||
|
||||
const totalCartCount = cartItems.reduce((acc, i) => acc + i.quantity, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="relative flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-surface shadow-2xl border border-border">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-surface/80 text-foreground shadow-md transition hover:bg-surface border border-border"
|
||||
aria-label="Close details"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{isRestLoading ? (
|
||||
<div className="flex h-64 items-center justify-center text-mutedForeground">
|
||||
<Clock className="h-6 w-6 animate-spin mr-2 text-accent" /> Loading store details...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-48 w-full bg-slate-900 md:h-64">
|
||||
{restaurant?.image_url ? (
|
||||
<img
|
||||
src={resolveMediaUrl(restaurant.image_url)}
|
||||
alt={restaurant.name}
|
||||
className="h-full w-full object-cover brightness-75"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-800 text-slate-400">
|
||||
<Store className="h-16 w-16 opacity-40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
<div className="absolute bottom-4 left-6 right-6 text-white">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${restaurant?.status === 'active' ? 'bg-emerald-500' : 'bg-rose-500'}`} />
|
||||
<span className="text-xs uppercase tracking-wider font-semibold opacity-90">
|
||||
{restaurant?.status === 'active' ? 'Open for Orders' : 'Currently Closed'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">{restaurant?.name}</h2>
|
||||
<p className="flex items-center text-sm text-slate-300 mt-1">
|
||||
<MapPin className="h-4 w-4 mr-1 text-accent shrink-0" />
|
||||
{restaurant?.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<div className="flex items-center gap-2 overflow-x-auto border-b border-border bg-surfaceSecondary p-3 px-6">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-semibold uppercase tracking-wider transition ${activeCategory === cat ? 'bg-primary text-primaryForeground shadow' : 'bg-surface text-mutedForeground hover:text-foreground'}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isMenuLoading ? (
|
||||
<div className="py-12 text-center text-mutedForeground">Loading menu items...</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="py-12 text-center text-mutedForeground">No dishes found in this category.</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{filteredItems.map((dish) => {
|
||||
const cartItem = cartItems.find((ci) => ci.item.id === dish.id);
|
||||
const qty = cartItem ? cartItem.quantity : 0;
|
||||
const isAvailable = Boolean(dish.is_available);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dish.id}
|
||||
className="flex flex-col justify-between rounded-xl border border-border bg-surface p-4 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{dish.image_url && (
|
||||
<img
|
||||
src={resolveMediaUrl(dish.image_url)}
|
||||
alt={dish.name}
|
||||
className="h-20 w-20 rounded-lg object-cover border border-border shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h4 className="font-display font-semibold text-foreground text-base">{dish.name}</h4>
|
||||
<span className="font-mono font-bold text-accent text-sm ml-2">
|
||||
${Number(dish.price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{dish.description && (
|
||||
<p className="mt-1 text-xs text-mutedForeground line-clamp-2">{dish.description}</p>
|
||||
)}
|
||||
{dish.ingredients && (
|
||||
<p className="mt-1 text-[11px] text-slate-500 italic flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-amber-500 inline" /> {dish.ingredients}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-3">
|
||||
<span className={`text-xs font-medium ${isAvailable ? 'text-emerald-600' : 'text-rose-500'}`}>
|
||||
{isAvailable ? 'Available' : 'Sold Out'}
|
||||
</span>
|
||||
{isAvailable && (
|
||||
<div className="flex items-center gap-2">
|
||||
{qty > 0 ? (
|
||||
<div className="flex items-center rounded-lg border border-border bg-surfaceSecondary p-0.5">
|
||||
<button
|
||||
onClick={() => onAddToCart(dish, -1)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-surface text-foreground"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="w-6 text-center font-mono text-xs font-semibold">{qty}</span>
|
||||
<button
|
||||
onClick={() => onAddToCart(dish, 1)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-surface text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAddToCart(dish, 1)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accentForeground shadow hover:opacity-90 transition"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add to Order
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalCartCount > 0 && onOpenCart && (
|
||||
<div className="border-t border-border bg-surface p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-mutedForeground">Items in Cart</p>
|
||||
<p className="font-mono font-bold text-foreground text-sm">{totalCartCount} item(s) selected</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenCart();
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl bg-accent px-5 py-2.5 text-sm font-semibold text-accentForeground shadow-md hover:opacity-90 transition"
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4" /> View Cart & Checkout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/components/delivery/ActiveTripTracker.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import MapView, { MapMarker } from '../MapView';
|
||||
import { CheckCircle, MapPin, Navigation, PackageCheck, Store } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ActiveTripTrackerProps {
|
||||
order: any;
|
||||
onTripCompleted?: () => void;
|
||||
}
|
||||
|
||||
export default function ActiveTripTracker({ order, onTripCompleted }: ActiveTripTrackerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [gpsStatus, setGpsStatus] = useState<'off' | 'tracking' | 'error'>('off');
|
||||
const [currentCoords, setCurrentCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const watchIdRef = useRef<number | null>(null);
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async (nextStatus: 'picked_up' | 'delivered') => {
|
||||
const res = await api.patch(`/api/delivery/orders/${order.id}/status`, { status: nextStatus });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data, nextStatus) => {
|
||||
toast.success(nextStatus === 'picked_up' ? 'Order marked as Picked Up!' : 'Order marked as Delivered!');
|
||||
queryClient.invalidateQueries({ queryKey: ['delivery-orders-active'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['delivery-orders-available'] });
|
||||
if (nextStatus === 'delivered' && onTripCompleted) {
|
||||
onTripCompleted();
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.msg || 'Failed to update order status');
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setGpsStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setGpsStatus('tracking');
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setCurrentCoords({ lat, lng });
|
||||
|
||||
api.patch('/api/delivery/location', { latitude: lat, longitude: lng }).catch(() => {});
|
||||
},
|
||||
(err) => {
|
||||
console.error('GPS tracking error:', err);
|
||||
setGpsStatus('error');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const restLat = typeof order?.restaurant_latitude === 'number' ? order.restaurant_latitude : order?.delivery_latitude || 37.7749;
|
||||
const restLng = typeof order?.restaurant_longitude === 'number' ? order.restaurant_longitude : order?.delivery_longitude || -122.4194;
|
||||
const delivLat = typeof order?.delivery_latitude === 'number' ? order.delivery_latitude : 37.7749;
|
||||
const delivLng = typeof order?.delivery_longitude === 'number' ? order.delivery_longitude : -122.4194;
|
||||
|
||||
const markers: MapMarker[] = [
|
||||
{ lat: restLat, lng: restLng, label: order?.restaurant_name || 'Restaurant', type: 'restaurant' },
|
||||
{ lat: delivLat, lng: delivLng, label: 'Customer Drop-off', type: 'customer' }
|
||||
];
|
||||
|
||||
if (currentCoords) {
|
||||
markers.push({ lat: currentCoords.lat, lng: currentCoords.lng, label: 'Your Location (Driver)', type: 'driver' });
|
||||
}
|
||||
|
||||
const mapCenter: [number, number] = currentCoords
|
||||
? [currentCoords.lat, currentCoords.lng]
|
||||
: [restLat, restLng];
|
||||
|
||||
const currentStatus = order?.status || 'assigned';
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border p-5 space-y-4 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded bg-accent/10 text-accent">
|
||||
Active Trip #{order?.id?.slice(0, 8)}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium flex items-center gap-1 ${gpsStatus === 'tracking' ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'}`}>
|
||||
<Navigation className="w-3 h-3 animate-pulse" />
|
||||
{gpsStatus === 'tracking' ? 'Live GPS Active' : 'GPS Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold font-display text-primary mt-1">
|
||||
{currentStatus === 'picked_up'
|
||||
? 'Deliver to Customer'
|
||||
: `Pickup from ${order?.restaurant_name || 'Restaurant'}`}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{currentStatus !== 'picked_up' && (
|
||||
<button
|
||||
onClick={() => updateStatusMutation.mutate('picked_up')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="inline-flex items-center gap-2 bg-primary hover:bg-slate-800 text-primary-foreground font-semibold px-4 py-2 rounded-lg text-sm transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<PackageCheck className="w-4 h-4" />
|
||||
Mark Picked Up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStatus === 'picked_up' && (
|
||||
<button
|
||||
onClick={() => updateStatusMutation.mutate('delivered')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="inline-flex items-center gap-2 bg-emerald-700 hover:bg-emerald-800 text-white font-semibold px-4 py-2 rounded-lg text-sm transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Mark Delivered
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="bg-surfaceSecondary p-3 rounded-lg border border-border flex items-start gap-3">
|
||||
<Store className="w-5 h-5 text-accent mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-mutedForeground font-semibold uppercase">Pickup Location</p>
|
||||
<p className="text-sm font-semibold text-primary">{order?.restaurant_name || 'Restaurant'}</p>
|
||||
<p className="text-xs text-mutedForeground">{order?.restaurant_address || 'Address provided on arrival'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surfaceSecondary p-3 rounded-lg border border-border flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-mutedForeground font-semibold uppercase">Drop-off Destination</p>
|
||||
<p className="text-sm font-semibold text-primary">{order?.delivery_address || 'Customer Delivery Address'}</p>
|
||||
<p className="text-xs text-mutedForeground">Total Order: ${Number(order?.total_amount || 0).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[220px] rounded-lg overflow-hidden border border-border">
|
||||
<MapView center={mapCenter} zoom={14} markers={markers} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
355
frontend/src/components/features/DishModal.tsx
Normal file
@ -0,0 +1,355 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Check, Image as ImageIcon, Loader2, Upload, Utensils, X } from 'lucide-react';
|
||||
import { api, resolveMediaUrl } from '../../api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export interface DishToEdit {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
ingredients?: string;
|
||||
recipe?: string;
|
||||
price: number;
|
||||
image_url?: string;
|
||||
is_available: number | boolean;
|
||||
}
|
||||
|
||||
export interface DishModalProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
restaurantId?: string;
|
||||
dishToEdit?: DishToEdit | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function DishModal({
|
||||
isOpen = false,
|
||||
onClose = () => {},
|
||||
restaurantId = '',
|
||||
dishToEdit = null,
|
||||
onSuccess,
|
||||
}: DishModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState('Mains');
|
||||
const [price, setPrice] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [ingredients, setIngredients] = useState('');
|
||||
const [recipe, setRecipe] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [isAvailable, setIsAvailable] = useState(true);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (dishToEdit) {
|
||||
setName(dishToEdit.name || '');
|
||||
setCategory(dishToEdit.category || 'Mains');
|
||||
setPrice(dishToEdit.price !== undefined ? String(dishToEdit.price) : '');
|
||||
setDescription(dishToEdit.description || '');
|
||||
setIngredients(dishToEdit.ingredients || '');
|
||||
setRecipe(dishToEdit.recipe || '');
|
||||
setImageUrl(dishToEdit.image_url || '');
|
||||
setIsAvailable(Boolean(dishToEdit.is_available));
|
||||
} else {
|
||||
setName('');
|
||||
setCategory('Mains');
|
||||
setPrice('');
|
||||
setDescription('');
|
||||
setIngredients('');
|
||||
setRecipe('');
|
||||
setImageUrl('');
|
||||
setIsAvailable(true);
|
||||
}
|
||||
setErrorMsg('');
|
||||
}, [dishToEdit, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await api.post('/api/upload', formData, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
|
||||
if (res.data?.success && res.data?.url) {
|
||||
setImageUrl(res.data.url);
|
||||
} else {
|
||||
setErrorMsg('Image upload failed.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Upload error:', err);
|
||||
setErrorMsg(err?.response?.data?.msg || 'Failed to upload image.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!String((name) ?? '').trim() || !price) {
|
||||
setErrorMsg('Dish name and price are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const numericPrice = Number(price);
|
||||
if (isNaN(numericPrice) || numericPrice < 0) {
|
||||
setErrorMsg('Please enter a valid price.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: String((name) ?? '').trim(),
|
||||
category: String((category) ?? '').trim() || 'Mains',
|
||||
price: numericPrice,
|
||||
description: String((description) ?? '').trim(),
|
||||
ingredients: String((ingredients) ?? '').trim(),
|
||||
recipe: String((recipe) ?? '').trim(),
|
||||
image_url: imageUrl,
|
||||
is_available: isAvailable ? 1 : 0,
|
||||
};
|
||||
|
||||
if (dishToEdit?.id) {
|
||||
await api.patch(`/api/menu-items/${dishToEdit.id}`, payload);
|
||||
} else {
|
||||
await api.post('/api/menu-items', {
|
||||
restaurant_id: restaurantId,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (restaurantId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['menu-items', restaurantId] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['restaurant-me'] });
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error('Dish save error:', err);
|
||||
setErrorMsg(err?.response?.data?.msg || 'Failed to save dish menu item.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl border border-[#E2E1DA] overflow-hidden my-8">
|
||||
<div className="p-5 bg-[#F8F7F4] border-b border-[#E2E1DA] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#B93812] text-white rounded-lg">
|
||||
<Utensils className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display font-bold text-lg text-slate-900">
|
||||
{dishToEdit?.id ? 'Edit Menu Item' : 'Add New Menu Item'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">Configure kitchen preparation & pricing details</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4 max-h-[80vh] overflow-y-auto">
|
||||
{errorMsg && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Dish Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Chicken Biryani"
|
||||
className="w-full px-3 py-2 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
>
|
||||
<option value="Mains">Mains</option>
|
||||
<option value="Appetizers">Appetizers</option>
|
||||
<option value="Breads & Sides">Breads & Sides</option>
|
||||
<option value="Desserts">Desserts</option>
|
||||
<option value="Beverages">Beverages</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Price ($ USD) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="14.99"
|
||||
className="w-full px-3 py-2 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 sm:pt-0">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAvailable}
|
||||
onChange={(e) => setIsAvailable(e.target.checked)}
|
||||
className="w-4 h-4 text-[#B93812] rounded border-[#E2E1DA] focus:ring-[#B93812]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-800">Available in Kitchen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Fragrant basmati rice slow-cooked with tender chicken and authentic spiced curry."
|
||||
className="w-full px-3 py-2 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Ingredients
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredients}
|
||||
onChange={(e) => setIngredients(e.target.value)}
|
||||
placeholder="Chicken, Basmati rice, Saffron, Ghee"
|
||||
className="w-full px-3 py-2 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Recipe Notes
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipe}
|
||||
onChange={(e) => setRecipe(e.target.value)}
|
||||
placeholder="Marinate 4hrs, dum cook on low flame"
|
||||
className="w-full px-3 py-2 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">
|
||||
Dish Photo
|
||||
</label>
|
||||
<div className="flex items-center gap-4 p-3 bg-[#F8F7F4] border border-dashed border-[#E2E1DA] rounded-xl">
|
||||
{imageUrl ? (
|
||||
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-[#E2E1DA] flex-shrink-0">
|
||||
<img
|
||||
src={resolveMediaUrl(imageUrl)}
|
||||
alt="Dish Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-lg bg-slate-200/60 text-slate-400 flex items-center justify-center flex-shrink-0">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="dish-photo-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="dish-photo-upload"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-white border border-[#E2E1DA] rounded-lg text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer shadow-sm"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#B93812]" /> Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-3.5 h-3.5 text-[#B93812]" /> Choose Image File
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400 mt-1">PNG, JPG or WEBP up to 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#E8E7E0] flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isUploading}
|
||||
className="px-5 py-2 bg-[#0F172A] hover:bg-slate-800 text-white font-semibold text-sm rounded-lg shadow-sm flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-emerald-400" /> Save Dish
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
frontend/src/components/features/FileUploadPanel.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Copy, File, FileText, Image as ImageIcon, Loader2, RefreshCw, Upload, X } from 'lucide-react';
|
||||
import { api, resolveMediaUrl } from '../../api';
|
||||
|
||||
interface FileUploadPanelProps {
|
||||
onUploadSuccess?: (url: string) => void;
|
||||
label?: string;
|
||||
accept?: string;
|
||||
currentUrl?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FileUploadPanel({
|
||||
onUploadSuccess,
|
||||
label = 'Upload Media Asset',
|
||||
accept = 'image/*',
|
||||
currentUrl = '',
|
||||
className = '',
|
||||
}: FileUploadPanelProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(currentUrl ? resolveMediaUrl(currentUrl) : null);
|
||||
const [uploadedUrl, setUploadedUrl] = useState<string>(currentUrl);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileSelect = (selectedFile: File) => {
|
||||
setError(null);
|
||||
setFile(selectedFile);
|
||||
|
||||
if (selectedFile.type.startsWith('image/')) {
|
||||
const objectUrl = URL.createObjectURL(selectedFile);
|
||||
setPreview(objectUrl);
|
||||
} else {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileSelect(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileSelect(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const uploadFile = async () => {
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await api.post('/api/upload', formData, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
|
||||
if (res.data && res.data.success && res.data.url) {
|
||||
const url = res.data.url;
|
||||
setUploadedUrl(url);
|
||||
if (onUploadSuccess) {
|
||||
onUploadSuccess(url);
|
||||
}
|
||||
} else {
|
||||
throw new Error(res.data?.msg || 'Failed to upload asset');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.msg || err?.message || 'File upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!uploadedUrl) return;
|
||||
const fullUrl = resolveMediaUrl(uploadedUrl);
|
||||
navigator.clipboard.writeText(fullUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setUploadedUrl('');
|
||||
setError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 bg-white rounded-xl border border-[#E2E1DA] shadow-sm ${className}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-semibold text-slate-900 flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-[#B93812]" />
|
||||
{label}
|
||||
</label>
|
||||
{uploadedUrl && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{!file && !uploadedUrl ? (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-[#B93812] bg-orange-50/50'
|
||||
: 'border-slate-300 hover:border-slate-400 bg-[#F8F7F4]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mb-2 text-slate-600">
|
||||
<Upload className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-800">
|
||||
Click to select or drag and drop image file
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
JPG, PNG, WebP up to 10MB
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{preview ? (
|
||||
<div className="relative rounded-lg overflow-hidden border border-slate-200 bg-slate-950 max-h-48 flex items-center justify-center">
|
||||
<img
|
||||
src={preview}
|
||||
alt="Selected asset preview"
|
||||
className="max-h-48 w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="absolute top-2 right-2 bg-slate-900/80 hover:bg-slate-900 text-white p-1 rounded-full text-xs transition-colors"
|
||||
title="Remove asset"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-slate-50 rounded-lg border border-slate-200 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 overflow-hidden">
|
||||
<FileText className="w-8 h-8 text-slate-400 flex-shrink-0" />
|
||||
<div className="truncate text-xs">
|
||||
<p className="font-medium text-slate-900 truncate">
|
||||
{file ? file.name : 'Uploaded File'}
|
||||
</p>
|
||||
{file && (
|
||||
<p className="text-slate-500">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && !uploadedUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadFile}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-[#0F172A] hover:bg-slate-800 text-white rounded-lg text-xs font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading Asset...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Confirm Upload
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{uploadedUrl && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center justify-between text-xs bg-slate-50 p-2 rounded border border-slate-200">
|
||||
<span className="font-mono text-slate-600 truncate mr-2 text-[11px]">
|
||||
{uploadedUrl}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-[#B93812] font-medium hover:underline flex-shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-emerald-600">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copy URL
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium text-slate-700 bg-slate-100 hover:bg-slate-200 rounded transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Replace File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 text-red-600" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
frontend/src/components/features/MapPanel.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import MapView, { MapMarker } from '../MapView';
|
||||
import { AlertCircle, Compass, Loader2, Locate, MapPin, Navigation, Search, Store, Truck, User } from 'lucide-react';
|
||||
|
||||
export interface MapPanelProps {
|
||||
orderId?: string;
|
||||
restaurantLocation?: { lat: number; lng: number; name?: string };
|
||||
deliveryLocation?: { lat: number; lng: number; address?: string };
|
||||
driverLocation?: { lat: number; lng: number; name?: string };
|
||||
onLocationSelect?: (lat: number, lng: number, address?: string) => void;
|
||||
interactiveSelect?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MapPanel({
|
||||
orderId,
|
||||
restaurantLocation,
|
||||
deliveryLocation,
|
||||
driverLocation: propDriverLocation,
|
||||
onLocationSelect,
|
||||
interactiveSelect = false,
|
||||
className = '',
|
||||
}: MapPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{ lat: number; lon: number; display_name: string }>>([]);
|
||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number; address?: string } | null>(null);
|
||||
const [geolocating, setGeolocating] = useState(false);
|
||||
const [geoError, setGeoError] = useState<string | null>(null);
|
||||
|
||||
// Poll live driver position if orderId is provided
|
||||
const { data: posData } = useQuery({
|
||||
queryKey: ['order-position', orderId],
|
||||
queryFn: async () => {
|
||||
if (!orderId) return null;
|
||||
const res = await api.get(`/api/orders/${orderId}/position`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(orderId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const livePos = posData?.position;
|
||||
const currentDriverPos = livePos?.latitude && livePos?.longitude
|
||||
? { lat: Number(livePos.latitude), lng: Number(livePos.longitude) }
|
||||
: propDriverLocation;
|
||||
|
||||
// Determine center map coordinate
|
||||
const defaultCenter: [number, number] = [37.7749, -122.4194];
|
||||
let center: [number, number] = defaultCenter;
|
||||
|
||||
if (selectedLocation) {
|
||||
center = [selectedLocation.lat, selectedLocation.lng];
|
||||
} else if (currentDriverPos?.lat && currentDriverPos?.lng) {
|
||||
center = [currentDriverPos.lat, currentDriverPos.lng];
|
||||
} else if (restaurantLocation?.lat && restaurantLocation?.lng) {
|
||||
center = [restaurantLocation.lat, restaurantLocation.lng];
|
||||
} else if (deliveryLocation?.lat && deliveryLocation?.lng) {
|
||||
center = [deliveryLocation.lat, deliveryLocation.lng];
|
||||
}
|
||||
|
||||
// Prepare map markers
|
||||
const markers: MapMarker[] = [];
|
||||
|
||||
if (restaurantLocation?.lat && restaurantLocation?.lng) {
|
||||
markers.push({
|
||||
lat: restaurantLocation.lat,
|
||||
lng: restaurantLocation.lng,
|
||||
label: restaurantLocation.name ? `Kitchen: ${restaurantLocation.name}` : 'Restaurant Pickup',
|
||||
type: 'restaurant',
|
||||
});
|
||||
}
|
||||
|
||||
if (deliveryLocation?.lat && deliveryLocation?.lng) {
|
||||
markers.push({
|
||||
lat: deliveryLocation.lat,
|
||||
lng: deliveryLocation.lng,
|
||||
label: deliveryLocation.address ? `Dropoff: ${deliveryLocation.address}` : 'Customer Delivery',
|
||||
type: 'customer',
|
||||
});
|
||||
}
|
||||
|
||||
if (currentDriverPos?.lat && currentDriverPos?.lng) {
|
||||
markers.push({
|
||||
lat: currentDriverPos.lat,
|
||||
lng: currentDriverPos.lng,
|
||||
label: currentDriverPos.name ? `Courier: ${currentDriverPos.name}` : 'Driver Live Position',
|
||||
type: 'driver',
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedLocation) {
|
||||
markers.push({
|
||||
lat: selectedLocation.lat,
|
||||
lng: selectedLocation.lng,
|
||||
label: selectedLocation.address || 'Selected Location',
|
||||
type: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!String((searchQuery) ?? '').trim()) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await api.get(`/api/geocode?q=${encodeURIComponent(searchQuery)}`);
|
||||
if (res.data?.results && Array.isArray(res.data.results)) {
|
||||
setSearchResults(res.data.results);
|
||||
} else if (Array.isArray(res.data)) {
|
||||
setSearchResults(res.data);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Geocode search failed:', err);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSearchResult = (res: { lat: number; lon: number; display_name: string }) => {
|
||||
const lat = Number(res.lat);
|
||||
const lng = Number(res.lon);
|
||||
const loc = { lat, lng, address: res.display_name };
|
||||
setSelectedLocation(loc);
|
||||
setSearchResults([]);
|
||||
if (onLocationSelect) {
|
||||
onLocationSelect(lat, lng, res.display_name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMapClick = async (lat: number, lng: number) => {
|
||||
if (!interactiveSelect) return;
|
||||
let addressName = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
try {
|
||||
const res = await api.get(`/api/reverse-geocode?lat=${lat}&lon=${lng}`);
|
||||
if (res.data?.display_name || res.data?.address) {
|
||||
addressName = res.data.display_name || res.data.address;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to coordinates
|
||||
}
|
||||
const loc = { lat, lng, address: addressName };
|
||||
setSelectedLocation(loc);
|
||||
if (onLocationSelect) {
|
||||
onLocationSelect(lat, lng, addressName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setGeoError('Geolocation is not supported by your browser.');
|
||||
return;
|
||||
}
|
||||
setGeolocating(true);
|
||||
setGeoError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
let addressName = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
try {
|
||||
const res = await api.get(`/api/reverse-geocode?lat=${lat}&lon=${lng}`);
|
||||
if (res.data?.display_name || res.data?.address) {
|
||||
addressName = res.data.display_name || res.data.address;
|
||||
}
|
||||
} catch {
|
||||
// Ignore reverse geocode failure
|
||||
}
|
||||
const loc = { lat, lng, address: addressName };
|
||||
setSelectedLocation(loc);
|
||||
setGeolocating(false);
|
||||
if (onLocationSelect) {
|
||||
onLocationSelect(lat, lng, addressName);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setGeolocating(false);
|
||||
setGeoError(err.message || 'Unable to retrieve location.');
|
||||
},
|
||||
{ timeout: 10000, enableHighAccuracy: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-border bg-surface p-4 shadow-sm ${className}`}>
|
||||
{interactiveSelect && (
|
||||
<div className="mb-4 space-y-3">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-mutedForeground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search delivery address or landmark..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background py-2 pl-9 pr-3 text-sm focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSearching}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primaryForeground transition hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Compass className="h-3.5 w-3.5" />}
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseCurrentLocation}
|
||||
disabled={geolocating}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surfaceSecondary px-3 py-2 text-xs font-semibold text-foreground transition hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{geolocating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Locate className="h-3.5 w-3.5" />}
|
||||
Current Location
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{geoError && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-2 text-xs text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{geoError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border bg-surface shadow-md">
|
||||
{searchResults.map((item, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectSearchResult(item)}
|
||||
className="flex w-full items-start gap-2 border-b border-border/50 p-2 text-left text-xs transition hover:bg-surfaceSecondary last:border-0"
|
||||
>
|
||||
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="line-clamp-2">{item.display_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(restaurantLocation || deliveryLocation || currentDriverPos || selectedLocation) && (
|
||||
<div className="mb-3 flex flex-wrap gap-2 text-xs">
|
||||
{restaurantLocation && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-800">
|
||||
<Store className="h-3 w-3 text-slate-600" />
|
||||
{restaurantLocation.name || 'Kitchen Pickup'}
|
||||
</span>
|
||||
)}
|
||||
{deliveryLocation && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 px-2.5 py-1 font-medium text-amber-900">
|
||||
<User className="h-3 w-3 text-amber-700" />
|
||||
{deliveryLocation.address ? deliveryLocation.address.split(',')[0] : 'Dropoff Point'}
|
||||
</span>
|
||||
)}
|
||||
{currentDriverPos && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-1 font-semibold text-emerald-800 animate-pulse">
|
||||
<Truck className="h-3 w-3 text-emerald-600" />
|
||||
Driver Live GPS
|
||||
</span>
|
||||
)}
|
||||
{selectedLocation && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-900 px-2.5 py-1 font-medium text-white">
|
||||
<Navigation className="h-3 w-3 text-accent" />
|
||||
{selectedLocation.address ? selectedLocation.address.split(',')[0] : 'Pin Target'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapView
|
||||
center={center}
|
||||
zoom={13}
|
||||
markers={markers}
|
||||
className="h-[300px] sm:h-[380px] w-full rounded-lg border border-border"
|
||||
onMapClick={interactiveSelect ? handleMapClick : undefined}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-mutedForeground">
|
||||
{selectedLocation?.address ? (
|
||||
<span className="truncate font-medium text-foreground">
|
||||
Selected: {selectedLocation.address}
|
||||
</span>
|
||||
) : interactiveSelect ? (
|
||||
<span>Click anywhere on the map or search to place a dropoff pin.</span>
|
||||
) : (
|
||||
<span>Live location synchronized with road network.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/features/NotificationsPanel.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle, Bell, CheckCircle2, Clock, ShoppingBag, Truck, Utensils, X } from 'lucide-react';
|
||||
import { api } from '../../api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface NotificationsPanelProps {
|
||||
className?: string;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export default function NotificationsPanel({ className, isOpen: externalIsOpen, onToggle }: NotificationsPanelProps) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
||||
const [dismissedIds, setDismissedIds] = useState<string[]>([]);
|
||||
|
||||
const isOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen;
|
||||
const togglePanel = () => {
|
||||
if (onToggle) {
|
||||
onToggle();
|
||||
} else {
|
||||
setInternalIsOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: countData } = useQuery({
|
||||
queryKey: ['notification-count', user?.email],
|
||||
queryFn: async () => {
|
||||
if (!isAuthenticated) return { count: 0 };
|
||||
try {
|
||||
const res = await api.get('/api/notifications/count');
|
||||
return res.data;
|
||||
} catch {
|
||||
return { count: 0 };
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: myOrdersData } = useQuery({
|
||||
queryKey: ['my-orders-notifications', user?.role],
|
||||
queryFn: async () => {
|
||||
if (!isAuthenticated || !user?.role) return [];
|
||||
try {
|
||||
if (user.role === 'customer') {
|
||||
const res = await api.get('/api/orders/my');
|
||||
const orders = Array.isArray(res.data?.orders) ? res.data.orders : [];
|
||||
return orders.slice(0, 5);
|
||||
} else if (user.role === 'restaurant') {
|
||||
const res = await api.get('/api/restaurants/me/orders');
|
||||
const orders = Array.isArray(res.data?.orders) ? res.data.orders : [];
|
||||
return orders.slice(0, 5);
|
||||
} else if (user.role === 'delivery_agent') {
|
||||
const res = await api.get('/api/delivery/orders/active');
|
||||
const orders = Array.isArray(res.data?.orders) ? res.data.orders : [];
|
||||
return orders.slice(0, 5);
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated && isOpen,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const apiCount = countData?.count ?? countData?.unread_count ?? 0;
|
||||
const orders = Array.isArray(myOrdersData) ? myOrdersData : [];
|
||||
|
||||
const generatedNotifications = orders
|
||||
.map((order: any) => {
|
||||
const id = String(order.id || '');
|
||||
const status = String(order.status || '');
|
||||
let title = `Order #${id.slice(0, 8)}`;
|
||||
let body = `Status: ${status.replace('_', ' ')}`;
|
||||
let icon = ShoppingBag;
|
||||
let colorClass = 'bg-slate-100 text-slate-700';
|
||||
|
||||
if (status === 'placed') {
|
||||
title = 'New Order Placed';
|
||||
body = `Order #${id.slice(0, 8)} is waiting for restaurant acceptance.`;
|
||||
icon = Clock;
|
||||
colorClass = 'bg-amber-100 text-amber-800';
|
||||
} else if (status === 'accepted' || status === 'preparing') {
|
||||
title = 'Meal in Kitchen Prep';
|
||||
body = `Kitchen is preparing Order #${id.slice(0, 8)}.`;
|
||||
icon = Utensils;
|
||||
colorClass = 'bg-orange-100 text-orange-800';
|
||||
} else if (status === 'ready_for_pickup') {
|
||||
title = 'Ready for Courier Pickup';
|
||||
body = `Order #${id.slice(0, 8)} is cooked and ready for delivery driver.`;
|
||||
icon = Truck;
|
||||
colorClass = 'bg-blue-100 text-blue-800';
|
||||
} else if (status === 'picked_up' || status === 'assigned') {
|
||||
title = 'Courier On The Way';
|
||||
body = `Delivery partner has collected Order #${id.slice(0, 8)} and is en route.`;
|
||||
icon = Truck;
|
||||
colorClass = 'bg-indigo-100 text-indigo-800';
|
||||
} else if (status === 'delivered') {
|
||||
title = 'Meal Delivered!';
|
||||
body = `Order #${id.slice(0, 8)} was delivered to dropoff location.`;
|
||||
icon = CheckCircle2;
|
||||
colorClass = 'bg-emerald-100 text-emerald-800';
|
||||
} else if (status === 'cancelled') {
|
||||
title = 'Order Cancelled';
|
||||
body = `Order #${id.slice(0, 8)} was cancelled.`;
|
||||
icon = AlertCircle;
|
||||
colorClass = 'bg-red-100 text-red-800';
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
created_at: order.updated_at || order.created_at,
|
||||
icon,
|
||||
colorClass,
|
||||
status,
|
||||
};
|
||||
})
|
||||
.filter((item) => !dismissedIds.includes(item.id));
|
||||
|
||||
const totalBadgeCount = Math.max(apiCount, generatedNotifications.length);
|
||||
|
||||
const dismissNotification = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDismissedIds((prev) => [...prev, id]);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
const allIds = generatedNotifications.map((n) => n.id);
|
||||
setDismissedIds((prev) => [...prev, ...allIds]);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('relative inline-block', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
aria-label="Toggle notifications"
|
||||
className="relative p-2 rounded-lg text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{totalBadgeCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[#B93812] px-1 text-[10px] font-bold text-white shadow-sm">
|
||||
{totalBadgeCount > 9 ? '9+' : totalBadgeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/10 md:hidden"
|
||||
onClick={togglePanel}
|
||||
/>
|
||||
|
||||
<div className="absolute right-0 z-50 mt-2 w-80 sm:w-96 rounded-xl border border-[#E2E1DA] bg-white shadow-lg ring-1 ring-black/5 focus:outline-none overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-[#F8F7F4] border-b border-[#E2E1DA]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bell className="w-4 h-4 text-[#B93812]" />
|
||||
<span className="font-display font-semibold text-slate-900 text-sm">
|
||||
Live Notifications
|
||||
</span>
|
||||
{totalBadgeCount > 0 && (
|
||||
<span className="bg-[#B93812]/10 text-[#B93812] text-xs font-semibold px-2 py-0.5 rounded-full">
|
||||
{totalBadgeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{generatedNotifications.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="text-xs text-slate-500 hover:text-slate-800 font-medium transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto divide-y divide-slate-100">
|
||||
{generatedNotifications.length === 0 ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="mx-auto w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-2">
|
||||
<Bell className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-800">No new notifications</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Updates on your orders and account will appear here in real time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
generatedNotifications.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 hover:bg-slate-50 transition-colors flex items-start space-x-3 group relative"
|
||||
>
|
||||
<div className={cn('p-2 rounded-lg flex-shrink-0 mt-0.5', item.colorClass)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<p className="text-xs font-semibold text-slate-900 truncate">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 mt-0.5 line-clamp-2">
|
||||
{item.body}
|
||||
</p>
|
||||
{item.created_at && (
|
||||
<span className="text-[10px] text-slate-400 mt-1 block">
|
||||
{new Date(item.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => dismissNotification(item.id, e)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-slate-400 hover:text-slate-600 p-1"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 bg-[#F8F7F4] border-t border-[#E2E1DA] text-center">
|
||||
<span className="text-[11px] text-slate-500 font-medium">
|
||||
Live order telemetry active • Auto-refreshed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
frontend/src/components/features/PaymentsPanel.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertCircle, ArrowRight, CreditCard, MapPin, Minus, Plus, ShoppingBag } from 'lucide-react';
|
||||
import { load } from '@cashfreepayments/cashfree-js';
|
||||
import { api } from '../../api';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
restaurant_id: string;
|
||||
special_instructions?: string;
|
||||
}
|
||||
|
||||
export interface PaymentsPanelProps {
|
||||
restaurantId?: string;
|
||||
restaurantName?: string;
|
||||
cartItems?: CartItem[];
|
||||
onUpdateQuantity?: (id: string, delta: number) => void;
|
||||
onClearCart?: () => void;
|
||||
onOrderSuccess?: (orderId: string) => void;
|
||||
}
|
||||
|
||||
export default function PaymentsPanel({
|
||||
restaurantId = '',
|
||||
restaurantName = 'Local Kitchen',
|
||||
cartItems = [],
|
||||
onUpdateQuantity,
|
||||
onClearCart,
|
||||
onOrderSuccess,
|
||||
}: PaymentsPanelProps) {
|
||||
const [deliveryAddress, setDeliveryAddress] = useState('123 Culinary Way, Downtown, City');
|
||||
const [coords, setCoords] = useState<{ lat: number; lng: number }>({ lat: 37.7749, lng: -122.4194 });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const subtotal = cartItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
|
||||
const deliveryFee = cartItems.length > 0 ? 5.00 : 0.00;
|
||||
const totalAmount = subtotal + deliveryFee;
|
||||
|
||||
const handleLocateMe = () => {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setCoords({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
setDeliveryAddress(`GPS Location (${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)})`);
|
||||
},
|
||||
() => {
|
||||
setErrorMessage('Could not fetch GPS location. Please type manually.');
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (cartItems.length === 0) return;
|
||||
if (!String((deliveryAddress) ?? '').trim()) {
|
||||
setErrorMessage('Please enter a valid delivery address.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/app/orders`;
|
||||
const payload = {
|
||||
restaurant_id: restaurantId || cartItems[0]?.restaurant_id,
|
||||
delivery_address: deliveryAddress,
|
||||
delivery_latitude: coords.lat,
|
||||
delivery_longitude: coords.lng,
|
||||
return_url: returnUrl,
|
||||
items: cartItems.map((item) => ({
|
||||
menu_item_id: item.id,
|
||||
quantity: item.quantity,
|
||||
special_instructions: item.special_instructions || '',
|
||||
})),
|
||||
};
|
||||
|
||||
const res = await api.post('/api/orders', payload);
|
||||
const data = res.data;
|
||||
|
||||
if (!data.success && !data.order) {
|
||||
throw new Error(data.msg || 'Failed to place order.');
|
||||
}
|
||||
|
||||
const createdOrder = data.order;
|
||||
const paymentSessionId = data.paymentSessionId || data.payment_session_id;
|
||||
|
||||
if (paymentSessionId) {
|
||||
try {
|
||||
const cashfree = await load({ mode: 'sandbox' });
|
||||
await cashfree.checkout({ paymentSessionId, redirectTarget: '_self' });
|
||||
return;
|
||||
} catch (sdkError) {
|
||||
console.warn('Cashfree SDK checkout bypassed:', sdkError);
|
||||
}
|
||||
}
|
||||
|
||||
if (onClearCart) onClearCart();
|
||||
if (onOrderSuccess && createdOrder?.id) {
|
||||
onOrderSuccess(createdOrder.id);
|
||||
} else {
|
||||
window.location.href = '/app/orders';
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err?.response?.data?.msg || err?.message || 'Failed to process payment and order.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border p-5 shadow-sm space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingBag className="w-5 h-5 text-accent" />
|
||||
<h2 className="font-display font-semibold text-lg text-foreground">
|
||||
Cart & Checkout
|
||||
</h2>
|
||||
</div>
|
||||
{restaurantName && (
|
||||
<span className="text-xs font-medium text-muted-foreground bg-surfaceSecondary px-2.5 py-1 rounded-md border border-border">
|
||||
{restaurantName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-xs">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cartItems.length === 0 ? (
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-surfaceSecondary border border-border flex items-center justify-center mx-auto text-muted-foreground">
|
||||
<ShoppingBag className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Your cart is currently empty.</p>
|
||||
<p className="text-xs text-muted-foreground/70">Select dishes from a restaurant to start your order.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-1 divide-y divide-border">
|
||||
{cartItems.map((item) => (
|
||||
<div key={item.id} className="pt-3 first:pt-0 flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">{item.name}</h4>
|
||||
<p className="text-xs text-muted-foreground">${item.price.toFixed(2)} each</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border border-border rounded-lg bg-surfaceSecondary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateQuantity && onUpdateQuantity(item.id, -1)}
|
||||
className="p-1 hover:bg-muted text-foreground transition-colors rounded-l-lg"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="px-2.5 text-xs font-semibold text-foreground">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateQuantity && onUpdateQuantity(item.id, 1)}
|
||||
className="p-1 hover:bg-muted text-foreground transition-colors rounded-r-lg"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-semibold text-foreground w-16 text-right">
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="text-xs font-medium text-foreground flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-accent" />
|
||||
Delivery Address
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocateMe}
|
||||
className="text-[11px] text-accent hover:underline font-medium"
|
||||
>
|
||||
Use Current GPS
|
||||
</button>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deliveryAddress}
|
||||
onChange={(e) => setDeliveryAddress(e.target.value)}
|
||||
placeholder="Enter complete street address"
|
||||
className="w-full text-xs px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surfaceSecondary/60 rounded-lg p-3 space-y-1.5 text-xs">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Items Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Delivery Fee</span>
|
||||
<span>${deliveryFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="border-t border-border pt-2 mt-2 flex justify-between font-semibold text-sm text-foreground">
|
||||
<span>Total Amount</span>
|
||||
<span className="text-accent">${totalAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckout}
|
||||
disabled={isSubmitting || cartItems.length === 0}
|
||||
className="w-full py-3 px-4 rounded-xl bg-accent text-accentForeground font-medium text-sm flex items-center justify-center gap-2 hover:opacity-95 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span>Processing Payment...</span>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
<span>Pay & Place Order (${totalAmount.toFixed(2)})</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
frontend/src/components/features/RestaurantCartDrawer.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRight, CreditCard, Loader2, MapPin, Minus, Plus, ShoppingBag, Trash2, X } from 'lucide-react';
|
||||
import { api, resolveMediaUrl } from '../../api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { load } from '@cashfreepayments/cashfree-js';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
special_instructions?: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface RestaurantCartDrawerProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
restaurantId?: string;
|
||||
restaurantName?: string;
|
||||
items?: CartItem[];
|
||||
onUpdateQuantity?: (id: string, delta: number) => void;
|
||||
onClearCart?: () => void;
|
||||
onOrderCreated?: (orderId: string) => void;
|
||||
}
|
||||
|
||||
export default function RestaurantCartDrawer({
|
||||
isOpen = false,
|
||||
onClose = () => {},
|
||||
restaurantId = '',
|
||||
restaurantName = 'Selected Kitchen',
|
||||
items = [],
|
||||
onUpdateQuantity,
|
||||
onClearCart,
|
||||
onOrderCreated,
|
||||
}: RestaurantCartDrawerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [address, setAddress] = useState('123 Main St, Market District');
|
||||
const [latitude] = useState(37.7749);
|
||||
const [longitude] = useState(-122.4194);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const deliveryFee = items.length > 0 ? 5.0 : 0.0;
|
||||
const total = subtotal + deliveryFee;
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (items.length === 0 || !restaurantId) return;
|
||||
if (!String((address) ?? '').trim()) {
|
||||
setErrorMsg('Please enter a delivery address.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const returnUrl = window.location.origin + '/app/orders';
|
||||
const payload = {
|
||||
restaurant_id: restaurantId,
|
||||
delivery_address: String((address) ?? '').trim(),
|
||||
delivery_latitude: latitude,
|
||||
delivery_longitude: longitude,
|
||||
items: items.map((item) => ({
|
||||
menu_item_id: item.id,
|
||||
quantity: item.quantity,
|
||||
special_instructions: item.special_instructions || '',
|
||||
})),
|
||||
return_url: returnUrl,
|
||||
};
|
||||
|
||||
const res = await api.post('/api/orders', payload);
|
||||
const data = res.data;
|
||||
|
||||
if (data?.success && data?.order) {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer-orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
|
||||
if (data.payment_session_id) {
|
||||
try {
|
||||
const cashfree = await load({ mode: 'sandbox' });
|
||||
await cashfree.checkout({
|
||||
paymentSessionId: data.payment_session_id,
|
||||
redirectTarget: '_self',
|
||||
});
|
||||
} catch (cfError) {
|
||||
console.warn('Cashfree SDK checkout fallback:', cfError);
|
||||
if (onOrderCreated) onOrderCreated(data.order.id);
|
||||
if (onClearCart) onClearCart();
|
||||
onClose();
|
||||
window.location.href = '/app/orders';
|
||||
}
|
||||
} else {
|
||||
if (onOrderCreated) onOrderCreated(data.order.id);
|
||||
if (onClearCart) onClearCart();
|
||||
onClose();
|
||||
window.location.href = '/app/orders';
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(data?.msg || 'Failed to place order. Please try again.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Order creation error:', err);
|
||||
setErrorMsg(err?.response?.data?.msg || err?.message || 'Error processing checkout.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden bg-slate-900/60 backdrop-blur-sm transition-opacity">
|
||||
<div className="fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div className="w-screen max-w-md bg-[#F8F7F4] shadow-2xl flex flex-col border-l border-[#E2E1DA]">
|
||||
<div className="p-5 bg-white border-b border-[#E2E1DA] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#0F172A] text-white rounded-lg">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display font-semibold text-lg text-slate-900 leading-tight">Your Order</h2>
|
||||
<p className="text-xs text-slate-500">{restaurantName}</p> // Corrected
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-6">
|
||||
{errorMsg && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg flex items-center gap-2">
|
||||
<span className="font-semibold">Error:</span> {errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center space-y-3">
|
||||
<div className="w-16 h-16 bg-slate-200/60 text-slate-400 rounded-full flex items-center justify-center mx-auto">
|
||||
<ShoppingBag className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="font-display font-semibold text-slate-800">Your cart is empty</h3>
|
||||
<p className="text-sm text-slate-500 max-w-xs mx-auto">
|
||||
Add delicious dishes from the menu to build your meal ticket.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500">Items ({items.length})</h3>
|
||||
{onClearCart && (
|
||||
<button
|
||||
onClick={onClearCart}
|
||||
className="text-xs text-red-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Clear Cart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#E8E7E0] bg-white rounded-xl border border-[#E2E1DA] overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="p-3.5 flex items-center justify-between gap-3">
|
||||
{item.image_url && (
|
||||
<img
|
||||
src={resolveMediaUrl(item.image_url)}
|
||||
alt={item.name}
|
||||
className="w-12 h-12 rounded-lg object-cover bg-slate-100 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm text-slate-900 truncate">{item.name}</h4>
|
||||
<p className="text-xs text-[#B93812] font-semibold">${item.price.toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-[#F0EFEA] px-2 py-1 rounded-lg border border-[#E2E1DA]">
|
||||
<button
|
||||
onClick={() => onUpdateQuantity && onUpdateQuantity(item.id, -1)}
|
||||
className="p-1 hover:bg-white rounded transition-colors text-slate-600"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs font-bold text-slate-900 min-w-[16px] text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onUpdateQuantity && onUpdateQuantity(item.id, 1)}
|
||||
className="p-1 hover:bg-white rounded transition-colors text-slate-600"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#B93812]" /> Delivery Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Enter street address, unit/apartment number"
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-[#E2E1DA] rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-xl border border-[#E2E1DA] space-y-2 text-sm">
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Delivery Fee</span>
|
||||
<span>${deliveryFee.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#E8E7E0] flex justify-between font-bold text-slate-900 text-base">
|
||||
<span>Total</span>
|
||||
<span className="text-[#B93812]">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-5 bg-white border-t border-[#E2E1DA]">
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3.5 px-4 bg-[#B93812] hover:bg-[#a02e0e] text-white font-semibold rounded-lg shadow-md flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Processing Checkout...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Pay & Place Order (${total.toFixed(2)})
|
||||
<ArrowRight className="w-4 h-4 ml-auto" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
frontend/src/components/features/ReviewsPanel.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { AlertCircle, CheckCircle2, MessageSquare, Send, Star, Truck, Utensils } from 'lucide-react';
|
||||
|
||||
interface ReviewsPanelProps {
|
||||
orderId?: string;
|
||||
restaurantName?: string;
|
||||
driverName?: string;
|
||||
onSuccess?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number;
|
||||
onChange: (val: number) => void;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function StarRatingInput({ value, onChange, label, icon }: StarRatingProps) {
|
||||
const [hoverVal, setHoverVal] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-[#F8F7F4] p-4 border border-[#E2E1DA]">
|
||||
<div className="flex items-center gap-2 mb-2 text-[#0F172A] font-semibold text-sm">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isFilled = (hoverVal || value) >= star;
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => onChange(star)}
|
||||
onMouseEnter={() => setHoverVal(star)}
|
||||
onMouseLeave={() => setHoverVal(0)}
|
||||
className="p-1 transition-transform hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#B93812] rounded"
|
||||
aria-label={`Rate ${star} stars out of 5`}
|
||||
>
|
||||
<Star
|
||||
className={`w-7 h-7 transition-colors ${' '}{
|
||||
isFilled ? 'fill-[#B93812] text-[#B93812]' : 'fill-none text-[#71717A]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="ml-3 text-xs font-bold text-[#0F172A]">
|
||||
{value > 0 ? `${value} / 5` : 'Select rating'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewsPanel({
|
||||
orderId = '',
|
||||
restaurantName = 'Restaurant',
|
||||
driverName = 'Delivery Driver',
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: ReviewsPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [restaurantRating, setRestaurantRating] = useState<number>(5);
|
||||
const [driverRating, setDriverRating] = useState<number>(5);
|
||||
const [comment, setComment] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
|
||||
|
||||
const ratingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!orderId) {
|
||||
throw new Error('Missing order reference for rating submission.');
|
||||
}
|
||||
const res = await api.post(`/api/orders/${orderId}/rating`, {
|
||||
restaurant_rating: restaurantRating,
|
||||
driver_rating: driverRating,
|
||||
comment: String((comment) ?? '').trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsSubmitted(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['order', orderId] });
|
||||
if (onSuccess) {
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.msg || err?.message || 'Failed to submit review. Please try again.';
|
||||
setErrorMessage(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
if (restaurantRating === 0) {
|
||||
setErrorMessage('Please provide a restaurant rating.');
|
||||
return;
|
||||
}
|
||||
ratingMutation.mutate();
|
||||
};
|
||||
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-6 text-center border border-[#E2E1DA] shadow-sm max-w-md mx-auto my-4">
|
||||
<div className="w-14 h-14 bg-[#15803D]/10 rounded-full flex items-center justify-center mx-auto mb-4 text-[#15803D]">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[#0F172A] mb-2 font-display">Thank You for Your Feedback!</h3>
|
||||
<p className="text-sm text-[#71717A] mb-6">
|
||||
Your ratings for {restaurantName} and {driverName} have been recorded successfully.
|
||||
</p>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 px-4 bg-[#0F172A] text-white font-medium text-sm rounded-xl hover:bg-[#0F172A]/90 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-6 border border-[#E2E1DA] shadow-sm max-w-md mx-auto my-4 font-sans">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-[#E8E7E0] pb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#0F172A] font-display">Rate Your Experience</h3>
|
||||
<p className="text-xs text-[#71717A]">Share your feedback to help us maintain quality standards.</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[#71717A] hover:text-[#0F172A] text-xs font-semibold px-2 py-1 rounded-md hover:bg-[#F0EFEA] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 p-3 bg-[#B91C1C]/10 border border-[#B91C1C]/20 rounded-xl flex items-start gap-2 text-xs text-[#B91C1C]">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<StarRatingInput
|
||||
value={restaurantRating}
|
||||
onChange={setRestaurantRating}
|
||||
label={`Food & Kitchen Quality (${restaurantName})`}
|
||||
icon={<Utensils className="w-4 h-4 text-[#B93812]" />}
|
||||
/>
|
||||
|
||||
<StarRatingInput
|
||||
value={driverRating}
|
||||
onChange={setDriverRating}
|
||||
label={`Delivery Service (${driverName})`}
|
||||
icon={<Truck className="w-4 h-4 text-[#B93812]" />}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl bg-[#F8F7F4] p-4 border border-[#E2E1DA]">
|
||||
<label htmlFor="review-comment" className="flex items-center gap-2 mb-2 text-[#0F172A] font-semibold text-sm">
|
||||
<MessageSquare className="w-4 h-4 text-[#B93812]" />
|
||||
<span>Comments & Suggestions</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-comment"
|
||||
rows={3}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Tell us how the food tasted or how the delivery went..."
|
||||
className="w-full text-sm p-3 rounded-lg border border-[#E2E1DA] bg-white text-[#0F172A] focus:outline-none focus:ring-2 focus:ring-[#B93812] focus:border-transparent resize-none placeholder:text-[#71717A]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={ratingMutation.isPending}
|
||||
className="w-full py-3 px-4 bg-[#B93812] text-white font-semibold text-sm rounded-xl hover:bg-[#B93812]/90 disabled:opacity-50 transition-all flex items-center justify-center gap-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#B93812]"
|
||||
>
|
||||
{ratingMutation.isPending ? (
|
||||
<span>Submitting Review...</span>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Submit Review</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
frontend/src/components/features/SearchFilterPanel.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowUpDown, Filter, RefreshCw, Search, SlidersHorizontal, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface SearchFilterPanelProps {
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (q: string) => void;
|
||||
selectedCategory?: string;
|
||||
onCategoryChange?: (category: string) => void;
|
||||
selectedSort?: string;
|
||||
onSortChange?: (sort: string) => void;
|
||||
categories?: string[];
|
||||
totalResults?: number;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORIES = [
|
||||
'All',
|
||||
'Indian',
|
||||
'Burgers',
|
||||
'Pizza',
|
||||
'Asian',
|
||||
'Italian',
|
||||
'Mexican',
|
||||
'Healthy',
|
||||
'Desserts'
|
||||
];
|
||||
|
||||
export default function SearchFilterPanel({
|
||||
searchQuery: externalQuery,
|
||||
onSearchChange,
|
||||
selectedCategory: externalCategory,
|
||||
onCategoryChange,
|
||||
selectedSort: externalSort,
|
||||
onSortChange,
|
||||
categories = DEFAULT_CATEGORIES,
|
||||
totalResults,
|
||||
isLoading = false,
|
||||
className
|
||||
}: SearchFilterPanelProps) {
|
||||
const [internalQuery, setInternalQuery] = useState(externalQuery || '');
|
||||
const [internalCategory, setInternalCategory] = useState(externalCategory || 'All');
|
||||
const [internalSort, setInternalSort] = useState(externalSort || 'featured');
|
||||
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = useState(false);
|
||||
|
||||
const query = externalQuery !== undefined ? externalQuery : internalQuery;
|
||||
const category = externalCategory !== undefined ? externalCategory : internalCategory;
|
||||
const sort = externalSort !== undefined ? externalSort : internalSort;
|
||||
|
||||
useEffect(() => {
|
||||
if (externalQuery !== undefined) setInternalQuery(externalQuery);
|
||||
}, [externalQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalCategory !== undefined) setInternalCategory(externalCategory);
|
||||
}, [externalCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalSort !== undefined) setInternalSort(externalSort);
|
||||
}, [externalSort]);
|
||||
|
||||
const handleSearchInput = (val: string) => {
|
||||
setInternalQuery(val);
|
||||
if (onSearchChange) {
|
||||
onSearchChange(val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategorySelect = (cat: string) => {
|
||||
setInternalCategory(cat);
|
||||
if (onCategoryChange) {
|
||||
onCategoryChange(cat);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortSelect = (s: string) => {
|
||||
setInternalSort(s);
|
||||
if (onSortChange) {
|
||||
onSortChange(s);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
handleSearchInput('');
|
||||
handleCategorySelect('All');
|
||||
handleSortSelect('featured');
|
||||
};
|
||||
|
||||
const hasActiveFilters = String((query) ?? '').trim().length > 0 || category !== 'All' || sort !== 'featured';
|
||||
|
||||
return (
|
||||
<div className={cn('w-full space-y-4 bg-[#F8F7F4] rounded-2xl p-4 border border-[#E2E1DA]', className)}>
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717A]" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearchInput(e.target.value)}
|
||||
placeholder="Search dishes, cuisines, or restaurants..."
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-white border border-[#E2E1DA] rounded-xl text-sm font-medium text-[#0F172A] placeholder:text-[#71717A] focus:outline-none focus:ring-2 focus:ring-[#B93812] focus:border-transparent transition-all h-11"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSearchInput('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-[#71717A] hover:text-[#0F172A] rounded-md transition-colors"
|
||||
aria-label="Clear search input"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 md:flex-initial">
|
||||
<div className="inline-flex items-center gap-2 bg-white border border-[#E2E1DA] rounded-xl px-3 py-2 h-11 text-xs font-semibold text-[#0F172A]">
|
||||
<ArrowUpDown className="h-3.5 w-3.5 text-[#B93812]" />
|
||||
<span className="hidden sm:inline text-[#71717A]">Sort:</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => handleSortSelect(e.target.value)}
|
||||
className="bg-transparent font-medium text-[#0F172A] focus:outline-none cursor-pointer pr-1"
|
||||
>
|
||||
<option value="featured">Featured</option>
|
||||
<option value="name_asc">Name (A-Z)</option>
|
||||
<option value="name_desc">Name (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileFiltersOpen(!isMobileFiltersOpen)}
|
||||
className={cn(
|
||||
'md:hidden h-11 px-3.5 rounded-xl border text-xs font-semibold inline-flex items-center gap-2 transition-all',
|
||||
hasActiveFilters
|
||||
? 'bg-[#B93812] text-white border-[#B93812]'
|
||||
: 'bg-white border-[#E2E1DA] text-[#0F172A]'
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-white text-[#B93812] text-[10px] font-bold px-1.5 py-0.5 rounded-full">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center justify-between gap-2 overflow-x-auto pt-1 pb-0.5 no-scrollbar">
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto py-1">
|
||||
{categories.map((cat) => {
|
||||
const isActive = category.toLowerCase() === cat.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => handleCategorySelect(cat)}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-xl text-xs font-semibold whitespace-nowrap transition-all border cursor-pointer h-9 flex items-center gap-1.5',
|
||||
isActive
|
||||
? 'bg-[#0F172A] text-white border-[#0F172A] shadow-sm'
|
||||
: 'bg-white text-[#0F172A] border-[#E2E1DA] hover:bg-[#F0EFEA]'
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="text-xs font-semibold text-[#B93812] hover:text-[#902A0E] hover:underline whitespace-nowrap flex items-center gap-1 px-2 py-1 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobileFiltersOpen && (
|
||||
<div className="md:hidden pt-3 border-t border-[#E2E1DA] space-y-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-[#71717A] mb-2 flex items-center justify-between">
|
||||
<span>Category</span>
|
||||
{category !== 'All' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCategorySelect('All')}
|
||||
className="text-[#B93812] text-[11px] font-medium"
|
||||
>
|
||||
Reset category
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{categories.map((cat) => {
|
||||
const isActive = category.toLowerCase() === cat.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => handleCategorySelect(cat)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-all border',
|
||||
isActive
|
||||
? 'bg-[#0F172A] text-white border-[#0F172A]'
|
||||
: 'bg-white text-[#0F172A] border-[#E2E1DA]'
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="text-xs font-semibold text-[#B93812] flex items-center gap-1.5 py-1 px-2 rounded-lg bg-red-50 border border-red-200"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-[#E8E7E0]">
|
||||
<span className="text-xs font-medium text-[#71717A]">Active:</span>
|
||||
{query && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-[#E2E1DA] rounded-lg text-xs font-medium text-[#0F172A]">
|
||||
<span>Query: "{query}"</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSearchInput('')}
|
||||
className="text-[#71717A] hover:text-[#B93812] ml-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{category !== 'All' && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-[#E2E1DA] rounded-lg text-xs font-medium text-[#0F172A]">
|
||||
<span>Category: {category}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCategorySelect('All')}
|
||||
className="text-[#71717A] hover:text-[#B93812] ml-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{totalResults !== undefined && (
|
||||
<span className="ml-auto text-xs font-semibold text-[#71717A]">
|
||||
{isLoading ? 'Searching...' : `${totalResults} found`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
frontend/src/components/landing/WorkflowSection.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useState } from 'react';
|
||||
import { Bike, CheckCircle2, ChevronRight, Clock, MapPin, ShieldCheck, ShoppingBag, Star, Utensils } from 'lucide-react';
|
||||
|
||||
interface Step {
|
||||
id: number;
|
||||
badge: string;
|
||||
role: 'Customer' | 'Restaurant' | 'Delivery Partner' | 'Complete';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
icon: typeof ShoppingBag;
|
||||
highlights: string[];
|
||||
statusEnum: string;
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
id: 1,
|
||||
badge: 'STAGE 01',
|
||||
role: 'Customer',
|
||||
title: 'Order Placement & Checkout',
|
||||
subtitle: 'Instant kitchen notification upon test payment confirmation.',
|
||||
description:
|
||||
'The customer selects menu items from a local restaurant, specifies delivery details, and places an order. Subtotal and $5.00 delivery fee are calculated on the server.',
|
||||
icon: ShoppingBag,
|
||||
highlights: ['Server price validation', 'Instant kitchen ticket alert', 'Customer cancellation window open'],
|
||||
statusEnum: 'placed',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
badge: 'STAGE 02',
|
||||
role: 'Restaurant',
|
||||
title: 'Kitchen Acceptance & Prep',
|
||||
subtitle: 'Ticket state updates in real time on the kitchen Kanban board.',
|
||||
description:
|
||||
'Kitchen operators receive instant visual alerts. The chef accepts the ticket and updates status to Preparing. Once cooked and bagged, it transitions to Ready for Pickup.',
|
||||
icon: Utensils,
|
||||
highlights: ['One-tap order acceptance', 'Kitchen Kanban tracking', 'Prevents late cancellations'],
|
||||
statusEnum: 'preparing / ready_for_pickup',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
badge: 'STAGE 03',
|
||||
role: 'Delivery Partner',
|
||||
title: 'Courier Job Lock & Pickup',
|
||||
subtitle: 'First-come atomic job assignment prevents duplicate dispatch.',
|
||||
description:
|
||||
'Active online drivers view open delivery requests in their area. Claiming an order locks it exclusively to that driver. The courier verifies the food bag at the store.',
|
||||
icon: Bike,
|
||||
highlights: ['Atomic single-driver lock', 'Store pickup verification', 'GPS location broadcast active'],
|
||||
statusEnum: 'assigned / picked_up',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
badge: 'STAGE 04',
|
||||
role: 'Complete',
|
||||
title: 'Live Road Map & Drop-off Rating',
|
||||
subtitle: 'Real-time telemetry road tracking and dual 5-star feedback.',
|
||||
description:
|
||||
'The customer follows the assigned driver moving along actual road routes. Upon doorstep drop-off, status updates to Delivered, unlocking restaurant and driver ratings.',
|
||||
icon: MapPin,
|
||||
highlights: ['5-second GPS position sync', 'Street route polyline rendering', 'Gated post-delivery 5-star review'],
|
||||
statusEnum: 'delivered',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WorkflowSection() {
|
||||
const [activeStep, setActiveStep] = useState<number>(1);
|
||||
|
||||
const currentStep = STEPS.find((s) => s.id === activeStep) || STEPS[0];
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-[#F8F7F4] border-t border-[#E2E1DA]" id="workflow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#B93812]/10 border border-[#B93812]/20 text-[#B93812] text-xs font-semibold tracking-wide uppercase mb-4">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>Unskippable Order Lifecycle</span>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-[#0F172A] tracking-tight font-display">
|
||||
How EatIt Reroutes Food Delivery
|
||||
</h2>
|
||||
<p className="mt-4 text-base md:text-lg text-[#0F172A]/70 font-body">
|
||||
A transparent 4-stage network state machine ensuring customers, kitchens, and couriers stay synchronized at every step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
{STEPS.map((step) => {
|
||||
const Icon = step.icon;
|
||||
const isActive = step.id === activeStep;
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => setActiveStep(step.id)}
|
||||
className={`relative text-left p-5 rounded-xl transition-all duration-200 border ${
|
||||
isActive
|
||||
? 'bg-white border-[#B93812] shadow-md ring-1 ring-[#B93812]/20'
|
||||
: 'bg-[#F0EFEA] border-[#E2E1DA] hover:bg-white hover:border-[#71717A]/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span
|
||||
className={`text-xs font-bold px-2 py-0.5 rounded ${
|
||||
isActive
|
||||
? 'bg-[#B93812] text-white'
|
||||
: 'bg-[#E5E4DD] text-[#0F172A]/70'
|
||||
}`}
|
||||
>
|
||||
{step.badge}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[#71717A]">
|
||||
{step.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
isActive
|
||||
? 'bg-[#B93812]/10 text-[#B93812]'
|
||||
: 'bg-[#E5E4DD] text-[#0F172A]/80'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-bold text-sm text-[#0F172A] line-clamp-1">
|
||||
{step.title}
|
||||
</h3>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-[#E2E1DA] p-6 md:p-10 shadow-sm grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-[#0F172A] text-white">
|
||||
{currentStep.badge}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[#B93812] uppercase tracking-wider flex items-center gap-1">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
State: {currentStep.statusEnum}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-[#0F172A] font-display">
|
||||
{currentStep.title}
|
||||
</h3>
|
||||
<p className="text-base text-[#B93812] font-semibold mt-1">
|
||||
{currentStep.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[#0F172A]/80 text-sm md:text-base leading-relaxed font-body">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-[#71717A]">
|
||||
Key Workflow Controls
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{currentStep.highlights.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 p-2.5 rounded-lg bg-[#F8F7F4] border border-[#E2E1DA] text-xs font-medium text-[#0F172A]"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D] shrink-0" />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center justify-between border-t border-[#E8E7E0]">
|
||||
<span className="text-xs text-[#71717A]">
|
||||
Step {currentStep.id} of {STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setActiveStep((prev) => (prev % STEPS.length) + 1)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-[#B93812] hover:text-[#0F172A] transition-colors"
|
||||
>
|
||||
<span>Next Lifecycle Stage</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 relative">
|
||||
<div className="relative rounded-xl overflow-hidden border border-[#E2E1DA] bg-[#F8F7F4]">
|
||||
<img
|
||||
src="/assets/landing-03-workflow-timeline.png"
|
||||
alt="Illustrate the step-by-step unskippable lifecycle of an order from placement to for EatIt"
|
||||
className="w-full h-64 md:h-80 object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0F172A]/80 via-transparent to-transparent flex flex-col justify-end p-6 text-white">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Star className="w-4 h-4 fill-amber-400 text-yellow-400" />
|
||||
<span className="text-xs font-bold tracking-wide uppercase">
|
||||
Order Integrity Engine
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/80">
|
||||
Strict step enforcement guarantees no step can be skipped without real actor authorization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
299
frontend/src/components/restaurant/KitchenOrderBoard.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { AlertCircle, CheckCircle2, ChefHat, Clock, MapPin, RefreshCw, ShoppingBag, User, XCircle } from 'lucide-react';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
customer_email: string;
|
||||
restaurant_id: string;
|
||||
delivery_agent_email?: string | null;
|
||||
delivery_address: string;
|
||||
delivery_latitude: number;
|
||||
delivery_longitude: number;
|
||||
subtotal: number;
|
||||
delivery_fee: number;
|
||||
total_amount: number;
|
||||
status: 'placed' | 'accepted' | 'preparing' | 'ready_for_pickup' | 'assigned' | 'picked_up' | 'delivered' | 'cancelled';
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export default function KitchenOrderBoard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<'all' | 'active'>('active');
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
queryKey: ['restaurant-orders'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/restaurants/me/orders');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async ({ orderId, status }: { orderId: string; status: string }) => {
|
||||
const res = await api.patch(`/api/restaurants/me/orders/${orderId}/status`, { status });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['restaurant-orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const orders: Order[] = Array.isArray(data?.orders) ? data.orders : [];
|
||||
|
||||
const handleStatusUpdate = (orderId: string, status: string) => {
|
||||
updateStatusMutation.mutate({ orderId, status });
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'placed':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-300">New Order</span>;
|
||||
case 'accepted':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-300">Accepted</span>;
|
||||
case 'preparing':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-800 border border-orange-300 animate-pulse">In Kitchen</span>;
|
||||
case 'ready_for_pickup':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-300">Ready for Pickup</span>;
|
||||
case 'picked_up':
|
||||
case 'assigned':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-800 border border-purple-300">Out for Delivery</span>;
|
||||
case 'delivered':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-800 border border-gray-300">Delivered</span>;
|
||||
case 'cancelled':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-800 border border-red-300">Cancelled</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-700">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'incoming',
|
||||
title: 'New Tickets',
|
||||
subtitle: 'Placed & Accepted',
|
||||
icon: <ShoppingBag className="w-5 h-5 text-amber-600" />,
|
||||
orders: orders.filter((o) => o.status === 'placed' || o.status === 'accepted'),
|
||||
accentColor: 'border-amber-500'
|
||||
},
|
||||
{
|
||||
id: 'cooking',
|
||||
title: 'In Preparation',
|
||||
subtitle: 'Currently Cooking',
|
||||
icon: <ChefHat className="w-5 h-5 text-orange-600" />,
|
||||
orders: orders.filter((o) => o.status === 'preparing'),
|
||||
accentColor: 'border-orange-500'
|
||||
},
|
||||
{
|
||||
id: 'ready',
|
||||
title: 'Pass Handoff',
|
||||
subtitle: 'Ready for Courier',
|
||||
icon: <CheckCircle2 className="w-5 h-5 text-emerald-600" />,
|
||||
orders: orders.filter((o) => o.status === 'ready_for_pickup'),
|
||||
accentColor: 'border-emerald-500'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 bg-white p-4 rounded-xl border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-[#0F172A] text-white rounded-lg">
|
||||
<ChefHat className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#0F172A] font-['Outfit']">Kitchen Display System</h2>
|
||||
<p className="text-xs text-gray-500">Live order ticket pipeline • Auto-updates every 5s</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg p-1 bg-[#F0EFEA] border border-[#E2E1DA] text-xs font-medium">
|
||||
<button
|
||||
onClick={() => setFilter('active')}
|
||||
className={`px-3 py-1.5 rounded-md transition-all ${filter === 'active' ? 'bg-white text-[#0F172A] shadow-xs font-semibold' : 'text-gray-600 hover:text-[#0F172A]'}`}
|
||||
>
|
||||
Active Tickets ({orders.filter((o) => !['delivered', 'cancelled'].includes(o.status)).length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-3 py-1.5 rounded-md transition-all ${filter === 'all' ? 'bg-white text-[#0F172A] shadow-xs font-semibold' : 'text-gray-600 hover:text-[#0F172A]'}`}
|
||||
>
|
||||
All Orders ({orders.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="p-2 rounded-lg border border-[#E2E1DA] hover:bg-gray-50 text-gray-700 transition-colors"
|
||||
title="Refresh tickets"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin text-[#B93812]' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center bg-white rounded-xl border border-[#E2E1DA]">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-[#B93812] mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-600">Connecting to kitchen order ticket feed...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="p-8 bg-red-50 rounded-xl border border-red-200 text-red-700 text-center">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-2 text-red-600" />
|
||||
<p className="font-semibold text-sm">Failed to connect to kitchen ticket server.</p>
|
||||
<button onClick={() => refetch()} className="mt-3 px-4 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700">
|
||||
Retry Connection
|
||||
</button>
|
||||
</div>
|
||||
) : filter === 'active' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{columns.map((col) => (
|
||||
<div key={col.id} className="flex flex-col bg-[#F0EFEA] rounded-xl border border-[#E2E1DA] overflow-hidden min-h-[500px]">
|
||||
<div className={`p-4 bg-white border-t-4 ${col.accentColor} border-b border-[#E2E1DA] flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{col.icon}
|
||||
<div>
|
||||
<h3 className="font-bold text-[#0F172A] text-sm">{col.title}</h3>
|
||||
<p className="text-xs text-gray-500">{col.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2.5 py-0.5 bg-[#0F172A] text-white rounded-full text-xs font-bold">
|
||||
{col.orders.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3 flex-1 overflow-y-auto">
|
||||
{col.orders.length === 0 ? (
|
||||
<div className="p-8 text-center border-2 border-dashed border-gray-300 rounded-lg my-4">
|
||||
<p className="text-xs text-gray-500 font-medium">No active tickets in this stage</p>
|
||||
</div>
|
||||
) : (
|
||||
col.orders.map((order) => (
|
||||
<div key={order.id} className="bg-white rounded-lg p-4 border border-[#E2E1DA] shadow-xs space-y-3">
|
||||
<div className="flex items-start justify-between gap-2 border-b border-gray-100 pb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-[#0F172A]">
|
||||
#{order.id.slice(0, 8)}
|
||||
</span>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(order.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-[#B93812] text-sm">
|
||||
${order.total_amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="truncate font-medium">{order.customer_email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="truncate">{order.delivery_address}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-100 flex items-center gap-2">
|
||||
{order.status === 'placed' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(order.id, 'accepted')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="flex-1 py-1.5 px-3 bg-[#0F172A] hover:bg-slate-800 text-white rounded-md text-xs font-semibold flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
Accept Order
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(order.id, 'cancelled')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="py-1.5 px-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-md text-xs font-medium transition-colors"
|
||||
title="Reject Order"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{order.status === 'accepted' && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(order.id, 'preparing')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="w-full py-1.5 px-3 bg-[#B93812] hover:bg-red-800 text-white rounded-md text-xs font-semibold flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<ChefHat className="w-3.5 h-3.5" /> Start Cooking
|
||||
</button>
|
||||
)}
|
||||
|
||||
{order.status === 'preparing' && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(order.id, 'ready_for_pickup')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="w-full py-1.5 px-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-md text-xs font-semibold flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Mark Ready for Pickup
|
||||
</button>
|
||||
)}
|
||||
|
||||
{order.status === 'ready_for_pickup' && (
|
||||
<div className="w-full p-2 bg-emerald-50 rounded-md text-xs text-emerald-800 font-medium text-center flex items-center justify-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5 animate-spin" /> Waiting for courier pickup
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-[#E2E1DA] overflow-hidden shadow-xs">
|
||||
<div className="p-4 border-b border-[#E2E1DA] bg-gray-50 flex items-center justify-between">
|
||||
<h3 className="font-bold text-[#0F172A] text-sm font-['Outfit']">All Order History</h3>
|
||||
<span className="text-xs text-gray-500">{orders.length} total tickets</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-gray-700">
|
||||
<thead className="bg-[#F0EFEA] text-gray-600 font-semibold uppercase text-[11px] border-b border-[#E2E1DA]">
|
||||
<tr>
|
||||
<th className="p-3">Order ID</th>
|
||||
<th className="p-3">Customer</th>
|
||||
<th className="p-3">Delivery Address</th>
|
||||
<th className="p-3">Total</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="hover:bg-gray-50">
|
||||
<td className="p-3 font-mono font-medium text-[#0F172A]">#{o.id.slice(0, 8)}</td>
|
||||
<td className="p-3">{o.customer_email}</td>
|
||||
<td className="p-3 max-w-[200px] truncate">{o.delivery_address}</td>
|
||||
<td className="p-3 font-bold text-[#B93812]">${o.total_amount.toFixed(2)}</td>
|
||||
<td className="p-3">{getStatusBadge(o.status)}</td>
|
||||
<td className="p-3 text-gray-500">{new Date(o.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
538
frontend/src/components/restaurant/MenuManager.tsx
Normal file
@ -0,0 +1,538 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, Check, Edit2, Image as ImageIcon, Loader2, Plus, Power, Search, Trash2, Upload, Utensils, X } from 'lucide-react';
|
||||
import { api, resolveMediaUrl } from '../../api';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
ingredients: string | null;
|
||||
recipe: string | null;
|
||||
price: number;
|
||||
image_url: string | null;
|
||||
is_available: number;
|
||||
}
|
||||
|
||||
interface MenuManagerProps {
|
||||
restaurantId: string;
|
||||
}
|
||||
|
||||
export default function MenuManager({ restaurantId }: MenuManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchCategory] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('All');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'Mains',
|
||||
ingredients: '',
|
||||
recipe: '',
|
||||
price: '',
|
||||
image_url: '',
|
||||
is_available: 1,
|
||||
});
|
||||
|
||||
const { data: menuItems = [], isLoading } = useQuery({
|
||||
queryKey: ['menu-items', restaurantId],
|
||||
queryFn: async () => {
|
||||
if (!restaurantId) return [];
|
||||
const res = await api.get(`/api/restaurants/${restaurantId}/menu-items`);
|
||||
return (res.data?.menu_items || []) as MenuItem[];
|
||||
},
|
||||
enabled: !!restaurantId,
|
||||
});
|
||||
|
||||
const categories = ['All', ...Array.from(new Set(menuItems.map((item) => item.category || 'Uncategorized').filter(Boolean)))];
|
||||
|
||||
const handleOpenAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setFormState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'Mains',
|
||||
ingredients: '',
|
||||
recipe: '',
|
||||
price: '',
|
||||
image_url: '',
|
||||
is_available: 1,
|
||||
});
|
||||
setMsg(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (item: MenuItem) => {
|
||||
setEditingItem(item);
|
||||
setFormState({
|
||||
name: item.name || '',
|
||||
description: item.description || '',
|
||||
category: item.category || 'Mains',
|
||||
ingredients: item.ingredients || '',
|
||||
recipe: item.recipe || '',
|
||||
price: String(item.price ?? ''),
|
||||
image_url: item.image_url || '',
|
||||
is_available: item.is_available ?? 1,
|
||||
});
|
||||
setMsg(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api.post('/api/upload', fd, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
return res.data?.url as string;
|
||||
},
|
||||
onSuccess: (url) => {
|
||||
setFormState((prev) => ({ ...prev, image_url: url }));
|
||||
setUploading(false);
|
||||
},
|
||||
onError: () => {
|
||||
setMsg({ type: 'error', text: 'Failed to upload dish image' });
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
restaurant_id: restaurantId,
|
||||
name: formState.name,
|
||||
description: formState.description,
|
||||
category: formState.category,
|
||||
ingredients: formState.ingredients,
|
||||
recipe: formState.recipe,
|
||||
price: parseFloat(formState.price) || 0,
|
||||
image_url: formState.image_url,
|
||||
is_available: formState.is_available,
|
||||
};
|
||||
|
||||
if (editingItem) {
|
||||
const res = await api.patch(`/api/menu-items/${editingItem.id}`, payload);
|
||||
return res.data;
|
||||
} else {
|
||||
const res = await api.post('/api/menu-items', payload);
|
||||
return res.data;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['menu-items', restaurantId] });
|
||||
setIsModalOpen(false);
|
||||
setMsg({ type: 'success', text: editingItem ? 'Dish updated successfully!' : 'New dish added to menu!' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMsg({ type: 'error', text: err?.response?.data?.msg || 'Failed to save menu item' });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAvailabilityMutation = useMutation({
|
||||
mutationFn: async ({ id, currentStatus }: { id: string; currentStatus: number }) => {
|
||||
const newStatus = currentStatus === 1 ? 0 : 1;
|
||||
const res = await api.patch(`/api/menu-items/${id}`, {
|
||||
is_available: newStatus,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['menu-items', restaurantId] });
|
||||
},
|
||||
onError: () => {
|
||||
setMsg({ type: 'error', text: 'Failed to update dish availability' });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/api/menu-items/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['menu-items', restaurantId] });
|
||||
setMsg({ type: 'success', text: 'Dish removed from menu' });
|
||||
},
|
||||
onError: () => {
|
||||
setMsg({ type: 'error', text: 'Failed to delete dish' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploading(true);
|
||||
uploadMutation.mutate(file);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = menuItems.filter((item) => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(item.description && item.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesCategory = categoryFilter === 'All' || item.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border p-6 shadow-sm mt-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-divider">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Utensils className="w-5 h-5 text-accent" />
|
||||
<h2 className="text-xl font-bold font-display text-foreground">Menu Items Manager</h2>
|
||||
</div>
|
||||
<p className="text-sm text-mutedForeground mt-1">
|
||||
Create, update, or remove dishes, set prices, assign categories, and toggle availability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAddModal}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-accent text-accentForeground hover:opacity-90 rounded-md text-sm font-semibold transition-opacity"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add New Dish
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div
|
||||
className={`mt-4 p-3 rounded-lg text-sm flex items-center gap-2 ${
|
||||
msg.type === 'success'
|
||||
? 'bg-success/10 text-success border border-success/20'
|
||||
: 'bg-destructive/10 text-destructive border border-destructive/20'
|
||||
}`}
|
||||
>
|
||||
{msg.type === 'success' ? <Check className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
<span>{msg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="relative w-full sm:flex-1">
|
||||
<Search className="w-4 h-4 absolute left-3 top-2.5 text-mutedForeground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search menu items..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchCategory(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full sm:w-auto pb-1 sm:pb-0">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold whitespace-nowrap transition-colors ${
|
||||
categoryFilter === cat
|
||||
? 'bg-primary text-primaryForeground'
|
||||
: 'bg-surfaceSecondary text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dish List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 mt-6">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent mr-2" />
|
||||
<span className="text-sm font-medium text-mutedForeground">Loading menu catalog...</span>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-border rounded-xl mt-6">
|
||||
<Utensils className="w-10 h-10 mx-auto text-mutedForeground mb-2 opacity-40" />
|
||||
<h3 className="text-base font-semibold text-foreground">No menu items found</h3>
|
||||
<p className="text-sm text-mutedForeground mt-1 max-w-sm mx-auto">
|
||||
{searchTerm || categoryFilter !== 'All'
|
||||
? 'No items match your search filter criteria. Try clearing filters.'
|
||||
: 'Your kitchen menu is currently empty. Add your first dish to start taking orders.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`rounded-lg border p-4 flex flex-col justify-between transition-all ${
|
||||
item.is_available === 1
|
||||
? 'bg-surface border-border hover:border-borderStrong'
|
||||
: 'bg-surfaceSecondary/50 border-border opacity-75'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-start gap-3">
|
||||
{item.image_url ? (
|
||||
<img
|
||||
src={resolveMediaUrl(item.image_url)}
|
||||
alt={item.name}
|
||||
className="w-16 h-16 rounded-md object-cover border border-border shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-md bg-surfaceSecondary border border-border flex items-center justify-center shrink-0 text-mutedForeground">
|
||||
<ImageIcon className="w-6 h-6 opacity-40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-xs font-semibold text-accent uppercase tracking-wider">
|
||||
{item.category || 'Mains'}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
${Number(item.price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-bold text-foreground truncate mt-0.5">{item.name}</h4>
|
||||
{item.description && (
|
||||
<p className="text-xs text-mutedForeground line-clamp-2 mt-1">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.ingredients && (
|
||||
<div className="mt-3 pt-2 border-t border-divider">
|
||||
<span className="text-[11px] font-semibold text-mutedForeground block">
|
||||
Ingredients: {item.ingredients}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-divider flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleAvailabilityMutation.mutate({
|
||||
id: item.id,
|
||||
currentStatus: item.is_available,
|
||||
})
|
||||
}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-semibold transition-colors ${
|
||||
item.is_available === 1
|
||||
? 'bg-success/15 text-success hover:bg-success/20'
|
||||
: 'bg-muted text-mutedForeground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
{item.is_available === 1 ? 'Available' : 'Unavailable'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenEditModal(item)}
|
||||
className="p-1.5 text-mutedForeground hover:text-foreground hover:bg-surfaceSecondary rounded transition-colors"
|
||||
title="Edit dish"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Are you sure you want to delete "${item.name}"?`)) {
|
||||
deleteMutation.mutate(item.id);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-mutedForeground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
title="Delete dish"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div className="bg-surface border border-border rounded-xl max-w-lg w-full p-6 shadow-xl relative animate-in fade-in zoom-in-95 duration-150 my-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="absolute top-4 right-4 p-1 text-mutedForeground hover:text-foreground rounded-md"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-bold font-display text-foreground">
|
||||
{editingItem ? 'Edit Dish Details' : 'Add New Menu Dish'}
|
||||
</h3>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate();
|
||||
}}
|
||||
className="mt-4 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Dish Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formState.name}
|
||||
onChange={(e) => setFormState({ ...formState, name: e.target.value })}
|
||||
placeholder="e.g. Chicken Biryani Special"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Price ($ USD) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
value={formState.price}
|
||||
onChange={(e) => setFormState({ ...formState, price: e.target.value })}
|
||||
placeholder="14.99"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Category *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formState.category}
|
||||
onChange={(e) => setFormState({ ...formState, category: e.target.value })}
|
||||
placeholder="e.g. Mains, Starters, Drinks"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formState.description}
|
||||
onChange={(e) => setFormState({ ...formState, description: e.target.value })}
|
||||
placeholder="Aromatic basmati rice cooked with tender chicken and authentic spices."
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Ingredients
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.ingredients}
|
||||
onChange={(e) => setFormState({ ...formState, ingredients: e.target.value })}
|
||||
placeholder="Chicken, Basmati Rice, Saffron, Garlic, Ginger"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Recipe / Kitchen Notes
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.recipe}
|
||||
onChange={(e) => setFormState({ ...formState, recipe: e.target.value })}
|
||||
placeholder="Garnish with fried onions before sending out."
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Dish Image
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{formState.image_url ? (
|
||||
<img
|
||||
src={resolveMediaUrl(formState.image_url)}
|
||||
alt="Preview"
|
||||
className="w-14 h-14 rounded-md object-cover border border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-md bg-surfaceSecondary border border-border flex items-center justify-center text-mutedForeground">
|
||||
<ImageIcon className="w-6 h-6 opacity-40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-2 px-3 py-1.5 border border-border rounded-md text-xs font-semibold bg-surface hover:bg-surfaceSecondary cursor-pointer">
|
||||
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileChange}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-divider flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.is_available === 1}
|
||||
onChange={(e) => setFormState({ ...formState, is_available: e.target.checked ? 1 : 0 })}
|
||||
className="rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
Mark Available for Order
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 border border-border rounded-md text-xs font-semibold hover:bg-surfaceSecondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending || uploading}
|
||||
className="px-4 py-2 bg-primary text-primaryForeground hover:opacity-90 rounded-md text-xs font-semibold transition-opacity flex items-center gap-1.5"
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{editingItem ? 'Save Changes' : 'Add Dish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
frontend/src/components/restaurant/RestaurantProfileForm.tsx
Normal file
@ -0,0 +1,342 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, Check, Image as ImageIcon, Loader2, MapPin, Power, Save, Store, Upload } from 'lucide-react';
|
||||
import { api, resolveMediaUrl } from '../../api';
|
||||
|
||||
interface Restaurant {
|
||||
id: string;
|
||||
owner_email: string;
|
||||
name: string;
|
||||
description: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
image_url: string | null;
|
||||
status: 'active' | 'closed';
|
||||
}
|
||||
|
||||
interface RestaurantProfileFormProps {
|
||||
onRestaurantUpdated?: (restaurant: Restaurant) => void;
|
||||
}
|
||||
|
||||
export default function RestaurantProfileForm({ onRestaurantUpdated }: RestaurantProfileFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
image_url: '',
|
||||
status: 'active' as 'active' | 'closed',
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['restaurant-me'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/restaurants/me');
|
||||
return (res.data?.restaurant || null) as Restaurant | null;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
address: data.address || '',
|
||||
latitude: typeof data.latitude === 'number' ? data.latitude : 37.7749,
|
||||
longitude: typeof data.longitude === 'number' ? data.longitude : -122.4194,
|
||||
image_url: data.image_url || '',
|
||||
status: data.status || 'active',
|
||||
});
|
||||
if (onRestaurantUpdated) {
|
||||
onRestaurantUpdated(data);
|
||||
}
|
||||
}
|
||||
}, [data, onRestaurantUpdated]);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api.post('/api/upload', fd, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
});
|
||||
return res.data?.url as string;
|
||||
},
|
||||
onSuccess: (url) => {
|
||||
setFormData((prev) => ({ ...prev, image_url: url }));
|
||||
setMsg({ type: 'success', text: 'Image uploaded successfully' });
|
||||
setUploading(false);
|
||||
},
|
||||
onError: () => {
|
||||
setMsg({ type: 'error', text: 'Failed to upload image' });
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (data?.id) {
|
||||
const res = await api.patch(`/api/restaurants/${data.id}`, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
address: formData.address,
|
||||
latitude: Number(formData.latitude),
|
||||
longitude: Number(formData.longitude),
|
||||
image_url: formData.image_url,
|
||||
status: formData.status,
|
||||
});
|
||||
return res.data?.restaurant as Restaurant;
|
||||
} else {
|
||||
const res = await api.post('/api/restaurants', {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
address: formData.address,
|
||||
latitude: Number(formData.latitude),
|
||||
longitude: Number(formData.longitude),
|
||||
image_url: formData.image_url,
|
||||
});
|
||||
return res.data?.restaurant as Restaurant;
|
||||
}
|
||||
},
|
||||
onSuccess: (updatedRestaurant) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['restaurant-me'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['restaurants'] });
|
||||
setMsg({ type: 'success', text: 'Restaurant profile saved successfully!' });
|
||||
if (onRestaurantUpdated && updatedRestaurant) {
|
||||
onRestaurantUpdated(updatedRestaurant);
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMsg({ type: 'error', text: err?.response?.data?.msg || 'Failed to save restaurant profile' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploading(true);
|
||||
uploadMutation.mutate(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
const newStatus = formData.status === 'active' ? 'closed' : 'active';
|
||||
setFormData((prev) => ({ ...prev, status: newStatus }));
|
||||
if (data?.id) {
|
||||
try {
|
||||
await api.patch(`/api/restaurants/${data.id}`, {
|
||||
status: newStatus,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['restaurant-me'] });
|
||||
setMsg({ type: 'success', text: `Store status changed to ${newStatus}` });
|
||||
} catch (err) {
|
||||
setFormData((prev) => ({ ...prev, status: data.status }));
|
||||
setMsg({ type: 'error', text: 'Failed to update store status' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12 bg-surface rounded-xl border border-border">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent mr-2" />
|
||||
<span className="text-sm font-medium text-mutedForeground">Loading kitchen profile...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border p-6 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-divider">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="w-5 h-5 text-accent" />
|
||||
<h2 className="text-xl font-bold font-display text-foreground">
|
||||
{data ? 'Kitchen Profile & Settings' : 'Register Your Restaurant'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-mutedForeground mt-1">
|
||||
Configure your store details, operational status, and location for delivery dispatch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="flex items-center gap-3 bg-surfaceSecondary p-2 rounded-lg border border-border">
|
||||
<span className="text-xs font-semibold text-foreground uppercase tracking-wider">
|
||||
Store Status:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleStatus}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
formData.status === 'active'
|
||||
? 'bg-success text-successForeground'
|
||||
: 'bg-destructive text-destructiveForeground'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
{formData.status === 'active' ? 'OPEN FOR ORDERS' : 'CLOSED'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div
|
||||
className={`mt-4 p-3 rounded-lg text-sm flex items-center gap-2 ${
|
||||
msg.type === 'success'
|
||||
? 'bg-success/10 text-success border border-success/20'
|
||||
: 'bg-destructive/10 text-destructive border border-destructive/20'
|
||||
}`}
|
||||
>
|
||||
{msg.type === 'success' ? <Check className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
<span>{msg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Restaurant Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g. Spice Garden Bistro"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Street Address *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="w-4 h-4 absolute left-3 top-2.5 text-mutedForeground" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="123 Culinary Way, Downtown"
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Store Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Authentic artisanal meals prepared daily with fresh ingredients."
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Latitude Coords *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
value={formData.latitude}
|
||||
onChange={(e) => setFormData({ ...formData, latitude: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-1">
|
||||
Longitude Coords *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
value={formData.longitude}
|
||||
onChange={(e) => setFormData({ ...formData, longitude: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground uppercase mb-2">
|
||||
Store Banner Image
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
{formData.image_url ? (
|
||||
<div className="relative w-full sm:w-48 h-28 rounded-lg overflow-hidden border border-border bg-muted">
|
||||
<img
|
||||
src={resolveMediaUrl(formData.image_url)}
|
||||
alt="Store Banner"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full sm:w-48 h-28 rounded-lg border border-dashed border-border bg-surfaceSecondary flex flex-col items-center justify-center text-mutedForeground">
|
||||
<ImageIcon className="w-8 h-8 mb-1 opacity-50" />
|
||||
<span className="text-xs">No image set</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 w-full">
|
||||
<label className="inline-flex items-center justify-center gap-2 px-4 py-2 border border-border rounded-md shadow-sm text-xs font-semibold text-foreground bg-surface hover:bg-surfaceSecondary cursor-pointer transition-colors">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin text-accent" /> : <Upload className="w-4 h-4 text-accent" />}
|
||||
{uploading ? 'Uploading...' : 'Choose Cover Photo'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-mutedForeground mt-1.5">
|
||||
PNG, JPG or WebP images up to 5MB. Formats stored securely via R2 storage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-divider flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending || uploading}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primaryForeground hover:opacity-90 rounded-md text-sm font-semibold transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{data ? 'Save Changes' : 'Create Restaurant Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Alert = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='alert' className={cx('rounded-lg border p-4 text-sm', className)} {...props} />;
|
||||
export const AlertTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h5 className='mb-1 font-medium' {...props} />;
|
||||
export const AlertDescription = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='text-muted-foreground' {...props} />;
|
||||
export default Alert;
|
||||
5
frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Badge = ({ className, variant, size, ...props }: React.HTMLAttributes<HTMLSpanElement> & { variant?: string; size?: string }) => <span className={cx('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', className)} {...props} />;
|
||||
export default Badge;
|
||||
6
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Button = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }>(({ className, variant, size, ...props }, ref) => <button ref={ref} className={cx('inline-flex min-h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', className)} {...props} />);
|
||||
Button.displayName = 'Button';
|
||||
export default Button;
|
||||
10
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />;
|
||||
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-1.5 p-6', className)} {...props} />;
|
||||
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => <h3 className={cx('text-lg font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||
export const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={cx('text-sm text-muted-foreground', className)} {...props} />;
|
||||
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('p-6 pt-0', className)} {...props} />;
|
||||
export const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex items-center p-6 pt-0', className)} {...props} />;
|
||||
export default Card;
|
||||
9
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Dialog = ({ open = true, children }: { open?: boolean; children?: React.ReactNode }) => open ? <div role='dialog' aria-modal='true' className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4'>{children}</div> : null;
|
||||
export const DialogContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg', className)} {...props} />;
|
||||
export const DialogTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h2 className='text-lg font-semibold' {...props} />;
|
||||
export const DialogDescription = (props: React.HTMLAttributes<HTMLParagraphElement>) => <p className='text-sm text-muted-foreground' {...props} />;
|
||||
export const DialogClose = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' aria-label='Close' {...props} />;
|
||||
export default Dialog;
|
||||
6
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DropdownMenu = ({ children }: { children?: React.ReactNode }) => <div className='relative'>{children}</div>;
|
||||
export const DropdownMenuTrigger = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' {...props} />;
|
||||
export const DropdownMenuContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='absolute right-0 z-20 mt-2 min-w-40 rounded-md border bg-popover p-1 shadow-md' {...props} />;
|
||||
export const DropdownMenuItem = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' className='flex w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent' {...props} />;
|
||||
5
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
|
||||
Input.displayName = 'Input';
|
||||
export default Input;
|
||||
4
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Label = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium leading-none', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default Label;
|
||||
7
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, ...props }, ref) => <select ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm', className].filter(Boolean).join(' ')} {...props} />);
|
||||
Select.displayName = 'Select';
|
||||
export const SelectItem = (props: React.OptionHTMLAttributes<HTMLOptionElement>) => <option {...props} />;
|
||||
export const SelectGroup = (props: React.OptgroupHTMLAttributes<HTMLOptGroupElement>) => <optgroup {...props} />;
|
||||
export default Select;
|
||||
4
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Separator = ({ orientation = 'horizontal', className, ...props }: React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) => <div role='separator' aria-orientation={orientation} className={[orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full', 'shrink-0 bg-border', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default Separator;
|
||||
3
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export { Dialog as Sheet, DialogContent as SheetContent, DialogTitle as SheetTitle, DialogDescription as SheetDescription, DialogClose as SheetClose } from './dialog';
|
||||
5
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' role='switch' className='size-5 rounded-full border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
|
||||
Switch.displayName = 'Switch';
|
||||
export default Switch;
|
||||
10
frontend/src/components/ui/table.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Table = ({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <div className='w-full overflow-auto'><table className={cx('w-full caption-bottom text-sm', className)} {...props} /></div>;
|
||||
export const TableHeader = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead {...props} />;
|
||||
export const TableBody = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />;
|
||||
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr className={cx('border-b', className)} {...props} />;
|
||||
export const TableHead = (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th className='h-10 px-2 text-left align-middle font-medium' {...props} />;
|
||||
export const TableCell = (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td className='p-2 align-middle' {...props} />;
|
||||
export default Table;
|
||||
8
frontend/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Tabs = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-3', className)} {...props} />;
|
||||
export const TabsList = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tablist' className={cx('inline-flex h-10 items-center gap-1 rounded-md bg-muted p-1', className)} {...props} />;
|
||||
export const TabsTrigger = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button role='tab' className={cx('rounded px-3 py-1.5 text-sm', className)} {...props} />;
|
||||
export const TabsContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tabpanel' className={cx('mt-2', className)} {...props} />;
|
||||
export default Tabs;
|
||||
5
frontend/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(({ className, ...props }, ref) => <textarea ref={ref} className={['flex min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
|
||||
Textarea.displayName = 'Textarea';
|
||||
export default Textarea;
|
||||
5
frontend/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Tooltip = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
|
||||
export const TooltipTrigger = (props: React.HTMLAttributes<HTMLElement>) => <span {...props} />;
|
||||
export const TooltipContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div role='tooltip' className='rounded bg-foreground px-2 py-1 text-xs text-background' {...props} />;
|
||||
154
frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import { authLink } from '../lib/mAuth';
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
fullName?: string;
|
||||
full_name?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
citizen_id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserData | null;
|
||||
token: string | null;
|
||||
login: () => Promise<void>;
|
||||
checkAuth: () => Promise<UserData | null>;
|
||||
logout: () => void;
|
||||
updateUser: (userData: UserData) => void;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const updateUser = (newUserData: UserData) => {
|
||||
setUser(newUserData);
|
||||
localStorage.setItem('auth_user', JSON.stringify(newUserData));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the backend check-in endpoint to sync the mAuth user
|
||||
* with the local database and get the full user profile.
|
||||
*/
|
||||
const handleCheckIn = async (authToken: string) => {
|
||||
try {
|
||||
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||
const res = await api.post('/user/check-in', {
|
||||
mauth_token: authToken,
|
||||
});
|
||||
if (res.data?.success && res.data.user) {
|
||||
setUser(res.data.user);
|
||||
localStorage.setItem('auth_user', JSON.stringify(res.data.user));
|
||||
return res.data.user;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Check-in failed:", err);
|
||||
logout();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Public compatibility method for generated onboarding and role guards.
|
||||
// It always reuses the scaffolded check-in path instead of inventing a
|
||||
// second token-validation flow in individual pages.
|
||||
const checkAuth = async (): Promise<UserData | null> => {
|
||||
const authToken = token || localStorage.getItem('user_auth_token') || localStorage.getItem('auth_token');
|
||||
if (!authToken) return null;
|
||||
setToken(authToken);
|
||||
return handleCheckIn(authToken);
|
||||
};
|
||||
|
||||
/**
|
||||
* mAuth Native Initializer:
|
||||
* 1. Check for user_auth_token in localStorage (set by /auth route).
|
||||
* 2. If present, call backend check-in to validate & sync user data.
|
||||
* The backend handles mAuth token verification — we must NOT call
|
||||
* fetchUserData here too, as that would consume/invalidate the token
|
||||
* before the backend can use it.
|
||||
* 3. If absent or invalid, clear auth state so the login button is shown.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initializer = async () => {
|
||||
const origin = window.location.origin.toString();
|
||||
if (!localStorage.getItem('mworld_origin')) {
|
||||
localStorage.setItem('mworld_origin', origin);
|
||||
}
|
||||
|
||||
const uat = localStorage.getItem('user_auth_token');
|
||||
if (uat) {
|
||||
setToken(uat);
|
||||
|
||||
// Restore cached user data immediately for fast UI
|
||||
const savedUser = localStorage.getItem('auth_user');
|
||||
if (savedUser && savedUser !== 'undefined') {
|
||||
try {
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Validate token & sync user via backend check-in
|
||||
// (backend calls mAuth internally to verify the token)
|
||||
const freshUser = await handleCheckIn(uat);
|
||||
if (!freshUser) {
|
||||
// Token is invalid or check-in failed — clean up
|
||||
localStorage.removeItem('user_auth_token');
|
||||
logout();
|
||||
}
|
||||
}
|
||||
// If no token present, user stays logged out (login button shown)
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initializer();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Triggers the mAuth native login flow:
|
||||
* Generates an auth link and redirects the user to mAuth in the same window.
|
||||
*/
|
||||
const login = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||
const link = await authLink(currentOrigin);
|
||||
if (!link) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Native flow: redirect in same window (not window.open)
|
||||
window.location.href = link;
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('auth_user');
|
||||
localStorage.removeItem('user_auth_token');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, checkAuth, logout, updateUser, isAuthenticated: !!user, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||
return context;
|
||||
};
|
||||
152
frontend/src/hooks/useLiveTrip.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export interface LivePositionData {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
updated_at?: string;
|
||||
expired?: boolean;
|
||||
}
|
||||
|
||||
export interface TripRouteData {
|
||||
geometry?: Array<[number, number]>;
|
||||
coordinates?: Array<[number, number]>;
|
||||
distance_m?: number;
|
||||
duration_s?: number;
|
||||
route?: any;
|
||||
}
|
||||
|
||||
interface UseLiveTripOptions {
|
||||
orderId?: string | null;
|
||||
enabled?: boolean;
|
||||
isDriver?: boolean;
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
export function useLiveTrip({
|
||||
orderId,
|
||||
enabled = true,
|
||||
isDriver = false,
|
||||
pollInterval = 5000,
|
||||
}: UseLiveTripOptions = {}) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [gpsError, setGpsError] = useState<string | null>(null);
|
||||
const watchIdRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
data: positionData,
|
||||
isLoading: isPositionLoading,
|
||||
error: positionError,
|
||||
refetch: refetchPosition,
|
||||
} = useQuery({
|
||||
queryKey: ['order-position', orderId],
|
||||
queryFn: async () => {
|
||||
if (!orderId) return null;
|
||||
const res = await api.get(`/api/orders/${orderId}/position`);
|
||||
return (res.data?.position as LivePositionData | null) || null;
|
||||
},
|
||||
enabled: Boolean(orderId) && enabled,
|
||||
refetchInterval: pollInterval,
|
||||
});
|
||||
|
||||
const {
|
||||
data: routeData,
|
||||
isLoading: isRouteLoading,
|
||||
error: routeError,
|
||||
} = useQuery({
|
||||
queryKey: ['order-route', orderId],
|
||||
queryFn: async () => {
|
||||
if (!orderId) return null;
|
||||
const res = await api.get(`/api/orders/${orderId}/route`);
|
||||
return (res.data?.route as TripRouteData | null) || null;
|
||||
},
|
||||
enabled: Boolean(orderId) && enabled,
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const publishLocation = useCallback(async (latitude: number, longitude: number) => {
|
||||
try {
|
||||
await api.patch('/api/delivery/location', {
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
setGpsError(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update driver location:', err);
|
||||
setGpsError(err?.response?.data?.msg || 'Failed to update location');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPublishing = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setGpsError('Geolocation is not supported by your browser');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
setGpsError(null);
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude } = pos.coords;
|
||||
publishLocation(latitude, longitude);
|
||||
},
|
||||
(err) => {
|
||||
console.warn('GPS location error:', err.message);
|
||||
setGpsError(err.message || 'Unable to access GPS location');
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
watchIdRef.current = watchId;
|
||||
}, [publishLocation]);
|
||||
|
||||
const stopPublishing = useCallback(() => {
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
watchIdRef.current = null;
|
||||
}
|
||||
setIsPublishing(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDriver && enabled) {
|
||||
startPublishing();
|
||||
}
|
||||
return () => {
|
||||
stopPublishing();
|
||||
};
|
||||
}, [isDriver, enabled, startPublishing, stopPublishing]);
|
||||
|
||||
const polylineCoordinates: Array<[number, number]> = (() => {
|
||||
if (!routeData) return [];
|
||||
if (Array.isArray(routeData.geometry)) return routeData.geometry;
|
||||
if (Array.isArray(routeData.coordinates)) return routeData.coordinates;
|
||||
if (routeData.route?.geometry?.coordinates && Array.isArray(routeData.route.geometry.coordinates)) {
|
||||
return routeData.route.geometry.coordinates.map((c: [number, number]) => [c[1], c[0]]);
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
return {
|
||||
position: positionData,
|
||||
route: routeData,
|
||||
polylineCoordinates,
|
||||
isLoading: isPositionLoading || isRouteLoading,
|
||||
positionError: positionError ? String(positionError) : null,
|
||||
routeError: routeError ? String(routeError) : null,
|
||||
refetchPosition,
|
||||
isPublishing,
|
||||
gpsError,
|
||||
startPublishing,
|
||||
stopPublishing,
|
||||
publishLocation,
|
||||
};
|
||||
}
|
||||
73
frontend/src/hooks/useSearchFilter.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export interface SearchFilterState {
|
||||
q: string;
|
||||
category: string;
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
sort: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export function useSearchFilter() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [state, setState] = useState<SearchFilterState>(() => ({
|
||||
q: searchParams.get('q') || searchParams.get('search') || '',
|
||||
category: searchParams.get('category') || '',
|
||||
minPrice: searchParams.get('min_price') || '',
|
||||
maxPrice: searchParams.get('max_price') || '',
|
||||
sort: searchParams.get('sort') || 'newest',
|
||||
page: Number(searchParams.get('page')) || 1,
|
||||
}));
|
||||
|
||||
const [debouncedQ, setDebouncedQ] = useState(state.q);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQ(state.q);
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [state.q]);
|
||||
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (debouncedQ) {
|
||||
params.search = debouncedQ;
|
||||
params.q = debouncedQ;
|
||||
}
|
||||
if (state.category) params.category = state.category;
|
||||
if (state.minPrice) params.min_price = state.minPrice;
|
||||
if (state.maxPrice) params.max_price = state.maxPrice;
|
||||
if (state.sort !== 'newest') params.sort = state.sort;
|
||||
if (state.page > 1) params.page = String(state.page);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [debouncedQ, state.category, state.minPrice, state.maxPrice, state.sort, state.page, setSearchParams]);
|
||||
|
||||
const queryString = useCallback(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (debouncedQ) {
|
||||
p.set('search', debouncedQ);
|
||||
p.set('q', debouncedQ);
|
||||
}
|
||||
if (state.category) p.set('category', state.category);
|
||||
if (state.minPrice) p.set('min_price', state.minPrice);
|
||||
if (state.maxPrice) p.set('max_price', state.maxPrice);
|
||||
p.set('sort', state.sort);
|
||||
p.set('page', String(state.page));
|
||||
return p.toString();
|
||||
}, [debouncedQ, state]);
|
||||
|
||||
const update = useCallback((patch: Partial<SearchFilterState>) => {
|
||||
setState((prev) => ({ ...prev, ...patch, page: patch.page ?? 1 }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ q: '', category: '', minPrice: '', maxPrice: '', sort: 'newest', page: 1 });
|
||||
}, []);
|
||||
|
||||
return { state, debouncedQ, queryString, update, reset };
|
||||
}
|
||||
|
||||
export default useSearchFilter;
|
||||
68
frontend/src/index.css
Normal file
@ -0,0 +1,68 @@
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #F8F7F4;
|
||||
--foreground: #000000;
|
||||
--surface: #FFFFFF;
|
||||
--surface-secondary: #F0EFEA;
|
||||
--surface-tertiary: #E5E4DD;
|
||||
--surface-inverse: #16181B;
|
||||
--primary: #0F172A;
|
||||
--primary-foreground: #FFFFFF;
|
||||
--secondary: #E2E8F0;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #EEEDE8;
|
||||
--muted-foreground: #000000;
|
||||
--accent: #B93812;
|
||||
--accent-foreground: #FFFFFF;
|
||||
--destructive: #B91C1C;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
--border: #E2E1DA;
|
||||
--border-strong: #71717A;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #F8F7F4;
|
||||
color: #000000;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, .font-display {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #F0EFEA;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #71717A;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #0F172A;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #E5E4DD;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
47
frontend/src/lib/mAuth.ts
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const mAuthAPIURL = "https://api.mauth.mworld.cloud";
|
||||
|
||||
/**
|
||||
* Creates a new mAuth authentication link.
|
||||
* Requires the current origin URL.
|
||||
*/
|
||||
export const authLink = async (currentOrigin: string): Promise<string | null> => {
|
||||
try {
|
||||
const r = await axios.get(`${mAuthAPIURL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}&type=native`);
|
||||
console.log(r.data);
|
||||
const link = r.data?.mAuth?.auth_link;
|
||||
return link || null;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the data of the user who authenticated.
|
||||
* Requires origin URL and the user auth token.
|
||||
*
|
||||
* WARNING: This CONSUMES the mAuth token. The backend `/user/check-in`
|
||||
* endpoint already verifies the token server-side, so calling this during
|
||||
* app bootstrap would invalidate the token before the backend can use it.
|
||||
* Do NOT call this from AuthContext — it exists only for one-off flows that
|
||||
* need raw mAuth profile fields without a backend round-trip.
|
||||
*/
|
||||
export const fetchUserData = async (origin: string, token: string) => {
|
||||
try {
|
||||
const payload = {
|
||||
origin: origin,
|
||||
required_user_data: ["fullName", "email", "phone"],
|
||||
};
|
||||
const r = await axios.get(
|
||||
`${mAuthAPIURL}/mAuthUser/${token}?payload=${JSON.stringify(payload)}`
|
||||
);
|
||||
console.log(r.data);
|
||||
return r.data;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
36
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,36 @@
|
||||
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatPrice(price: any): string {
|
||||
const num = Number(price);
|
||||
if (isNaN(num)) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export function formatMileage(mileage: any): string {
|
||||
const num = Number(mileage);
|
||||
if (isNaN(num)) return '';
|
||||
return new Intl.NumberFormat('en-US').format(num) + ' miles';
|
||||
}
|
||||
|
||||
export function formatDate(dateString: any): string {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
31
frontend/src/main.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
|
||||
import React, { Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
375
frontend/src/pages/Admin.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../api';
|
||||
import AdminOrderModal, { AdminOrderData } from '../components/admin/AdminOrderModal';
|
||||
import { Activity, Bike, DollarSign, Eye, Layers, RefreshCw, Search, ShoppingBag, Users, Utensils } from 'lucide-react';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState<'orders' | 'users'>('orders');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedOrder, setSelectedOrder] = useState<AdminOrderData | null>(null);
|
||||
|
||||
const { data: statsData, refetch: refetchStats, isFetching: isStatsFetching } = useQuery({
|
||||
queryKey: ['admin-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await adminApi.get('/api/admin/stats');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const { data: usersData, isLoading: isUsersLoading, refetch: refetchUsers } = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: async () => {
|
||||
const res = await adminApi.get('/api/admin/users');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ordersData, isLoading: isOrdersLoading, refetch: refetchOrders } = useQuery({
|
||||
queryKey: ['admin-orders'],
|
||||
queryFn: async () => {
|
||||
const res = await adminApi.get('/api/admin/orders');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const stats = statsData?.stats || {
|
||||
total_users: 0,
|
||||
active_restaurants: 0,
|
||||
online_drivers: 0,
|
||||
total_orders: 0,
|
||||
active_orders: 0,
|
||||
total_revenue: 0,
|
||||
};
|
||||
|
||||
const rawUsers = Array.isArray(usersData?.users) ? usersData.users : [];
|
||||
const filteredUsers = rawUsers.filter((u: any) => {
|
||||
const q = String((searchQuery.toLowerCase()) ?? '').trim();
|
||||
if (!q) return true;
|
||||
return (
|
||||
String(u.full_name || '').toLowerCase().includes(q) ||
|
||||
String(u.email || '').toLowerCase().includes(q) ||
|
||||
String(u.phone || '').toLowerCase().includes(q) ||
|
||||
String(u.role || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const rawOrders = Array.isArray(ordersData?.orders) ? ordersData.orders : [];
|
||||
const filteredOrders = rawOrders.filter((o: any) => {
|
||||
const matchesStatus = statusFilter === 'all' || o.status === statusFilter;
|
||||
const q = String((searchQuery.toLowerCase()) ?? '').trim();
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
String(o.id || '').toLowerCase().includes(q) ||
|
||||
String(o.customer_email || '').toLowerCase().includes(q) ||
|
||||
String(o.restaurant_name || '').toLowerCase().includes(q) ||
|
||||
String(o.delivery_address || '').toLowerCase().includes(q);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'placed':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Placed</span>;
|
||||
case 'accepted':
|
||||
case 'preparing':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">In Kitchen ({status})</span>;
|
||||
case 'ready_for_pickup':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">Ready for Pickup</span>;
|
||||
case 'assigned':
|
||||
case 'picked_up':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-800">On Delivery</span>;
|
||||
case 'delivered':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">Delivered</span>;
|
||||
case 'cancelled':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-800">Cancelled</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-800">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string | null) => {
|
||||
switch (role) {
|
||||
case 'customer':
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700">Customer</span>;
|
||||
case 'restaurant':
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800">Restaurant</span>;
|
||||
case 'delivery_agent':
|
||||
case 'delivery_rider':
|
||||
case 'driver':
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800">Driver</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">Pending</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F7F4] text-slate-900 pb-16">
|
||||
<header className="bg-[#16181B] text-white border-b border-slate-800 sticky top-0 z-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 bg-[#B93812] rounded-lg flex items-center justify-center font-bold text-lg text-white">
|
||||
E
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display font-bold text-lg leading-tight tracking-tight flex items-center gap-2">
|
||||
EatIt Operator Panel
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400">Live Network Telemetry & Operations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Verified Token
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
refetchStats();
|
||||
refetchOrders();
|
||||
refetchUsers();
|
||||
}}
|
||||
className="p-2 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors"
|
||||
title="Refresh telemetry"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isStatsFetching ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div className="bg-white rounded-xl p-4 border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center justify-between text-slate-500 text-xs font-medium mb-1">
|
||||
<span>Total Users</span>
|
||||
<Users className="h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-display">{stats.total_users}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center justify-between text-slate-500 text-xs font-medium mb-1">
|
||||
<span>Active Kitchens</span>
|
||||
<Utensils className="h-4 w-4 text-orange-500" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-display">{stats.active_restaurants}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center justify-between text-slate-500 text-xs font-medium mb-1">
|
||||
<span>Online Couriers</span>
|
||||
<Bike className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-display">{stats.online_drivers}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center justify-between text-slate-500 text-xs font-medium mb-1">
|
||||
<span>Active Tickets</span>
|
||||
<Activity className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-display text-amber-600">{stats.active_orders}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center justify-between text-slate-500 text-xs font-medium mb-1">
|
||||
<span>Total Orders</span>
|
||||
<ShoppingBag className="h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-display">{stats.total_orders}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-[#E2E1DA] shadow-sm">
|
||||
<div className="flex items-center justify-between text-slate-500 text-xs font-medium mb-1">
|
||||
<span>Gross Revenue</span>
|
||||
<DollarSign className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-display text-emerald-700">
|
||||
${Number(stats.total_revenue || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E2E1DA] p-4 shadow-sm space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-[#E8E7E0] pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'orders'
|
||||
? 'bg-[#0F172A] text-white shadow-sm'
|
||||
: 'bg-[#F0EFEA] text-slate-700 hover:bg-[#E5E4DD]'
|
||||
}`}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
Live Network Orders ({rawOrders.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'users'
|
||||
? 'bg-[#0F172A] text-white shadow-sm'
|
||||
: 'bg-[#F0EFEA] text-slate-700 hover:bg-[#E5E4DD]'
|
||||
}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
User Directory ({rawUsers.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[220px]">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={activeTab === 'orders' ? 'Search order, email, kitchen...' : 'Search user, email, phone...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-lg border border-[#E2E1DA] focus:outline-none focus:ring-2 focus:ring-[#0F172A]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTab === 'orders' && (
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="py-1.5 px-3 text-xs rounded-lg border border-[#E2E1DA] bg-white text-slate-700 font-medium focus:outline-none focus:ring-2 focus:ring-[#0F172A]"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="placed">Placed</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="preparing">Preparing</option>
|
||||
<option value="ready_for_pickup">Ready for Pickup</option>
|
||||
<option value="assigned">Assigned / On Delivery</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'orders' ? (
|
||||
<div className="overflow-x-auto">
|
||||
{isOrdersLoading ? (
|
||||
<div className="py-12 text-center text-slate-500 text-sm">
|
||||
Loading live marketplace orders...
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div className="py-12 text-center text-slate-500 text-sm">
|
||||
No orders match the selected criteria.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E8E7E0] bg-[#F8F7F4] text-slate-600 font-semibold uppercase tracking-wider">
|
||||
<th className="py-3 px-3">Order ID</th>
|
||||
<th className="py-3 px-3">Customer</th>
|
||||
<th className="py-3 px-3">Kitchen</th>
|
||||
<th className="py-3 px-3">Status</th>
|
||||
<th className="py-3 px-3">Amount</th>
|
||||
<th className="py-3 px-3">Placed At</th>
|
||||
<th className="py-3 px-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E8E7E0]">
|
||||
{filteredOrders.map((order: any) => (
|
||||
<tr key={order.id} className="hover:bg-[#F8F7F4] transition-colors">
|
||||
<td className="py-3 px-3 font-mono font-bold text-slate-800">
|
||||
#{order.id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-slate-700">
|
||||
{order.customer_email}
|
||||
</td>
|
||||
<td className="py-3 px-3 font-medium text-slate-900">
|
||||
{order.restaurant_name || order.restaurant_id || 'Kitchen'}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="py-3 px-3 font-semibold text-slate-900">
|
||||
${Number(order.total_amount || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-slate-500">
|
||||
{order.created_at ? new Date(order.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
<button
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-[#0F172A] text-white hover:bg-slate-800 transition-colors text-xs font-medium"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
{isUsersLoading ? (
|
||||
<div className="py-12 text-center text-slate-500 text-sm">
|
||||
Loading user directory...
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="py-12 text-center text-slate-500 text-sm">
|
||||
No users match search criteria.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E8E7E0] bg-[#F8F7F4] text-slate-600 font-semibold uppercase tracking-wider">
|
||||
<th className="py-3 px-3">Full Name / Username</th>
|
||||
<th className="py-3 px-3">Email</th>
|
||||
<th className="py-3 px-3">Phone</th>
|
||||
<th className="py-3 px-3">Role</th>
|
||||
<th className="py-3 px-3">Registered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E8E7E0]">
|
||||
{filteredUsers.map((user: any) => (
|
||||
<tr key={user.email} className="hover:bg-[#F8F7F4] transition-colors">
|
||||
<td className="py-3 px-3 font-medium text-slate-900">
|
||||
<div>{user.full_name || user.username || 'Marketplace User'}</div>
|
||||
{user.username && <div className="text-[10px] text-slate-400">@{user.username}</div>}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-slate-700 font-mono">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-slate-600">
|
||||
{user.phone || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
{getRoleBadge(user.role)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-slate-500">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{selectedOrder && (
|
||||
<AdminOrderModal
|
||||
order={selectedOrder}
|
||||
isOpen={!!selectedOrder}
|
||||
onClose={() => setSelectedOrder(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
frontend/src/pages/Auth.tsx
Normal file
428
frontend/src/pages/CustomerOrders.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import MapPanel from '../components/features/MapPanel';
|
||||
import OrderRatingModal from '../components/customer/OrderRatingModal';
|
||||
import { ArrowRight, Ban, CheckCircle2, ChefHat, Clock, MapPin, PackageCheck, RefreshCw, ShoppingBag, Star, Store, Truck, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type OrderStatus =
|
||||
| 'placed'
|
||||
| 'accepted'
|
||||
| 'preparing'
|
||||
| 'ready_for_pickup'
|
||||
| 'assigned'
|
||||
| 'picked_up'
|
||||
| 'delivered'
|
||||
| 'cancelled';
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
special_instructions?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
restaurant_name?: string;
|
||||
customer_email: string;
|
||||
delivery_address: string;
|
||||
delivery_latitude: number;
|
||||
delivery_longitude: number;
|
||||
restaurant_latitude?: number;
|
||||
restaurant_longitude?: number;
|
||||
restaurant_address?: string;
|
||||
subtotal: number;
|
||||
delivery_fee: number;
|
||||
total_amount: number;
|
||||
status: OrderStatus;
|
||||
created_at: string;
|
||||
items?: OrderItem[];
|
||||
delivery_agent_email?: string;
|
||||
}
|
||||
|
||||
const STEP_ORDER: { key: OrderStatus; label: string; icon: any }[] = [
|
||||
{ key: 'placed', label: 'Placed', icon: Clock },
|
||||
{ key: 'accepted', label: 'Accepted', icon: CheckCircle2 },
|
||||
{ key: 'preparing', label: 'Preparing', icon: ChefHat },
|
||||
{ key: 'ready_for_pickup', label: 'Ready', icon: Store },
|
||||
{ key: 'picked_up', label: 'Picked Up', icon: Truck },
|
||||
{ key: 'delivered', label: 'Delivered', icon: PackageCheck },
|
||||
];
|
||||
|
||||
function getStepIndex(status: OrderStatus): number {
|
||||
if (status === 'cancelled') return -1;
|
||||
if (status === 'assigned') return 3;
|
||||
const idx = STEP_ORDER.findIndex((s) => s.key === status);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
export default function CustomerOrdersPage() {
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null);
|
||||
const [filterTab, setFilterTab] = useState<'active' | 'past'>('active');
|
||||
const [ratingOrder, setRatingOrder] = useState<Order | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: myOrdersData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['my-orders'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/orders/my');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const orders: Order[] = Array.isArray(myOrdersData?.orders) ? myOrdersData.orders : [];
|
||||
|
||||
const activeOrders = orders.filter(
|
||||
(o) => o.status !== 'delivered' && o.status !== 'cancelled'
|
||||
);
|
||||
const pastOrders = orders.filter(
|
||||
(o) => o.status === 'delivered' || o.status === 'cancelled'
|
||||
);
|
||||
|
||||
const displayedOrders = filterTab === 'active' ? activeOrders : pastOrders;
|
||||
const activeSelectedOrder = orders.find((o) => o.id === selectedOrderId) || displayedOrders[0] || null;
|
||||
|
||||
const { data: orderDetailData } = useQuery({
|
||||
queryKey: ['order-detail', activeSelectedOrder?.id],
|
||||
queryFn: async () => {
|
||||
if (!activeSelectedOrder?.id) return null;
|
||||
const res = await api.get(`/api/orders/${activeSelectedOrder.id}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(activeSelectedOrder?.id),
|
||||
refetchInterval: activeSelectedOrder?.status !== 'delivered' && activeSelectedOrder?.status !== 'cancelled' ? 5000 : false,
|
||||
});
|
||||
|
||||
const detailedOrder: Order | null = orderDetailData?.order || activeSelectedOrder;
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async (orderId: string) => {
|
||||
const res = await api.patch(`/api/orders/${orderId}/cancel`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Order cancelled successfully.');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-orders'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.msg || 'Could not cancel order. Cooking may have already started.');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-background py-6 px-4 md:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-border pb-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<ShoppingBag className="text-accent" size={28} />
|
||||
Your Orders
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Track live delivery updates, view order history, and submit ratings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-surfaceSecondary p-1 rounded-xl border border-border self-start sm:self-auto">
|
||||
<button
|
||||
onClick={() => setFilterTab('active')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-semibold transition-all ${
|
||||
filterTab === 'active'
|
||||
? 'bg-white text-slate-900 shadow-sm border border-border'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Active Orders ({activeOrders.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterTab('past')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-semibold transition-all ${
|
||||
filterTab === 'past'
|
||||
? 'bg-white text-slate-900 shadow-sm border border-border'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Past Orders ({pastOrders.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-white rounded-2xl border border-border space-y-3">
|
||||
<RefreshCw className="animate-spin text-accent" size={32} />
|
||||
<p className="text-sm text-slate-500 font-medium">Retrieving order details and live locations...</p>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-white rounded-2xl border border-border text-center p-6">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-50 text-accent mb-4 border border-amber-200">
|
||||
<ShoppingBag size={32} />
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-bold text-slate-900 mb-1">No Orders Found</h3>
|
||||
<p className="text-sm text-slate-500 max-w-sm mb-6">
|
||||
You haven't placed any food orders yet. Explore top local kitchens and order a meal!
|
||||
</p>
|
||||
<a
|
||||
href="/app"
|
||||
className="inline-flex items-center gap-2 bg-primary text-white px-5 py-2.5 rounded-xl font-semibold text-sm shadow hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Browse Restaurants
|
||||
<ArrowRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-slate-500 px-1">
|
||||
{filterTab === 'active' ? 'Current Active Tickets' : 'Completed Order Records'}
|
||||
</h2>
|
||||
{displayedOrders.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-border p-6 text-center text-sm text-slate-500">
|
||||
No {filterTab} orders right now.
|
||||
</div>
|
||||
) : (
|
||||
displayedOrders.map((order) => {
|
||||
const isSelected = detailedOrder?.id === order.id;
|
||||
const currentStep = getStepIndex(order.status);
|
||||
const isCancelled = order.status === 'cancelled';
|
||||
const isDelivered = order.status === 'delivered';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={order.id}
|
||||
onClick={() => setSelectedOrderId(order.id)}
|
||||
className={`cursor-pointer rounded-2xl border p-4 transition-all bg-white ${
|
||||
isSelected
|
||||
? 'border-accent shadow-md ring-1 ring-accent/20'
|
||||
: 'border-border hover:border-slate-300 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h3 className="font-display font-bold text-slate-900 text-base">
|
||||
{order.restaurant_name || 'Kitchen Order'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
||||
ID: #{order.id.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${
|
||||
isDelivered
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: isCancelled
|
||||
? 'bg-red-50 text-red-700 border border-red-200'
|
||||
: 'bg-amber-50 text-amber-800 border border-amber-200 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
{order.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-600 space-y-1 mb-3 bg-slate-50 p-2.5 rounded-xl">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin size={14} className="text-slate-400 shrink-0" />
|
||||
<span className="truncate">{order.delivery_address}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-slate-500 pt-1 border-t border-slate-200/60">
|
||||
<span>{new Date(order.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span className="font-bold text-slate-900">${Number(order.total_amount).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-slate-500 font-medium">
|
||||
{isCancelled
|
||||
? 'Order Cancelled'
|
||||
: isDelivered
|
||||
? 'Order Delivered'
|
||||
: `Step ${currentStep + 1} of 6: ${STEP_ORDER[currentStep]?.label || order.status}`}
|
||||
</span>
|
||||
{isDelivered && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRatingOrder(order);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-bold text-amber-600 hover:text-amber-700 bg-amber-50 px-2.5 py-1 rounded-lg border border-amber-200"
|
||||
>
|
||||
<Star size={12} className="fill-amber-400 text-amber-500" />
|
||||
Rate Meal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
{detailedOrder ? (
|
||||
<div className="bg-white rounded-2xl border border-border shadow-sm overflow-hidden p-6 space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-border pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="text-accent" size={20} />
|
||||
<h2 className="font-display text-xl font-bold text-slate-900">
|
||||
{detailedOrder.restaurant_name || 'Restaurant Order'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Order ID: <span className="font-mono font-medium text-slate-700">{detailedOrder.id}</span> • Placed {new Date(detailedOrder.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(detailedOrder.status === 'placed' || detailedOrder.status === 'accepted') && (
|
||||
<button
|
||||
onClick={() => cancelMutation.mutate(detailedOrder.id)}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 px-3.5 py-2 rounded-xl text-xs font-bold transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Ban size={14} />
|
||||
{cancelMutation.isPending ? 'Cancelling...' : 'Cancel Order'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-500 mb-4">
|
||||
Live Preparation & Delivery Progress
|
||||
</h3>
|
||||
{detailedOrder.status === 'cancelled' ? (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 border border-red-200 text-red-800 text-sm font-medium">
|
||||
<XCircle size={20} className="shrink-0 text-red-600" />
|
||||
This order was cancelled and is no longer being prepared.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{STEP_ORDER.map((step, index) => {
|
||||
const currentIdx = getStepIndex(detailedOrder.status);
|
||||
const isPassed = index <= currentIdx;
|
||||
const isCurrent = index === currentIdx;
|
||||
const StepIcon = step.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className={`flex flex-col items-center text-center p-2.5 rounded-xl border transition-all ${
|
||||
isCurrent
|
||||
? 'bg-amber-50 border-amber-300 ring-2 ring-amber-400/20 shadow-sm'
|
||||
: isPassed
|
||||
? 'bg-slate-50 border-slate-200 text-slate-900'
|
||||
: 'bg-white border-dashed border-slate-200 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold mb-2 ${
|
||||
isCurrent
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: isPassed
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<StepIcon size={16} />
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold leading-tight text-slate-800">
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Live Driver Route Navigation
|
||||
</h3>
|
||||
<MapPanel
|
||||
orderId={detailedOrder.id}
|
||||
restaurantLocation={
|
||||
detailedOrder.restaurant_latitude && detailedOrder.restaurant_longitude
|
||||
? {
|
||||
lat: Number(detailedOrder.restaurant_latitude),
|
||||
lng: Number(detailedOrder.restaurant_longitude),
|
||||
name: detailedOrder.restaurant_name,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
deliveryLocation={{
|
||||
lat: Number(detailedOrder.delivery_latitude),
|
||||
lng: Number(detailedOrder.delivery_longitude),
|
||||
address: detailedOrder.delivery_address,
|
||||
}}
|
||||
className="h-[320px] w-full rounded-2xl border border-border shadow-inner overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Order Receipt & Breakdown
|
||||
</h3>
|
||||
<div className="rounded-xl border border-border bg-slate-50/50 p-4 space-y-3 text-sm">
|
||||
{detailedOrder.items && detailedOrder.items.length > 0 ? (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
{detailedOrder.items.map((item) => (
|
||||
<div key={item.id} className="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-bold text-slate-900 mr-2">{item.quantity}x</span>
|
||||
<span className="text-slate-800 font-medium">{item.item_name}</span>
|
||||
{item.special_instructions && (
|
||||
<p className="text-xs text-slate-500 italic mt-0.5">Note: {item.special_instructions}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold text-slate-900">
|
||||
${Number(item.unit_price * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 italic">Standard meal package</p>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-3 space-y-1.5 text-xs text-slate-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>${Number(detailedOrder.subtotal || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Delivery Fee</span>
|
||||
<span>${Number(detailedOrder.delivery_fee || 5.0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-slate-300 font-bold text-sm text-slate-900">
|
||||
<span>Total Amount</span>
|
||||
<span className="text-accent">${Number(detailedOrder.total_amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-border p-12 text-center text-slate-500 space-y-2">
|
||||
<ShoppingBag className="mx-auto text-slate-300" size={40} />
|
||||
<p className="font-medium text-slate-700">Select an order on the left to view detailed progress</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OrderRatingModal
|
||||
isOpen={Boolean(ratingOrder)}
|
||||
orderId={ratingOrder?.id || null}
|
||||
restaurantName={ratingOrder?.restaurant_name}
|
||||
onClose={() => setRatingOrder(null)}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
frontend/src/pages/DeliveryWorkspace.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import MapView, { MapMarker } from '../components/MapView';
|
||||
import ActiveTripTracker from '../components/delivery/ActiveTripTracker';
|
||||
import { ArrowRight, Bike, Car, CheckCircle2, MapPin, RefreshCw, ShieldAlert, Store, Truck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function DeliveryWorkspace() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [vehicleType, setVehicleType] = useState<string>('Bike');
|
||||
const [isAvailable, setIsAvailable] = useState<boolean>(true);
|
||||
|
||||
const { data: activeData, refetch: refetchActive } = useQuery({
|
||||
queryKey: ['delivery-orders-active'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/delivery/orders/active');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000
|
||||
});
|
||||
|
||||
const { data: availableData, refetch: refetchAvailable } = useQuery({
|
||||
queryKey: ['delivery-orders-available'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/delivery/orders/available');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000
|
||||
});
|
||||
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async (payload: { vehicle_type: string; is_available: number | boolean }) => {
|
||||
const res = await api.patch('/api/delivery/profile', payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Driver status updated');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update status');
|
||||
}
|
||||
});
|
||||
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: async (orderId: string) => {
|
||||
const res = await api.post(`/api/delivery/orders/${orderId}/claim`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Delivery job claimed successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['delivery-orders-active'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['delivery-orders-available'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err?.response?.status === 409) {
|
||||
toast.error('Job already claimed by another driver.');
|
||||
} else {
|
||||
toast.error(err?.response?.data?.msg || 'Could not claim delivery job.');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['delivery-orders-available'] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleOnline = (online: boolean) => {
|
||||
setIsAvailable(online);
|
||||
profileMutation.mutate({ vehicle_type: vehicleType, is_available: online ? 1 : 0 });
|
||||
};
|
||||
|
||||
const handleVehicleChange = (type: string) => {
|
||||
setVehicleType(type);
|
||||
profileMutation.mutate({ vehicle_type: type, is_available: isAvailable ? 1 : 0 });
|
||||
};
|
||||
|
||||
if (user && user.role !== 'delivery_agent') {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-12 text-center">
|
||||
<div className="bg-surface rounded-xl border border-border p-8 shadow-sm">
|
||||
<ShieldAlert className="w-12 h-12 text-accent mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold font-display text-primary mb-2">Delivery Partner Access Required</h2>
|
||||
<p className="text-mutedForeground mb-6">
|
||||
Your current account role is <span className="font-semibold text-primary">{user.role || 'Unassigned'}</span>.
|
||||
Please set your role to Delivery Partner to access driver dispatch jobs.
|
||||
</p>
|
||||
<Link
|
||||
to="/app/onboarding"
|
||||
className="inline-flex items-center gap-2 bg-accent text-accentForeground font-semibold px-6 py-2.5 rounded-lg hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
Switch Role to Delivery Partner
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeOrders = Array.isArray(activeData?.orders) ? activeData.orders : [];
|
||||
const currentActiveOrder = activeOrders.length > 0 ? activeOrders[0] : null;
|
||||
|
||||
const availableOrders = Array.isArray(availableData?.orders) ? availableData.orders : [];
|
||||
|
||||
const availableMarkers: MapMarker[] = [];
|
||||
availableOrders.forEach((o: any) => {
|
||||
if (typeof o.restaurant_latitude === 'number' && typeof o.restaurant_longitude === 'number') {
|
||||
availableMarkers.push({
|
||||
lat: o.restaurant_latitude,
|
||||
lng: o.restaurant_longitude,
|
||||
label: `Pickup: ${o.restaurant_name || 'Restaurant'} ($${Number(o.total_amount || 0).toFixed(2)})`,
|
||||
type: 'restaurant'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const defaultCenter: [number, number] = availableMarkers.length > 0
|
||||
? [availableMarkers[0].lat, availableMarkers[0].lng]
|
||||
: [37.7749, -122.4194];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div className="bg-surface rounded-xl border border-border p-5 flex flex-col md:flex-row md:items-center justify-between gap-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-display text-primary">Courier Dispatch Portal</h1>
|
||||
<p className="text-sm text-mutedForeground">Manage availability, claim open routes, and execute drop-offs</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1 bg-surfaceSecondary p-1 rounded-lg border border-border">
|
||||
{[
|
||||
{ id: 'Bike', label: 'Bike', icon: Bike },
|
||||
{ id: 'Scooter', label: 'Scooter', icon: Truck },
|
||||
{ id: 'Car', label: 'Car', icon: Car }
|
||||
].map((v) => {
|
||||
const Icon = v.icon;
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => handleVehicleChange(v.id)}
|
||||
className={`flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-md transition-colors ${vehicleType === v.id ? 'bg-surface text-primary shadow-xs' : 'text-mutedForeground hover:text-primary'}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{v.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleOnline(!isAvailable)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors cursor-pointer ${isAvailable ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
{isAvailable ? 'Online (Ready)' : 'Offline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentActiveOrder && (
|
||||
<ActiveTripTracker
|
||||
order={currentActiveOrder}
|
||||
onTripCompleted={() => {
|
||||
refetchActive();
|
||||
refetchAvailable();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-surface rounded-xl border border-border p-5 space-y-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold font-display text-primary">Available Delivery Jobs</h2>
|
||||
<p className="text-xs text-mutedForeground">
|
||||
{isAvailable
|
||||
? `${availableOrders.length} job(s) ready for pickup in your area`
|
||||
: 'Go Online to claim new delivery requests'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetchAvailable()}
|
||||
className="p-2 text-mutedForeground hover:text-primary hover:bg-surfaceSecondary rounded-lg transition-colors cursor-pointer"
|
||||
title="Refresh Available Jobs"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isAvailable ? (
|
||||
<div className="bg-surfaceSecondary rounded-lg p-6 text-center border border-border">
|
||||
<p className="text-sm font-medium text-mutedForeground">You are currently offline. Toggle your status to "Online" above to view and claim available deliveries.</p>
|
||||
</div>
|
||||
) : availableOrders.length === 0 ? (
|
||||
<div className="bg-surfaceSecondary rounded-lg p-8 text-center border border-border">
|
||||
<p className="text-sm font-semibold text-primary">No active delivery jobs in your area right now.</p>
|
||||
<p className="text-xs text-mutedForeground mt-1">New kitchen orders will automatically pop up here when marked ready for pickup.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div className="lg:col-span-2 h-[320px] rounded-lg overflow-hidden border border-border">
|
||||
<MapView center={defaultCenter} zoom={13} markers={availableMarkers} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
|
||||
{availableOrders.map((job: any) => (
|
||||
<div key={job.id} className="border border-border rounded-lg p-3 hover:border-accent/50 transition-colors bg-surface flex flex-col justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-accent uppercase">Order #{job.id.slice(0, 8)}</span>
|
||||
<span className="text-sm font-bold text-primary">${Number(job.total_amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="flex items-center gap-1 font-semibold text-primary">
|
||||
<Store className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
{job.restaurant_name || 'Restaurant'}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 text-mutedForeground">
|
||||
<MapPin className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||
{job.delivery_address || 'Delivery Destination'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => claimMutation.mutate(job.id)}
|
||||
disabled={claimMutation.isPending || !!currentActiveOrder}
|
||||
className="mt-3 w-full bg-primary hover:bg-slate-800 text-primary-foreground text-xs font-semibold py-2 px-3 rounded-md transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{currentActiveOrder ? 'Active Job in Progress' : 'Claim Delivery Job'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
453
frontend/src/pages/Landing.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Activity, ArrowRight, Bike, CheckCircle2, ChefHat, Clock, Layers, MapPin, Navigation, ShieldCheck, ShoppingBag, Sparkles, Utensils } from 'lucide-react';
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F7F4] text-[#0F172A] font-sans antialiased selection:bg-[#B93812]/20 selection:text-[#B93812]">
|
||||
|
||||
<header className="sticky top-0 z-40 bg-[#F8F7F4]/90 backdrop-blur-md border-b border-[#E2E1DA]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#0F172A] flex items-center justify-center text-[#FFFFFF] shadow-sm group-hover:bg-[#B93812] transition-colors">
|
||||
<Utensils className="w-5 h-5 text-current" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-display text-xl font-bold tracking-tight text-[#0F172A] block leading-none">
|
||||
EatIt
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-mono tracking-widest text-[#B93812] font-semibold">
|
||||
BiteRoute
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 ml-4 px-3 py-1 rounded-full bg-[#FFFFFF] border border-[#E2E1DA] text-xs text-[#71717A]">
|
||||
<span className="w-2 h-2 rounded-full bg-[#15803D] animate-pulse" />
|
||||
<span>Live Dispatch Network Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden sm:flex items-center gap-6 text-sm font-medium text-[#71717A]">
|
||||
<a href="#hero-split" className="hover:text-[#0F172A] transition-colors">Overview</a>
|
||||
<a href="#role-pathways" className="hover:text-[#0F172A] transition-colors">Roles</a>
|
||||
<a href="#workflow-timeline" className="hover:text-[#0F172A] transition-colors">Workflow</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-[#B93812] hover:bg-[#a02f0e] text-white font-medium text-sm transition-all shadow-sm hover:shadow active:scale-[0.98]"
|
||||
>
|
||||
<span>Sign In with M-Auth</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<main>
|
||||
|
||||
<section id="hero-split" className="relative pt-8 pb-16 md:pt-16 md:pb-24 overflow-hidden border-b border-[#E2E1DA]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
|
||||
|
||||
<div className="lg:col-span-6 space-y-6 text-left">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-[#FFFFFF] border border-[#E2E1DA] text-xs font-semibold text-[#0F172A]">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#B93812]" />
|
||||
<span>3-Party Real-Time Food Delivery Marketplace</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-display text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight text-[#0F172A] leading-[1.1]">
|
||||
Fresh meals from local kitchens, routed in real time.
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-[#71717A] max-w-xl font-normal leading-relaxed">
|
||||
EatIt connects hungry diners, active kitchens, and dedicated couriers on a transparent, live-tracked road network. Experience live kitchen tickets and turn-by-turn driver tracking.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 pt-2">
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="inline-flex items-center justify-center gap-2.5 px-7 py-3.5 rounded-lg bg-[#B93812] hover:bg-[#a02f0e] text-white font-semibold text-base transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
<span>Sign In with M-Auth</span>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<a
|
||||
href="#role-pathways"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-[#FFFFFF] hover:bg-[#F0EFEA] border border-[#E2E1DA] text-[#0F172A] font-semibold text-base transition-colors"
|
||||
>
|
||||
<span>Explore Pathways</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-6 border-t border-[#E2E1DA]">
|
||||
<div>
|
||||
<div className="font-display text-2xl font-bold text-[#0F172A]">100%</div>
|
||||
<div className="text-xs text-[#71717A]">Live Map Tracked</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-display text-2xl font-bold text-[#0F172A]">0%</div>
|
||||
<div className="text-xs text-[#71717A]">Hidden Fees</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-display text-2xl font-bold text-[#0F172A]">3 Roles</div>
|
||||
<div className="text-xs text-[#71717A]">One Ecosystem</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="lg:col-span-6 relative">
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#E2E1DA] shadow-xl bg-[#FFFFFF]">
|
||||
<img
|
||||
src="/assets/landing-01-hero-split.png"
|
||||
alt="Hero food photography with dark gradient contrast overlay for EatIt"
|
||||
className="w-full h-[320px] sm:h-[420px] object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0F172A]/90 via-[#0F172A]/30 to-transparent" />
|
||||
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 p-4 rounded-xl bg-[#FFFFFF]/95 backdrop-blur border border-[#E2E1DA] shadow-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-[#B93812]" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[#0F172A]">Live Telemetry</span>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-[#15803D]/10 text-[#15803D]">
|
||||
Active Route
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center pt-1 border-t border-[#E2E1DA]/60">
|
||||
<div>
|
||||
<div className="text-[10px] text-[#71717A]">Kitchen Prep</div>
|
||||
<div className="text-xs font-semibold text-[#0F172A]">Spice Garden</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-[#71717A]">Courier</div>
|
||||
<div className="text-xs font-semibold text-[#0F172A]">Driver Alex</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-[#71717A]">ETA</div>
|
||||
<div className="text-xs font-semibold text-[#B93812]">12 Mins</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="role-pathways" className="py-16 md:py-24 bg-[#FFFFFF] border-b border-[#E2E1DA]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 space-y-3">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#F0EFEA] text-xs font-semibold text-[#0F172A]">
|
||||
<Layers className="w-3.5 h-3.5 text-[#B93812]" />
|
||||
<span>Marketplace Participants</span>
|
||||
</div>
|
||||
<h2 className="font-display text-3xl sm:text-4xl font-bold tracking-tight text-[#0F172A]">
|
||||
Designed for three synchronized roles
|
||||
</h2>
|
||||
<p className="text-base text-[#71717A]">
|
||||
Whether you are ordering a meal, managing a kitchen pass, or navigating delivery routes, EatIt provides tailored operational interfaces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-10 rounded-xl overflow-hidden border border-[#E2E1DA] max-h-64 shadow-sm">
|
||||
<img
|
||||
src="/assets/landing-02-role-pathways.png"
|
||||
alt="Role pathway visual cards showing kitchen and delivery operational context for EatIt"
|
||||
className="w-full h-64 object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
|
||||
<div className="rounded-xl p-6 bg-[#F8F7F4] border border-[#E2E1DA] hover:border-[#71717A] transition-all flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-lg bg-[#0F172A] text-white flex items-center justify-center mb-5 shadow-sm">
|
||||
<ShoppingBag className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[#B93812]">01 / Diners</span>
|
||||
<h3 className="font-display text-xl font-bold text-[#0F172A] mt-1 mb-3">Customer Experience</h3>
|
||||
<p className="text-sm text-[#71717A] leading-relaxed mb-4">
|
||||
Browse local kitchens, build custom orders, pay securely, and follow driver movement on a street map.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs text-[#0F172A] font-medium mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Category dish filtering & full store menus</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Live status stepper & map route tracking</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Post-delivery dual ratings for kitchen & courier</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="inline-flex items-center justify-center gap-2 w-full py-2.5 rounded-lg bg-[#FFFFFF] hover:bg-[#F0EFEA] border border-[#E2E1DA] text-[#0F172A] font-semibold text-sm transition-colors"
|
||||
>
|
||||
<span>Order as Customer</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl p-6 bg-[#F8F7F4] border border-[#E2E1DA] hover:border-[#71717A] transition-all flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-lg bg-[#0F172A] text-white flex items-center justify-center mb-5 shadow-sm">
|
||||
<ChefHat className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[#B93812]">02 / Kitchens</span>
|
||||
<h3 className="font-display text-xl font-bold text-[#0F172A] mt-1 mb-3">Kitchen Operations</h3>
|
||||
<p className="text-sm text-[#71717A] leading-relaxed mb-4">
|
||||
Manage incoming tickets, update preparation stages, control dish availability, and hand off meals.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs text-[#0F172A] font-medium mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Live 3-stage kitchen ticket kanban board</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Menu item creation & dish availability toggles</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Store profile & operating hours status</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="inline-flex items-center justify-center gap-2 w-full py-2.5 rounded-lg bg-[#FFFFFF] hover:bg-[#F0EFEA] border border-[#E2E1DA] text-[#0F172A] font-semibold text-sm transition-colors"
|
||||
>
|
||||
<span>Manage Kitchen</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl p-6 bg-[#F8F7F4] border border-[#E2E1DA] hover:border-[#71717A] transition-all flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-lg bg-[#0F172A] text-white flex items-center justify-center mb-5 shadow-sm">
|
||||
<Bike className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[#B93812]">03 / Couriers</span>
|
||||
<h3 className="font-display text-xl font-bold text-[#0F172A] mt-1 mb-3">Driver Dispatch</h3>
|
||||
<p className="text-sm text-[#71717A] leading-relaxed mb-4">
|
||||
Go online, accept single delivery jobs, share GPS location during active trips, and earn on completed deliveries.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs text-[#0F172A] font-medium mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>First-come job lock with zero claim conflicts</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Active GPS location publishing during deliveries</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803D]" />
|
||||
<span>Trip milestone buttons & delivery history</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="inline-flex items-center justify-center gap-2 w-full py-2.5 rounded-lg bg-[#FFFFFF] hover:bg-[#F0EFEA] border border-[#E2E1DA] text-[#0F172A] font-semibold text-sm transition-colors"
|
||||
>
|
||||
<span>Start Delivering</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="workflow-timeline" className="py-16 md:py-24 bg-[#F8F7F4] border-b border-[#E2E1DA]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 space-y-3">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#FFFFFF] border border-[#E2E1DA] text-xs font-semibold text-[#0F172A]">
|
||||
<Clock className="w-3.5 h-3.5 text-[#B93812]" />
|
||||
<span>Strict Order Lifecycle</span>
|
||||
</div>
|
||||
<h2 className="font-display text-3xl sm:text-4xl font-bold tracking-tight text-[#0F172A]">
|
||||
Unskippable step-by-step progress
|
||||
</h2>
|
||||
<p className="text-base text-[#71717A]">
|
||||
From initial order placement to doorstep handoff, every order moves through transparent, verifiable milestones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-12 rounded-xl overflow-hidden border border-[#E2E1DA] shadow-sm max-h-56">
|
||||
<img
|
||||
src="/assets/landing-03-workflow-timeline.png"
|
||||
alt="Illustrate the step-by-step unskippable lifecycle of an order from placement to for EatIt"
|
||||
className="w-full h-56 object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 relative">
|
||||
|
||||
|
||||
<div className="rounded-xl p-5 bg-[#FFFFFF] border border-[#E2E1DA] space-y-3 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="w-8 h-8 rounded-full bg-[#B93812] text-white font-bold text-sm flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold uppercase bg-[#F0EFEA] text-[#0F172A]">
|
||||
Placed
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-display text-lg font-bold text-[#0F172A]">Order Placement</h4>
|
||||
<p className="text-xs text-[#71717A] leading-relaxed">
|
||||
Customer selects items from a store menu, reviews subtotal and delivery fee, and places order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl p-5 bg-[#FFFFFF] border border-[#E2E1DA] space-y-3 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="w-8 h-8 rounded-full bg-[#0F172A] text-white font-bold text-sm flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold uppercase bg-[#F0EFEA] text-[#0F172A]">
|
||||
Preparing
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-display text-lg font-bold text-[#0F172A]">Kitchen Accept & Prep</h4>
|
||||
<p className="text-xs text-[#71717A] leading-relaxed">
|
||||
Restaurant accepts incoming ticket, updates stage to Preparing, and begins cooking the meal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl p-5 bg-[#FFFFFF] border border-[#E2E1DA] space-y-3 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="w-8 h-8 rounded-full bg-[#0F172A] text-white font-bold text-sm flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold uppercase bg-[#F0EFEA] text-[#0F172A]">
|
||||
Ready / Picked Up
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-display text-lg font-bold text-[#0F172A]">Courier Job Lock & Pickup</h4>
|
||||
<p className="text-xs text-[#71717A] leading-relaxed">
|
||||
Online driver claims the open job, arrives at restaurant, collects food bag, and sets status to Picked Up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl p-5 bg-[#FFFFFF] border border-[#E2E1DA] space-y-3 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="w-8 h-8 rounded-full bg-[#15803D] text-white font-bold text-sm flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold uppercase bg-[#15803D]/10 text-[#15803D]">
|
||||
Delivered
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-display text-lg font-bold text-[#0F172A]">Live Delivery & Feedback</h4>
|
||||
<p className="text-xs text-[#71717A] leading-relaxed">
|
||||
Customer tracks driver along street route, receives meal at doorstep, and submits dual 5-star ratings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="auth-callout" className="py-16 md:py-24 bg-[#16181B] text-[#FFFFFF] relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-center">
|
||||
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-[#FFFFFF]/10 border border-[#FFFFFF]/20 text-xs font-semibold text-white">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-[#B93812]" />
|
||||
<span>M-Auth Authenticated Gateway</span>
|
||||
</div>
|
||||
<h2 className="font-display text-3xl sm:text-4xl lg:text-5xl font-extrabold tracking-tight text-white leading-tight">
|
||||
Ready to order, cook, or deliver?
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-[#E2E8F0]/80 max-w-xl font-normal leading-relaxed">
|
||||
Sign in with M-Auth to enter your workspace and select your role as Customer, Restaurant Owner, or Delivery Partner.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 flex flex-col items-start lg:items-end space-y-4">
|
||||
<div className="w-full sm:w-auto rounded-2xl overflow-hidden border border-[#FFFFFF]/10 mb-4 max-h-40">
|
||||
<img
|
||||
src="/assets/landing-04-auth-callout.png"
|
||||
alt="Provide final conversion gateway encouraging users to sign in and choose their for EatIt"
|
||||
className="w-full h-40 object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp%2Fonboarding"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-3 px-8 py-4 rounded-xl bg-[#B93812] hover:bg-[#a02f0e] text-white font-bold text-base transition-all shadow-lg hover:shadow-xl active:scale-[0.98]"
|
||||
>
|
||||
<span>Continue with M-Auth</span>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<p className="text-xs text-[#E2E8F0]/60">
|
||||
Single sign-in opens role onboarding & active workspaces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<footer className="bg-[#0F172A] text-[#E2E8F0] border-t border-[#16181B] py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6 pb-8 border-b border-[#16181B]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#B93812] flex items-center justify-center text-white">
|
||||
<Utensils className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-display text-lg font-bold text-white block leading-none">EatIt</span>
|
||||
<span className="text-[10px] text-[#71717A]">BiteRoute Marketplace</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 text-xs text-[#71717A]">
|
||||
<span>Customer Discovery</span>
|
||||
<span>Kitchen Ticket Management</span>
|
||||
<span>Courier Dispatch Navigation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 flex flex-col sm:flex-row items-center justify-between text-xs text-[#71717A] gap-4">
|
||||
<p>© {new Date().getFullYear()} EatIt Food Marketplace. All rights reserved.</p>
|
||||
<p className="text-[11px]">Savor & Dispatch Visual System • Powered by M-Auth</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
frontend/src/pages/Onboarding.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api } from '../api';
|
||||
import { AlertCircle, ArrowRight, Bike, Check, CheckCircle2, ChefHat, ShieldCheck, Sparkles, Utensils } from 'lucide-react';
|
||||
|
||||
type RoleType = 'customer' | 'restaurant' | 'delivery_agent';
|
||||
|
||||
interface RoleOption {
|
||||
id: RoleType;
|
||||
title: string;
|
||||
badge: string;
|
||||
subtitle: string;
|
||||
icon: React.ElementType;
|
||||
capabilities: string[];
|
||||
destination: string;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: RoleOption[] = [
|
||||
{
|
||||
id: 'customer',
|
||||
title: 'Diner & Customer',
|
||||
badge: 'Hunger & Dining',
|
||||
subtitle: 'Browse local kitchen menus, place custom orders, pay securely, and track delivery drivers on a live road map.',
|
||||
icon: Utensils,
|
||||
capabilities: [
|
||||
'Browse nearby restaurant menus & categories',
|
||||
'Real-time order progress & live road map tracking',
|
||||
'Post-delivery ratings & feedback'
|
||||
],
|
||||
destination: '/app'
|
||||
},
|
||||
{
|
||||
id: 'restaurant',
|
||||
title: 'Kitchen & Restaurant Owner',
|
||||
badge: 'Store Operations',
|
||||
subtitle: 'Manage store details, control menu availability, receive live kitchen ticket alerts, and update dish prep stages.',
|
||||
icon: ChefHat,
|
||||
capabilities: [
|
||||
'Live kitchen Kanban order ticket board',
|
||||
'Menu management & dish availability switches',
|
||||
'Store open/closed operational control'
|
||||
],
|
||||
destination: '/app/restaurant'
|
||||
},
|
||||
{
|
||||
id: 'delivery_agent',
|
||||
title: 'Courier & Delivery Driver',
|
||||
badge: 'Logistics & Dispatch',
|
||||
subtitle: 'Switch availability online, claim open delivery requests in your area, and share GPS location during active trips.',
|
||||
icon: Bike,
|
||||
capabilities: [
|
||||
'Online / Offline driver status toggle',
|
||||
'First-come job queue map & pickup dispatch',
|
||||
'Turn-by-turn route tracking & earnings record'
|
||||
],
|
||||
destination: '/app/delivery'
|
||||
}
|
||||
];
|
||||
|
||||
export default function Onboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { user, updateUser, isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<RoleType>('customer');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Sync initial selection if user already has a role
|
||||
useEffect(() => {
|
||||
if (user?.role && (user.role === 'customer' || user.role === 'restaurant' || user.role === 'delivery_agent')) {
|
||||
setSelectedRole(user.role as RoleType);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleConfirmRole = async () => {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await api.patch('/api/users/me/role', { role: selectedRole });
|
||||
if (res.data?.success && res.data?.user) {
|
||||
updateUser(res.data.user);
|
||||
setSuccessMessage('Role updated successfully! Navigating to your workspace...');
|
||||
|
||||
const targetPath =
|
||||
selectedRole === 'customer'
|
||||
? '/app'
|
||||
: selectedRole === 'restaurant'
|
||||
? '/app/restaurant'
|
||||
: '/app/delivery';
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(targetPath);
|
||||
}, 600);
|
||||
} else {
|
||||
setErrorMessage('Failed to update role. Please try again.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.msg || err?.message || 'Failed to update user role';
|
||||
setErrorMessage(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center p-6 bg-[#F8F7F4]">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="w-10 h-10 border-4 border-[#0F172A] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm font-medium text-slate-600">Checking user profile and role status...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-[#F8F7F4] py-10 px-4 sm:px-6 lg:px-8 flex flex-col justify-center">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-8">
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-orange-100 text-[#B93812] text-xs font-semibold tracking-wide uppercase">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Marketplace Onboarding</span>
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-[#0F172A] font-['Outfit',sans-serif]">
|
||||
Choose Your Role on EatIt
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-slate-600 max-w-2xl mx-auto font-['Plus_Jakarta_Sans',sans-serif]">
|
||||
Select how you want to interact with our live food delivery marketplace. You can update your selection anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{user?.role && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-start gap-3 text-amber-900 text-sm">
|
||||
<ShieldCheck className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 sm:flex sm:items-center sm:justify-between gap-4">
|
||||
<span>
|
||||
You are currently set as <strong className="capitalize">{user.role.replace('_', ' ')}</strong>. Select another role below to switch workspaces.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const path = user.role === 'customer' ? '/app' : user.role === 'restaurant' ? '/app/restaurant' : '/app/delivery';
|
||||
navigate(path);
|
||||
}}
|
||||
className="mt-2 sm:mt-0 text-xs font-semibold underline hover:text-amber-800 text-nowrap"
|
||||
>
|
||||
Go to Current Workspace →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{errorMessage && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl p-4 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl p-4 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5" id="role-selection-grid">
|
||||
{ROLE_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedRole === option.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => setSelectedRole(option.id)}
|
||||
className={`relative rounded-2xl p-6 transition-all duration-200 cursor-pointer flex flex-col justify-between border ${' '}
|
||||
${isSelected
|
||||
? 'bg-white border-[#B93812] shadow-md ring-2 ring-[#B93812]/20'
|
||||
: 'bg-white/80 hover:bg-white border-[#E2E1DA] hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-4 right-4 bg-[#B93812] text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-xl ${isSelected ? 'bg-orange-50 text-[#B93812]' : 'bg-slate-100 text-slate-700'}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 block">
|
||||
{option.badge}
|
||||
</span>
|
||||
<h2 className="text-lg font-bold text-[#0F172A] font-['Outfit',sans-serif]">
|
||||
{option.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600 leading-relaxed font-['Plus_Jakarta_Sans',sans-serif]">
|
||||
{option.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="pt-2 border-t border-slate-100 space-y-2">
|
||||
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
|
||||
Key Capabilities:
|
||||
</span>
|
||||
<ul className="space-y-1.5">
|
||||
{option.capabilities.map((cap, idx) => (
|
||||
<li key={idx} className="text-xs text-slate-700 flex items-start gap-2">
|
||||
<span className="text-[#B93812] font-bold mt-0.5">•</span>
|
||||
<span>{cap}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-100 flex items-center justify-between text-xs font-semibold text-slate-700">
|
||||
<span>Workspace: {option.destination}</span>
|
||||
<div className={`w-3 h-3 rounded-full border ${isSelected ? 'border-[#B93812] bg-[#B93812]' : 'border-slate-300'}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Role Graphic Asset Banner */}
|
||||
<div className="bg-white rounded-2xl border border-[#E2E1DA] p-4 flex items-center gap-4 text-xs text-slate-600">
|
||||
<img
|
||||
src="/assets/application-01-role-selection-grid.png"
|
||||
alt="Role selection visual options"
|
||||
className="w-16 h-16 object-cover rounded-xl border border-slate-200 shrink-0 hidden sm:block"
|
||||
/>
|
||||
<p className="flex-1 font-['Plus_Jakarta_Sans',sans-serif]">
|
||||
Each marketplace role unlocks a customized operational dashboard. Customers enjoy streamlined discovery and tracking, kitchens receive continuous ticket updates, and couriers access dispatch routes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white p-5 rounded-2xl border border-[#E2E1DA] shadow-sm">
|
||||
<div className="text-center sm:text-left">
|
||||
<p className="text-sm font-semibold text-[#0F172A]">
|
||||
Selected Role: <span className="text-[#B93812] capitalize">{selectedRole.replace('_', ' ')}</span>
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
You will be redirected to the matching workspace immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmRole}
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-[#B93812] hover:bg-[#a0300f] text-white font-semibold text-sm rounded-xl transition-all shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Updating Role...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Enter {selectedRole === 'customer' ? 'Customer App' : selectedRole === 'restaurant' ? 'Kitchen Portal' : 'Driver Dashboard'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
497
frontend/src/pages/OrderTracking.tsx
Normal file
@ -0,0 +1,497 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import MapView, { MapMarker } from '../components/MapView';
|
||||
import { useLiveTrip } from '../hooks/useLiveTrip';
|
||||
import { StarRating } from '../components/StarRating';
|
||||
import { AlertCircle, CheckCircle2, ChevronRight, Clock, MapPin, Package, RefreshCw, ShoppingBag, Star, Truck, Utensils, X, XCircle } from 'lucide-react';
|
||||
|
||||
type OrderStatus = 'placed' | 'accepted' | 'preparing' | 'ready_for_pickup' | 'assigned' | 'picked_up' | 'delivered' | 'cancelled';
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
special_instructions?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
customer_email: string;
|
||||
restaurant_id: string;
|
||||
restaurant_name?: string;
|
||||
delivery_agent_email?: string;
|
||||
delivery_address: string;
|
||||
delivery_latitude: number;
|
||||
delivery_longitude: number;
|
||||
subtotal: number;
|
||||
delivery_fee: number;
|
||||
total_amount: number;
|
||||
status: OrderStatus;
|
||||
created_at: string;
|
||||
items?: OrderItem[];
|
||||
restaurant_latitude?: number;
|
||||
restaurant_longitude?: number;
|
||||
}
|
||||
|
||||
const STAGES: { key: OrderStatus; label: string; icon: typeof Clock }[] = [
|
||||
{ key: 'placed', label: 'Placed', icon: Clock },
|
||||
{ key: 'accepted', label: 'Accepted', icon: CheckCircle2 },
|
||||
{ key: 'preparing', label: 'Preparing', icon: Utensils },
|
||||
{ key: 'ready_for_pickup', label: 'Ready', icon: Package },
|
||||
{ key: 'picked_up', label: 'Out for Delivery', icon: Truck },
|
||||
{ key: 'delivered', label: 'Delivered', icon: CheckCircle2 },
|
||||
];
|
||||
|
||||
const getStageIndex = (status: OrderStatus): number => {
|
||||
switch (status) {
|
||||
case 'placed': return 0;
|
||||
case 'accepted': return 1;
|
||||
case 'preparing': return 2;
|
||||
case 'ready_for_pickup': return 3;
|
||||
case 'assigned': return 3;
|
||||
case 'picked_up': return 4;
|
||||
case 'delivered': return 5;
|
||||
case 'cancelled': return -1;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrderTrackingPage() {
|
||||
const { id: urlOrderId } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [ratingModalOrderId, setRatingModalOrderId] = useState<string | null>(null);
|
||||
const [restaurantRating, setRestaurantRating] = useState(5);
|
||||
const [driverRating, setDriverRating] = useState(5);
|
||||
const [ratingComment, setRatingComment] = useState('');
|
||||
const [ratingSuccessMsg, setRatingSuccessMsg] = useState('');
|
||||
|
||||
// 1. Fetch user orders
|
||||
const { data: ordersData, isLoading: isLoadingOrders, refetch: refetchOrders } = useQuery({
|
||||
queryKey: ['orders', 'my'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/orders/my');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const orders: Order[] = Array.isArray(ordersData?.orders) ? ordersData.orders : [];
|
||||
const selectedOrderId = urlOrderId || (orders.length > 0 ? orders[0].id : null);
|
||||
|
||||
// 2. Fetch specific selected order with items & location coordinates
|
||||
const { data: selectedOrderData, isLoading: isLoadingSingle } = useQuery({
|
||||
queryKey: ['order', selectedOrderId],
|
||||
queryFn: async () => {
|
||||
if (!selectedOrderId) return null;
|
||||
const res = await api.get(`/api/orders/${selectedOrderId}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!selectedOrderId,
|
||||
refetchInterval: 4000,
|
||||
});
|
||||
|
||||
const activeOrder: Order | null = selectedOrderData?.order || orders.find(o => o.id === selectedOrderId) || null;
|
||||
|
||||
// 3. Live route/position hook for active order
|
||||
const { position, polylineCoordinates } = useLiveTrip({
|
||||
orderId: selectedOrderId || '',
|
||||
enabled: !!selectedOrderId && activeOrder?.status !== 'delivered' && activeOrder?.status !== 'cancelled',
|
||||
isDriver: false,
|
||||
pollInterval: 5000,
|
||||
});
|
||||
|
||||
// 4. Cancel Order Mutation
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async (orderId: string) => {
|
||||
const res = await api.patch(`/api/orders/${orderId}/cancel`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['order', selectedOrderId] });
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Rating Mutation
|
||||
const ratingMutation = useMutation({
|
||||
mutationFn: async ({ orderId, payload }: { orderId: string; payload: any }) => {
|
||||
const res = await api.post(`/api/orders/${orderId}/rating`, payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setRatingSuccessMsg('Thank you for your rating and review!');
|
||||
setTimeout(() => {
|
||||
setRatingModalOrderId(null);
|
||||
setRatingSuccessMsg('');
|
||||
setRatingComment('');
|
||||
}, 1500);
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-open rating modal if freshly delivered order
|
||||
useEffect(() => {
|
||||
if (activeOrder && activeOrder.status === 'delivered' && !ratingModalOrderId) {
|
||||
// check if already rated in local storage to prevent annoyance
|
||||
const ratedKey = `rated_${activeOrder.id}`;
|
||||
if (!localStorage.getItem(ratedKey)) {
|
||||
setRatingModalOrderId(activeOrder.id);
|
||||
localStorage.setItem(ratedKey, 'true');
|
||||
}
|
||||
}
|
||||
}, [activeOrder?.status, activeOrder?.id]);
|
||||
|
||||
// Prepare Map Markers
|
||||
const mapMarkers: MapMarker[] = [];
|
||||
if (activeOrder) {
|
||||
if (activeOrder.restaurant_latitude && activeOrder.restaurant_longitude) {
|
||||
mapMarkers.push({
|
||||
id: 'restaurant',
|
||||
lat: Number(activeOrder.restaurant_latitude),
|
||||
lng: Number(activeOrder.restaurant_longitude),
|
||||
label: activeOrder.restaurant_name || 'Restaurant',
|
||||
type: 'restaurant',
|
||||
});
|
||||
}
|
||||
if (activeOrder.delivery_latitude && activeOrder.delivery_longitude) {
|
||||
mapMarkers.push({
|
||||
id: 'customer',
|
||||
lat: Number(activeOrder.delivery_latitude),
|
||||
lng: Number(activeOrder.delivery_longitude),
|
||||
label: 'Delivery Drop-off',
|
||||
type: 'customer',
|
||||
});
|
||||
}
|
||||
if (position && position.latitude && position.longitude && !position.expired) {
|
||||
mapMarkers.push({
|
||||
id: 'driver',
|
||||
lat: Number(position.latitude),
|
||||
lng: Number(position.longitude),
|
||||
label: 'Delivery Partner',
|
||||
type: 'driver',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const centerLat = position?.latitude || activeOrder?.delivery_latitude || activeOrder?.restaurant_latitude || 37.7749;
|
||||
const centerLng = position?.longitude || activeOrder?.delivery_longitude || activeOrder?.restaurant_longitude || -122.4194;
|
||||
|
||||
const currentStageIndex = activeOrder ? getStageIndex(activeOrder.status) : 0;
|
||||
const canCancel = activeOrder && (activeOrder.status === 'placed' || activeOrder.status === 'accepted');
|
||||
|
||||
const handleRateSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ratingModalOrderId) return;
|
||||
ratingMutation.mutate({
|
||||
orderId: ratingModalOrderId,
|
||||
payload: {
|
||||
restaurant_rating: restaurantRating,
|
||||
driver_rating: driverRating,
|
||||
comment: String((ratingComment) ?? '').trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-[#F8F7F4] font-['Plus_Jakarta_Sans',sans-serif] text-[#0F172A] py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-[#E2E1DA] pb-4">
|
||||
<div>
|
||||
<h1 className="font-['Outfit',sans-serif] text-2xl sm:text-3xl font-bold text-[#0F172A]">
|
||||
Order Tracking & Telemetry
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Follow real-time meal preparation and live courier street routing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetchOrders()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-xs font-semibold rounded-lg bg-white border border-[#E2E1DA] text-slate-700 hover:bg-[#F0EFEA] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
<Link
|
||||
to="/app"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-xs font-semibold rounded-lg bg-[#0F172A] text-white hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" /> Order Food
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingOrders ? (
|
||||
<div className="bg-white rounded-xl p-12 text-center border border-[#E2E1DA] shadow-sm">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-[#B93812] mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-600">Retrieving your active order details and live location...</p>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="bg-white rounded-xl p-12 text-center border border-[#E2E1DA] shadow-sm max-w-md mx-auto">
|
||||
<div className="w-12 h-12 rounded-full bg-[#F0EFEA] flex items-center justify-center mx-auto mb-4 text-slate-500">
|
||||
<Package className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="font-['Outfit',sans-serif] text-lg font-bold text-[#0F172A] mb-1">No Active Orders</h2>
|
||||
<p className="text-sm text-slate-600 mb-6">You have no active or previous orders. Browse open local kitchens and place your first order!</p>
|
||||
<Link
|
||||
to="/app"
|
||||
className="inline-flex items-center justify-center px-5 py-2.5 rounded-lg bg-[#B93812] text-white font-medium text-sm hover:bg-[#a12f0e] transition-colors shadow-sm"
|
||||
>
|
||||
Browse Restaurants
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E2E1DA] p-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-2 mb-2">Your Orders</p>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
|
||||
{orders.map((o) => {
|
||||
const isSelected = o.id === selectedOrderId;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
onClick={() => navigate(`/app/orders/${o.id}`)}
|
||||
className={`w-full text-left p-3 rounded-lg border text-xs transition-all flex items-center justify-between ${
|
||||
isSelected
|
||||
? 'border-[#B93812] bg-[#F8F7F4] font-medium'
|
||||
: 'border-transparent hover:bg-[#F0EFEA] text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-slate-500">#{o.id.slice(0, 8)}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold uppercase ${'bg-[#E2E8F0] text-slate-800'}`}>
|
||||
{o.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-semibold text-slate-900 truncate">{o.restaurant_name || 'Restaurant'}</p>
|
||||
<p className="text-slate-500 text-[11px]">
|
||||
${Number(o.total_amount || 0).toFixed(2)} • {new Date(o.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{activeOrder && (
|
||||
<div className="bg-white rounded-xl border border-[#E2E1DA] shadow-sm p-5 space-y-5">
|
||||
<div className="flex items-start justify-between border-b border-[#E2E1DA] pb-4">
|
||||
<div>
|
||||
<span className="text-xs text-slate-500 font-mono">Order #{activeOrder.id.slice(0, 8)}</span>
|
||||
<h2 className="font-['Outfit',sans-serif] text-xl font-bold text-[#0F172A]">
|
||||
{activeOrder.restaurant_name || 'Kitchen Prep'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-600 flex items-center gap-1 mt-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#B93812] shrink-0" />
|
||||
{activeOrder.delivery_address}
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-semibold uppercase bg-[#F0EFEA] text-[#0F172A] border border-[#E2E1DA]">
|
||||
{activeOrder.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{activeOrder.status === 'cancelled' ? (
|
||||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 text-red-800 text-xs flex items-center gap-3">
|
||||
<XCircle className="w-5 h-5 shrink-0 text-red-600" />
|
||||
<div>
|
||||
<p className="font-semibold">Order Cancelled</p>
|
||||
<p className="text-red-600 mt-0.5">This order was cancelled and will not be prepared.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">Preparation Stage</p>
|
||||
<div className="space-y-2">
|
||||
{STAGES.map((stg, idx) => {
|
||||
const isPassed = idx <= currentStageIndex;
|
||||
const isCurrent = idx === currentStageIndex;
|
||||
const Icon = stg.icon;
|
||||
return (
|
||||
<div key={stg.key} className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${
|
||||
isCurrent
|
||||
? 'bg-[#B93812] text-white shadow-sm ring-2 ring-[#B93812]/20'
|
||||
: isPassed
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-[#F0EFEA] text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isPassed ? <Icon className="w-3.5 h-3.5" /> : idx + 1}
|
||||
</div>
|
||||
<div className="flex-1 text-xs">
|
||||
<p className={`font-semibold ${isCurrent ? 'text-[#B93812]' : isPassed ? 'text-slate-900' : 'text-slate-400'}`}>
|
||||
{stg.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items List */}
|
||||
<div className="space-y-2 border-t border-[#E2E1DA] pt-4 text-xs">
|
||||
<p className="font-semibold uppercase tracking-wider text-slate-500">Order Summary</p>
|
||||
{Array.isArray(activeOrder.items) && activeOrder.items.length > 0 ? (
|
||||
<div className="space-y-1.5 divide-y divide-[#E2E1DA]">
|
||||
{activeOrder.items.map((it) => (
|
||||
<div key={it.id} className="flex justify-between pt-1.5 text-slate-700">
|
||||
<span>{it.quantity}x {it.item_name}</span>
|
||||
<span className="font-mono font-medium">${(Number(it.unit_price) * it.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 italic">Items summary loaded with telemetry.</p>
|
||||
)}
|
||||
<div className="border-t border-[#E2E1DA] pt-2 space-y-1 font-mono text-slate-600">
|
||||
<div className="flex justify-between"><span>Subtotal</span><span>${Number(activeOrder.subtotal || 0).toFixed(2)}</span></div>
|
||||
<div className="flex justify-between"><span>Delivery Fee</span><span>${Number(activeOrder.delivery_fee || 5.0).toFixed(2)}</span></div>
|
||||
<div className="flex justify-between text-slate-900 font-bold text-sm pt-1 border-t border-[#E2E1DA]">
|
||||
<span>Total</span>
|
||||
<span>${Number(activeOrder.total_amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={() => cancelMutation.mutate(activeOrder.id)}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="w-full py-2.5 rounded-lg border border-red-300 text-red-700 bg-red-50 hover:bg-red-100 text-xs font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelMutation.isPending ? 'Cancelling...' : 'Cancel Order'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeOrder.status === 'delivered' && (
|
||||
<button
|
||||
onClick={() => setRatingModalOrderId(activeOrder.id)}
|
||||
className="w-full py-2.5 rounded-lg bg-[#B93812] text-white hover:bg-[#a12f0e] text-xs font-semibold transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Star className="w-4 h-4 fill-white" /> Leave Restaurant & Courier Rating
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="lg:col-span-7 bg-white rounded-xl border border-[#E2E1DA] shadow-sm p-4 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#E2E1DA] pb-3">
|
||||
<div>
|
||||
<h3 className="font-['Outfit',sans-serif] text-base font-bold text-[#0F172A]">Live Route Navigation</h3>
|
||||
<p className="text-xs text-slate-500">Real-time courier telemetry polled automatically</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> GPS Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<MapView
|
||||
center={[centerLat, centerLng]}
|
||||
zoom={13}
|
||||
markers={mapMarkers}
|
||||
polyline={polylineCoordinates}
|
||||
className="h-[480px] w-full rounded-lg border border-[#E2E1DA] overflow-hidden"
|
||||
autoBounds={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{ratingModalOrderId && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl border border-[#E2E1DA] max-w-md w-full p-6 shadow-xl relative space-y-4 font-['Plus_Jakarta_Sans',sans-serif]">
|
||||
<button
|
||||
onClick={() => setRatingModalOrderId(null)}
|
||||
className="absolute top-4 right-4 p-1 rounded-full hover:bg-slate-100 text-slate-500 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h3 className="font-['Outfit',sans-serif] text-xl font-bold text-[#0F172A]">Rate Meal & Delivery</h3>
|
||||
<p className="text-xs text-slate-600 mt-0.5">Your feedback helps local kitchens and couriers improve quality.</p>
|
||||
</div>
|
||||
|
||||
{ratingSuccessMsg ? (
|
||||
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs text-center font-medium">
|
||||
{ratingSuccessMsg}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleRateSubmit} className="space-y-4 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-semibold text-slate-700 block">Restaurant Rating</label>
|
||||
<StarRating
|
||||
value={restaurantRating}
|
||||
onChange={setRestaurantRating}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-semibold text-slate-700 block">Courier Delivery Rating</label>
|
||||
<StarRating
|
||||
value={driverRating}
|
||||
onChange={setDriverRating}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-semibold text-slate-700 block">Comments & Feedback</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={ratingComment}
|
||||
onChange={(e) => setRatingComment(e.target.value)}
|
||||
placeholder="How was the food temperature, packaging, and speed?"
|
||||
className="w-full rounded-lg border border-[#E2E1DA] p-2.5 text-xs text-slate-900 focus:outline-none focus:border-[#B93812]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ratingMutation.isError && (
|
||||
<div className="p-2.5 rounded bg-red-50 text-red-700 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>Failed to submit rating. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={ratingMutation.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-[#B93812] text-white font-semibold hover:bg-[#a12f0e] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{ratingMutation.isPending ? 'Submitting...' : 'Submit Rating'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
frontend/src/pages/RestaurantWorkspace.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import KitchenOrderBoard from '../components/restaurant/KitchenOrderBoard';
|
||||
import MenuManager from '../components/restaurant/MenuManager';
|
||||
import RestaurantProfileForm from '../components/restaurant/RestaurantProfileForm';
|
||||
import { ArrowRight, Building2, ChefHat, Power, Settings, ShieldAlert, Sparkles, Store, UtensilsCrossed } from 'lucide-react';
|
||||
|
||||
export default function RestaurantWorkspace() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<'tickets' | 'menu' | 'profile'>('tickets');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['my-restaurant'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/restaurants/me');
|
||||
return res.data;
|
||||
},
|
||||
enabled: user?.role === 'restaurant',
|
||||
});
|
||||
|
||||
const restaurant = data?.restaurant;
|
||||
|
||||
const toggleStatusMutation = useMutation({
|
||||
mutationFn: async (newStatus: 'active' | 'closed') => {
|
||||
if (!restaurant?.id) return;
|
||||
const res = await api.patch(`/api/restaurants/${restaurant.id}`, {
|
||||
status: newStatus,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['my-restaurant'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.role !== 'restaurant') {
|
||||
return (
|
||||
<div className="min-h-[80vh] bg-[#F8F7F4] flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white p-8 rounded-2xl border border-[#E2E1DA] text-center shadow-sm space-y-4">
|
||||
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<ShieldAlert className="w-8 h-8" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#0F172A] font-['Outfit']">Kitchen Role Required</h2>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Your current profile is set to <span className="font-semibold text-[#0F172A]">{user?.role || 'unassigned'}</span>. Switch your marketplace role to <span className="font-semibold text-[#B93812]">Restaurant Owner</span> to manage kitchen tickets and menu items.
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/app/onboarding"
|
||||
className="inline-flex items-center justify-center gap-2 w-full py-3 px-6 bg-[#0F172A] text-white font-semibold rounded-xl hover:bg-slate-800 transition-colors text-sm"
|
||||
>
|
||||
Switch Role in Onboarding <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F7F4] pb-16">
|
||||
<div className="bg-[#0F172A] text-white border-b border-slate-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#B93812] flex items-center justify-center text-white shrink-0">
|
||||
<Store className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl md:text-2xl font-bold font-['Outfit']">
|
||||
{restaurant ? restaurant.name : 'Kitchen Management Studio'}
|
||||
</h1>
|
||||
{restaurant && (
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${restaurant.status === 'active' ? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40' : 'bg-red-500/20 text-red-300 border-red-500/40'}`}>
|
||||
{restaurant.status === 'active' ? 'Kitchen Open' : 'Kitchen Closed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 mt-0.5">
|
||||
{restaurant?.address || 'Set up your store profile to begin taking live orders'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{restaurant && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => toggleStatusMutation.mutate(restaurant.status === 'active' ? 'closed' : 'active')}
|
||||
disabled={toggleStatusMutation.isPending}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
restaurant.status === 'active'
|
||||
? 'bg-red-600/90 hover:bg-red-600 text-white'
|
||||
: 'bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
{restaurant.status === 'active' ? 'Close Restaurant' : 'Open Restaurant'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-6 border-t border-slate-800 pt-4 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('tickets')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
activeTab === 'tickets'
|
||||
? 'bg-[#B93812] text-white shadow-xs'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<ChefHat className="w-4 h-4" />
|
||||
Kitchen Ticket Board
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('menu')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
activeTab === 'menu'
|
||||
? 'bg-[#B93812] text-white shadow-xs'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<UtensilsCrossed className="w-4 h-4" />
|
||||
Menu Manager
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
activeTab === 'profile'
|
||||
? 'bg-[#B93812] text-white shadow-xs'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Store Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-8">
|
||||
{!restaurant && activeTab !== 'profile' ? (
|
||||
<div className="bg-white rounded-2xl border border-[#E2E1DA] p-8 text-center max-w-xl mx-auto my-8 space-y-4">
|
||||
<div className="w-14 h-14 bg-[#F0EFEA] text-[#0F172A] rounded-2xl flex items-center justify-center mx-auto">
|
||||
<Building2 className="w-7 h-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[#0F172A] font-['Outfit']">Welcome to EatIt Kitchen Studio</h3>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">
|
||||
Please register your store details, restaurant address, and operating hours before managing your menu and accepting tickets.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#B93812] text-white text-xs font-semibold rounded-xl hover:bg-red-800 transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Set Up Store Profile Now
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{activeTab === 'tickets' && <KitchenOrderBoard />}
|
||||
{activeTab === 'menu' && restaurant && <MenuManager restaurantId={restaurant.id} />}
|
||||
{activeTab === 'profile' && (
|
||||
<RestaurantProfileForm
|
||||
onRestaurantUpdated={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['my-restaurant'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
frontend/src/pages/RoleOnboarding.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api } from '../api';
|
||||
import { AlertCircle, ArrowRight, Bike, Check, ChefHat, Loader2, ShieldCheck, Sparkles, Utensils } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
type UserRole = 'customer' | 'restaurant' | 'delivery_agent';
|
||||
|
||||
interface RoleOption {
|
||||
id: UserRole;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
redirectPath: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: RoleOption[] = [
|
||||
{
|
||||
id: 'customer',
|
||||
title: 'Diner / Customer',
|
||||
subtitle: 'Browse & Order Food',
|
||||
badge: 'Hunger Solved',
|
||||
description: 'Explore local kitchens, customize dishes, track live road couriers, and rate your culinary experiences.',
|
||||
icon: Utensils,
|
||||
redirectPath: '/app',
|
||||
features: [
|
||||
'Interactive restaurant menus & dish search',
|
||||
'Secure test payment checkout flow',
|
||||
'Real-time Leaflet street route order map',
|
||||
'Post-delivery kitchen & courier ratings'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'restaurant',
|
||||
title: 'Kitchen Operator',
|
||||
subtitle: 'Manage Store & Orders',
|
||||
badge: 'Culinary Master',
|
||||
description: 'Set up your restaurant profile, manage menu dishes, set item availability, and control kitchen prep tickets.',
|
||||
icon: ChefHat,
|
||||
redirectPath: '/app/restaurant',
|
||||
features: [
|
||||
'Live kitchen order Kanban ticket board',
|
||||
'Dish menu manager with R2 photo uploads',
|
||||
'Toggle instant item & store availability',
|
||||
'Kitchen workflow step updates (Prep, Ready)'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'delivery_agent',
|
||||
title: 'Delivery Courier',
|
||||
subtitle: 'Dispatch & Earn',
|
||||
badge: 'Road Master',
|
||||
description: 'Toggle online availability, claim open pickup jobs, publish live GPS tracking, and complete drop-offs.',
|
||||
icon: Bike,
|
||||
redirectPath: '/app/delivery',
|
||||
features: [
|
||||
'Available delivery job dispatch queue',
|
||||
'First-come atomic job claim lock',
|
||||
'Live GPS location publisher during trips',
|
||||
'Trip history & completed drop-off records'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function RoleOnboardingPage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole>((user?.role as UserRole) || 'customer');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const handleConfirmRole = async () => {
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg(null);
|
||||
|
||||
try {
|
||||
const res = await api.patch('/api/users/me/role', { role: selectedRole });
|
||||
if (res.data && res.data.user) {
|
||||
updateUser(res.data.user);
|
||||
const targetOption = ROLE_OPTIONS.find((r) => r.id === selectedRole);
|
||||
const targetPath = targetOption ? targetOption.redirectPath : '/app';
|
||||
navigate(targetPath, { replace: true });
|
||||
} else {
|
||||
throw new Error('Failed to update role. Please try again.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Role update error:', err);
|
||||
const msg = err.response?.data?.msg || err.message || 'Failed to update role. Please try again.';
|
||||
setErrorMsg(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-[#F8F7F4] py-10 px-4 sm:px-6 lg:px-8 font-['Plus_Jakarta_Sans'] flex flex-col justify-center">
|
||||
<div className="max-w-5xl mx-auto w-full">
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#B93812]/10 text-[#B93812] text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Marketplace Role Setup
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-3xl sm:text-4xl font-bold font-['Outfit'] text-[#0F172A] tracking-tight"
|
||||
>
|
||||
Welcome to EatIt! Select Your Workspace
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mt-2 text-base sm:text-lg text-slate-600 max-w-2xl mx-auto"
|
||||
>
|
||||
Choose how you will participate in our live food delivery marketplace. You can always change your focus or manage permissions later.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
|
||||
{user && (
|
||||
<div className="mb-8 p-4 rounded-xl bg-white border border-[#E2E1DA] shadow-sm flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#0F172A] text-white flex items-center justify-center font-bold text-base">
|
||||
{user.full_name ? user.full_name.charAt(0).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#0F172A]">
|
||||
Signed in as <span className="text-[#B93812]">{user.full_name || user.email}</span>
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600 bg-[#F0EFEA] px-3 py-1.5 rounded-lg border border-[#E2E1DA]">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-600" />
|
||||
<span>Current Assigned Role: <strong className="uppercase text-[#0F172A]">{user.role || 'Unassigned'}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Asset Banner */}
|
||||
<div className="mb-8 rounded-2xl overflow-hidden border border-[#E2E1DA] bg-white shadow-sm max-h-48 relative flex items-center">
|
||||
<img
|
||||
src="/assets/application-01-role-selection-grid.png"
|
||||
alt="Role Selection Visual"
|
||||
className="w-full h-48 object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#0F172A]/80 via-[#0F172A]/50 to-transparent p-6 flex flex-col justify-center">
|
||||
<span className="text-white/80 text-xs font-semibold uppercase tracking-wider">One Network, Three Operators</span>
|
||||
<h2 className="text-xl sm:text-2xl font-bold font-['Outfit'] text-white">Savor & Dispatch Platform</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{errorMsg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="mb-6 p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 flex items-center gap-3 text-sm"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-600 shrink-0" />
|
||||
<div className="flex-1">{errorMsg}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
{ROLE_OPTIONS.map((role) => {
|
||||
const Icon = role.icon;
|
||||
const isSelected = selectedRole === role.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={role.id}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setSelectedRole(role.id)}
|
||||
className={`relative rounded-2xl p-6 cursor-pointer transition-all duration-200 flex flex-col justify-between border-2 ${
|
||||
isSelected
|
||||
? 'bg-white border-[#B93812] shadow-md ring-2 ring-[#B93812]/20'
|
||||
: 'bg-white border-[#E2E1DA] hover:border-slate-300 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Selection Radio / Badge */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[#B93812] text-white'
|
||||
: 'bg-[#F0EFEA] text-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium px-2.5 py-0.5 rounded-full bg-[#F0EFEA] text-slate-700">
|
||||
{role.badge}
|
||||
</span>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full border flex items-center justify-center transition-colors ${
|
||||
isSelected
|
||||
? 'border-[#B93812] bg-[#B93812] text-white'
|
||||
: 'border-slate-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold font-['Outfit'] text-[#0F172A]">{role.title}</h3>
|
||||
<p className="text-xs font-semibold text-[#B93812] uppercase tracking-wide mt-0.5 mb-2">
|
||||
{role.subtitle}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 leading-relaxed mb-4">
|
||||
{role.description}
|
||||
</p>
|
||||
|
||||
<div className="pt-3 border-t border-[#E8E7E0] space-y-2 mb-4">
|
||||
{role.features.map((feat, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-xs text-slate-700">
|
||||
<Check className="w-3.5 h-3.5 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<span>{feat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="pt-2">
|
||||
<span
|
||||
className={`block text-center text-xs font-semibold py-2 rounded-lg transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[#B93812]/10 text-[#B93812]'
|
||||
: 'bg-slate-50 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? 'Currently Selected' : 'Click to Select'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom Primary Submit Action */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 p-6 rounded-2xl bg-[#0F172A] text-white shadow-lg">
|
||||
<div>
|
||||
<h4 className="text-lg font-bold font-['Outfit']">Ready to Enter Workspace</h4>
|
||||
<p className="text-xs text-slate-300">
|
||||
Confirming will assign your account role and direct you straight to your role dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmRole}
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-3.5 rounded-xl bg-[#B93812] hover:bg-[#a02f0e] active:scale-[0.98] text-white font-semibold text-sm transition-all shadow-md disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Saving Role...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Enter Workspace</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/tailwind.config.js
Normal file
@ -0,0 +1,71 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: '#F8F7F4',
|
||||
foreground: '#000000',
|
||||
surface: {
|
||||
DEFAULT: '#FFFFFF',
|
||||
secondary: '#F0EFEA',
|
||||
tertiary: '#E5E4DD',
|
||||
inverse: '#16181B',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: '#0F172A',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: '#E2E8F0',
|
||||
foreground: '#000000',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#B93812',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: '#EEEDE8',
|
||||
foreground: '#000000',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#E2E1DA',
|
||||
strong: '#71717A',
|
||||
},
|
||||
divider: '#E8E7E0',
|
||||
destructive: {
|
||||
DEFAULT: '#B91C1C',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#15803D',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#B45309',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
info: {
|
||||
DEFAULT: '#1D4ED8',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['Outfit', 'sans-serif'],
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
body: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '0.375rem',
|
||||
md: '0.625rem',
|
||||
lg: '1rem',
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 1px 3px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
|
||||
popover: '0 10px 25px -5px rgba(15,23,42,0.08), 0 8px 10px -6px rgba(15,23,42,0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
27
frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"react-query": "@tanstack/react-query",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
|
||||
return;
|
||||
}
|
||||
warn(warning);
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||