fix: repair EatIt frontend routing, customer discovery page, and D1 database queries
This commit is contained in:
parent
e08ccfaeda
commit
4293906e95
2
backend/package-lock.json
generated
2
backend/package-lock.json
generated
@ -9,7 +9,7 @@
|
|||||||
"hono": "latest"
|
"hono": "latest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.0.0",
|
"@cloudflare/workers-types": "^4.20240404.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"wrangler": "^4.129.0"
|
"wrangler": "^4.129.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"wrangler": "^4.129.0",
|
"wrangler": "^4.129.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"@cloudflare/workers-types": "^4.0.0"
|
"@cloudflare/workers-types": "^4.20240404.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -169,7 +169,7 @@ async function readPosition(c: any) {
|
|||||||
if (!position && record.agentEmail) {
|
if (!position && record.agentEmail) {
|
||||||
for (const table of ['delivery_agents', 'delivery_riders']) {
|
for (const table of ['delivery_agents', 'delivery_riders']) {
|
||||||
try {
|
try {
|
||||||
const row = await c.env.DB.prepare('SELECT current_latitude, current_longitude, updated_at FROM ' + table + ' WHERE user_email = ?').bind(record.agentEmail).first<any>();
|
const row = (await c.env.DB.prepare('SELECT current_latitude, current_longitude, updated_at FROM ' + table + ' WHERE user_email = ?').bind(record.agentEmail).first()) as any;
|
||||||
const lat = finiteCoordinate(row?.current_latitude, -90, 90);
|
const lat = finiteCoordinate(row?.current_latitude, -90, 90);
|
||||||
const lng = finiteCoordinate(row?.current_longitude, -180, 180);
|
const lng = finiteCoordinate(row?.current_longitude, -180, 180);
|
||||||
const updatedAt = Date.parse(String(row?.updated_at || ''));
|
const updatedAt = Date.parse(String(row?.updated_at || ''));
|
||||||
|
|||||||
@ -14,9 +14,9 @@ function __requireRole(allowedRoles: string[]) {
|
|||||||
const email = authenticatedUser?.email;
|
const email = authenticatedUser?.email;
|
||||||
if (!email) return c.json({ success: false, msg: 'Unauthorized' }, 401);
|
if (!email) return c.json({ success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
const persistedUser = await c.env.DB.prepare('SELECT role FROM users WHERE email = ?')
|
const persistedUser = (await c.env.DB.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
.bind(email || null)
|
.bind(email || null)
|
||||||
.first<{ role?: string | null }>();
|
.first()) as { role?: string | null } | null;
|
||||||
const role = String(persistedUser?.role || '');
|
const role = String(persistedUser?.role || '');
|
||||||
if (!allowedRoles.includes(role)) {
|
if (!allowedRoles.includes(role)) {
|
||||||
return c.json({ success: false, msg: 'You do not have permission for this action' }, 403);
|
return c.json({ success: false, msg: 'You do not have permission for this action' }, 403);
|
||||||
|
|||||||
@ -117,10 +117,9 @@ export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) =>
|
|||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
|
|
||||||
await c.env.DB.prepare(`
|
await c.env.DB.prepare(`
|
||||||
INSERT INTO users (id, email, full_name, mauth_token, phone, username, citizen_id, role)
|
INSERT INTO users (email, full_name, mauth_token, phone, username, citizen_id, role)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`).bind(
|
`).bind(
|
||||||
newId,
|
|
||||||
userData.email,
|
userData.email,
|
||||||
userData.fullName,
|
userData.fullName,
|
||||||
token,
|
token,
|
||||||
@ -209,10 +208,10 @@ export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next
|
|||||||
const ensureAdminUser = async (email: string, fullName: string) => {
|
const ensureAdminUser = async (email: string, fullName: string) => {
|
||||||
try {
|
try {
|
||||||
await c.env.DB.prepare(`
|
await c.env.DB.prepare(`
|
||||||
INSERT INTO users (id, email, full_name, username, role, citizen_id)
|
INSERT INTO users (email, full_name, username, role, citizen_id)
|
||||||
VALUES (?, ?, ?, ?, 'admin', 'admin-bypass-id')
|
VALUES (?, ?, ?, 'admin', 'admin-bypass-id')
|
||||||
ON CONFLICT(email) DO NOTHING
|
ON CONFLICT(email) DO NOTHING
|
||||||
`).bind(crypto.randomUUID(), email, fullName, email.split('@')[0]).run();
|
`).bind(email, fullName, email.split('@')[0]).run();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Admin Auth] Error upserting admin user:', e);
|
console.error('[Admin Auth] Error upserting admin user:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,10 +117,9 @@ export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) =>
|
|||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
|
|
||||||
await c.env.DB.prepare(`
|
await c.env.DB.prepare(`
|
||||||
INSERT INTO users (id, email, full_name, mauth_token, phone, username, citizen_id, role)
|
INSERT INTO users (email, full_name, mauth_token, phone, username, citizen_id, role)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`).bind(
|
`).bind(
|
||||||
newId,
|
|
||||||
userData.email,
|
userData.email,
|
||||||
userData.fullName,
|
userData.fullName,
|
||||||
token,
|
token,
|
||||||
@ -209,10 +208,10 @@ export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next
|
|||||||
const ensureAdminUser = async (email: string, fullName: string) => {
|
const ensureAdminUser = async (email: string, fullName: string) => {
|
||||||
try {
|
try {
|
||||||
await c.env.DB.prepare(`
|
await c.env.DB.prepare(`
|
||||||
INSERT INTO users (id, email, full_name, username, role, citizen_id)
|
INSERT INTO users (email, full_name, username, role, citizen_id)
|
||||||
VALUES (?, ?, ?, ?, 'admin', 'admin-bypass-id')
|
VALUES (?, ?, ?, 'admin', 'admin-bypass-id')
|
||||||
ON CONFLICT(email) DO NOTHING
|
ON CONFLICT(email) DO NOTHING
|
||||||
`).bind(crypto.randomUUID(), email, fullName, email.split('@')[0]).run();
|
`).bind(email, fullName, email.split('@')[0]).run();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Admin Auth] Error upserting admin user:', e);
|
console.error('[Admin Auth] Error upserting admin user:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
66
frontend/package-lock.json
generated
66
frontend/package-lock.json
generated
@ -1043,7 +1043,7 @@
|
|||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@ -1053,14 +1053,14 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@ -1071,7 +1071,7 @@
|
|||||||
"version": "18.3.7",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
@ -1128,6 +1128,18 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/anymatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@ -2223,6 +2235,18 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@ -2328,12 +2352,12 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
@ -2733,6 +2757,18 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdirp/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "2.15.4",
|
"version": "2.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
@ -3070,18 +3106,6 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
|
||||||
"version": "4.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
|
||||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@ -3123,7 +3147,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
|
|||||||
@ -1,3 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import Landing from './pages/Landing';
|
||||||
|
import AuthPage from './pages/Auth';
|
||||||
|
import Onboarding from './pages/Onboarding';
|
||||||
|
import CustomerApp from './pages/CustomerApp';
|
||||||
|
import CustomerOrdersPage from './pages/CustomerOrders';
|
||||||
|
import OrderTracking from './pages/OrderTracking';
|
||||||
|
import RestaurantWorkspace from './pages/RestaurantWorkspace';
|
||||||
|
import DeliveryWorkspace from './pages/DeliveryWorkspace';
|
||||||
|
import AdminPage from './pages/Admin';
|
||||||
|
import AdminTokenGate from './components/AdminTokenGate';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <main className="min-h-screen" />;
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F7F4] flex flex-col font-sans text-[#0F172A]">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
|
<Route path="/app" element={<CustomerApp />} />
|
||||||
|
<Route path="/app/onboarding" element={<Onboarding />} />
|
||||||
|
<Route path="/app/orders" element={<CustomerOrdersPage />} />
|
||||||
|
<Route path="/app/orders/:orderId" element={<OrderTracking />} />
|
||||||
|
<Route path="/app/restaurant" element={<RestaurantWorkspace />} />
|
||||||
|
<Route path="/app/delivery" element={<DeliveryWorkspace />} />
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<AdminTokenGate>
|
||||||
|
<AdminPage />
|
||||||
|
</AdminTokenGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
320
frontend/src/pages/CustomerApp.tsx
Normal file
320
frontend/src/pages/CustomerApp.tsx
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api, resolveMediaUrl } from '../api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import SearchFilterPanel from '../components/features/SearchFilterPanel';
|
||||||
|
import StoreDetailModal from '../components/customer/StoreDetailModal';
|
||||||
|
import CartDrawer from '../components/customer/CartDrawer';
|
||||||
|
import { Clock, MapPin, ShoppingBag, Sparkles, Store, UtensilsCrossed } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Restaurant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
image_url?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: string;
|
||||||
|
restaurant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
ingredients?: string;
|
||||||
|
price: number;
|
||||||
|
image_url?: string;
|
||||||
|
is_available: number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomerApp() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||||
|
const [selectedSort, setSelectedSort] = useState('featured');
|
||||||
|
|
||||||
|
const [selectedRestaurantId, setSelectedRestaurantId] = useState<string | null>(null);
|
||||||
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
|
|
||||||
|
// Cart state: items and selected restaurant
|
||||||
|
const [cartItems, setCartItems] = useState<{ item: MenuItem; quantity: number }[]>([]);
|
||||||
|
const [activeCartRestaurant, setActiveCartRestaurant] = useState<{ id: string; name: string; address?: string } | null>(null);
|
||||||
|
|
||||||
|
const { data: restaurantsRes, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['restaurants', searchQuery],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/restaurants', {
|
||||||
|
params: searchQuery ? { search: searchQuery } : {},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const restaurants: Restaurant[] = Array.isArray(restaurantsRes?.restaurants) ? restaurantsRes.restaurants : [];
|
||||||
|
|
||||||
|
// Filter and sort restaurants
|
||||||
|
const filteredRestaurants = restaurants.filter((r) => {
|
||||||
|
if (selectedCategory !== 'All') {
|
||||||
|
const catLower = selectedCategory.toLowerCase();
|
||||||
|
const nameMatch = r.name.toLowerCase().includes(catLower);
|
||||||
|
const descMatch = (r.description || '').toLowerCase().includes(catLower);
|
||||||
|
if (!nameMatch && !descMatch) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
if (selectedSort === 'name_asc') return a.name.localeCompare(b.name);
|
||||||
|
if (selectedSort === 'name_desc') return b.name.localeCompare(a.name);
|
||||||
|
return 0; // featured
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddToCart = (item: MenuItem, delta: number, storeInfo?: { id: string; name: string; address?: string }) => {
|
||||||
|
setCartItems((prev) => {
|
||||||
|
// If switching restaurants, clear cart first
|
||||||
|
if (activeCartRestaurant && storeInfo && activeCartRestaurant.id !== storeInfo.id) {
|
||||||
|
if (!window.confirm(`Your cart contains items from ${activeCartRestaurant.name}. Clear cart and start a new order?`)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
setActiveCartRestaurant(storeInfo);
|
||||||
|
if (delta > 0) return [{ item, quantity: delta }];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storeInfo && !activeCartRestaurant) {
|
||||||
|
setActiveCartRestaurant(storeInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = prev.findIndex((ci) => ci.item.id === item.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const newQty = prev[existingIndex].quantity + delta;
|
||||||
|
if (newQty <= 0) {
|
||||||
|
const updated = prev.filter((_, idx) => idx !== existingIndex);
|
||||||
|
if (updated.length === 0) setActiveCartRestaurant(null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existingIndex] = { ...updated[existingIndex], quantity: newQty };
|
||||||
|
return updated;
|
||||||
|
} else if (delta > 0) {
|
||||||
|
return [...prev, { item, quantity: delta }];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCartQuantity = (itemId: string, delta: number) => {
|
||||||
|
setCartItems((prev) => {
|
||||||
|
const index = prev.findIndex((ci) => ci.item.id === itemId);
|
||||||
|
if (index >= 0) {
|
||||||
|
const newQty = prev[index].quantity + delta;
|
||||||
|
if (newQty <= 0) {
|
||||||
|
const updated = prev.filter((_, idx) => idx !== index);
|
||||||
|
if (updated.length === 0) setActiveCartRestaurant(null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[index] = { ...updated[index], quantity: newQty };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCart = () => {
|
||||||
|
setCartItems([]);
|
||||||
|
setActiveCartRestaurant(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCartCount = cartItems.reduce((sum, ci) => sum + ci.quantity, 0);
|
||||||
|
const totalCartPrice = cartItems.reduce((sum, ci) => sum + Number(ci.item.price) * ci.quantity, 0);
|
||||||
|
|
||||||
|
const selectedRestaurantForModal = restaurants.find((r) => r.id === selectedRestaurantId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] bg-[#F8F7F4] py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
|
{/* Welcome Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-2xl border border-[#E2E1DA] shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-orange-100 text-[#B93812] text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span>Customer Discovery</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-[#0F172A] font-['Outfit',sans-serif]">
|
||||||
|
Welcome back, {user?.full_name || 'Diner'}!
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-600 font-['Plus_Jakarta_Sans',sans-serif]">
|
||||||
|
Explore local kitchens, discover delicious dishes, and place your order for fast delivery.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 self-start md:self-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/app/orders')}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 bg-[#F0EFEA] hover:bg-[#E2E1DA] text-[#0F172A] text-xs font-semibold rounded-xl transition-colors border border-[#E2E1DA]"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-4 h-4 text-[#B93812]" />
|
||||||
|
<span>Track Orders</span>
|
||||||
|
</button>
|
||||||
|
{totalCartCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCartOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#B93812] hover:bg-[#962d0e] text-white text-xs font-bold rounded-xl transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-4 h-4" />
|
||||||
|
<span>Basket ({totalCartCount})</span>
|
||||||
|
<span className="bg-white/20 px-2 py-0.5 rounded-md font-mono">${totalCartPrice.toFixed(2)}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter Component */}
|
||||||
|
<SearchFilterPanel
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onCategoryChange={setSelectedCategory}
|
||||||
|
selectedSort={selectedSort}
|
||||||
|
onSortChange={setSelectedSort}
|
||||||
|
totalResults={filteredRestaurants.length}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Restaurant Grid */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-[#0F172A] font-['Outfit',sans-serif]">
|
||||||
|
{selectedCategory === 'All' ? 'Nearby Restaurants' : `${selectedCategory} Kitchens`}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-slate-500 font-medium">
|
||||||
|
Showing {filteredRestaurants.length} partner store(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-16 text-center bg-white rounded-2xl border border-[#E2E1DA]">
|
||||||
|
<div className="w-8 h-8 border-4 border-[#B93812] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-sm font-medium text-slate-600">Discovering open restaurants...</p>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="p-12 text-center bg-red-50 rounded-2xl border border-red-200 text-red-700">
|
||||||
|
<p className="font-semibold text-sm">Failed to load local restaurants.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-3 px-4 py-2 bg-[#B93812] text-white text-xs font-semibold rounded-lg hover:bg-[#962d0e]"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredRestaurants.length === 0 ? (
|
||||||
|
<div className="p-16 text-center bg-white rounded-2xl border border-[#E2E1DA] space-y-3">
|
||||||
|
<Store className="w-12 h-12 text-slate-300 mx-auto" />
|
||||||
|
<h3 className="text-base font-bold text-[#0F172A]">No restaurants match your search</h3>
|
||||||
|
<p className="text-xs text-slate-500 max-w-md mx-auto">
|
||||||
|
Try clearing your search query or choosing another category filter.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedCategory('All');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-[#0F172A] text-white text-xs font-semibold rounded-xl hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Reset Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredRestaurants.map((restaurant) => (
|
||||||
|
<div
|
||||||
|
key={restaurant.id}
|
||||||
|
className="group bg-white rounded-2xl border border-[#E2E1DA] overflow-hidden shadow-xs hover:shadow-md transition-all duration-200 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="relative h-44 bg-slate-100 overflow-hidden">
|
||||||
|
{restaurant.image_url ? (
|
||||||
|
<img
|
||||||
|
src={resolveMediaUrl(restaurant.image_url)}
|
||||||
|
alt={restaurant.name}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-slate-200 text-slate-400">
|
||||||
|
<UtensilsCrossed className="w-12 h-12 stroke-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-3 left-3 bg-white/95 backdrop-blur-xs px-2.5 py-1 rounded-full text-[11px] font-bold text-[#0F172A] shadow-xs flex items-center gap-1.5">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${restaurant.status === 'active' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
|
<span>{restaurant.status === 'active' ? 'Open Now' : 'Closed'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-2">
|
||||||
|
<h3 className="font-bold text-[#0F172A] text-lg font-['Outfit',sans-serif] group-hover:text-[#B93812] transition-colors">
|
||||||
|
{restaurant.name}
|
||||||
|
</h3>
|
||||||
|
{restaurant.description && (
|
||||||
|
<p className="text-xs text-slate-600 line-clamp-2 leading-relaxed">
|
||||||
|
{restaurant.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500 pt-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-[#B93812] shrink-0" />
|
||||||
|
<span className="truncate">{restaurant.address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 pt-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedRestaurantId(restaurant.id)}
|
||||||
|
className="w-full py-2.5 px-4 bg-[#0F172A] hover:bg-[#B93812] text-white text-xs font-semibold rounded-xl transition-colors flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-3.5 h-3.5" />
|
||||||
|
<span>View Menu & Order</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Store Detail & Menu Modal */}
|
||||||
|
{selectedRestaurantId && (
|
||||||
|
<StoreDetailModal
|
||||||
|
restaurantId={selectedRestaurantId}
|
||||||
|
onClose={() => setSelectedRestaurantId(null)}
|
||||||
|
cartItems={cartItems}
|
||||||
|
onAddToCart={(item, qty) => {
|
||||||
|
if (selectedRestaurantForModal) {
|
||||||
|
handleAddToCart(item, qty, {
|
||||||
|
id: selectedRestaurantForModal.id,
|
||||||
|
name: selectedRestaurantForModal.name,
|
||||||
|
address: selectedRestaurantForModal.address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cart Drawer */}
|
||||||
|
<CartDrawer
|
||||||
|
isOpen={isCartOpen}
|
||||||
|
onClose={() => setIsCartOpen(false)}
|
||||||
|
items={cartItems}
|
||||||
|
restaurant={activeCartRestaurant}
|
||||||
|
onUpdateQuantity={handleUpdateCartQuantity}
|
||||||
|
onClearCart={handleClearCart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user