chore: hand off generated uau39m app to repair harness

This commit is contained in:
MWorld Deployer 2026-09-28 23:38:56 +05:30
parent 296026831b
commit e08ccfaeda
77 changed files with 14268 additions and 0 deletions

4
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.wrangler
.env

26
frontend/components.json Normal file
View 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
View 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

View 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"
}
]

File diff suppressed because one or more lines are too long

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

49
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

3
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function App() {
return <main className="min-h-screen" />;
}

97
frontend/src/api.ts Normal file
View 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;

View 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>;
}

View 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">
&ldquo;{item.special_instructions}&rdquo;
</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 &amp; Place Order (${totalAmount.toFixed(2)})</span>
</>
)}
</button>
</div>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
);
}

View 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;

View 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'}
/>
);
}

View 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;

View 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 &amp; 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>
);
}

View 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;

View 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>
);
}

View 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 &amp; Place Order (${totalAmount.toFixed(2)})
</>
)}
</button>
</div>
)}
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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 &amp; Sides">Breads &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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 &amp; Place Order (${totalAmount.toFixed(2)})</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</div>
)}
</div>
);
}

View 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 &amp; Place Order (${total.toFixed(2)})
<ArrowRight className="w-4 h-4 ml-auto" />
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View 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>
);
}

View 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: &quot;{query}&quot;</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>
);
}

View 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>
);
}

View 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 &bull; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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} />;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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} />;

View 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;
};

View 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,
};
}

View 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
View 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
View 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
View 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
View 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>,
)

View 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

File diff suppressed because one or more lines are too long

View 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>
);
}

View 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 &quot;Online&quot; 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>
);
}

View 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>&copy; {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>
);
}

View 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 &rarr;
</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">&bull;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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);
},
},
},
})