From 4a207714b65ac8c935cef90296878d4405e5476d Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 6 Oct 2025 21:09:43 -0700 Subject: [PATCH] chore: replace pwa icons with svg sources --- .env.example | 6 +- .gitignore | 1 + README.md | 96 ++- eslint.config.js | 13 +- index.html | 7 +- mock-cms/server.js | 367 ++++++++ package.json | 4 +- public/icons/icon.svg | 16 + public/manifest.webmanifest | 17 + src/App.css | 274 ------ src/App.jsx | 59 -- src/api/auth.ts | 22 + src/api/chat.ts | 19 + src/api/client.ts | 35 + src/api/orders.ts | 34 + src/api/telemetry.ts | 6 + src/app.tsx | 67 ++ src/assets/react.svg | 1 - src/components/BottomNav.tsx | 20 + src/components/Header.tsx | 29 + src/components/ProtectedRoute.jsx | 20 - src/components/ProtectedRoute.tsx | 22 + src/components/SignaturePad.tsx | 119 +++ src/components/Tabs.tsx | 43 + src/components/TimerChip.tsx | 23 + src/components/VerifyChecklist.tsx | 32 + src/context/AuthContext.jsx | 129 --- src/context/AuthContext.tsx | 136 +++ src/hooks/queryClient.tsx | 215 +++++ src/hooks/useInterval.ts | 22 + src/hooks/useLocationStreamer.ts | 74 ++ src/hooks/useOrders.ts | 71 ++ src/hooks/usePageVisibility.ts | 15 + src/index.css | 46 - src/main.jsx | 16 - src/main.tsx | 13 + src/pages/Login.jsx | 117 --- src/pages/Settings.jsx | 206 ----- src/providers/AppProviders.tsx | 11 + src/routes/chat/ChatPage.tsx | 90 ++ src/routes/login/LoginPage.tsx | 94 ++ src/routes/orders/OrdersPage.tsx | 101 +++ src/routes/orders/components/OrderCard.tsx | 59 ++ .../orders/components/OrderDetailPage.tsx | 225 +++++ src/routes/orders/components/OrderList.tsx | 38 + src/routes/profile/ProfilePage.tsx | 81 ++ src/services/authService.js | 75 -- src/services/urlServices.js | 11 - src/styles/globals.css | 802 ++++++++++++++++++ src/types.ts | 66 ++ src/utils/time.ts | 20 + src/ws/socket.ts | 77 ++ tests/api-client.test.js | 29 + tests/build-tests.js | 14 + tests/run-tests.cjs | 39 + tests/time.test.js | 16 + tsconfig.base.json | 19 + tsconfig.json | 5 + tsconfig.node.json | 8 + vite.config.js | 7 - vite.config.ts | 31 + 61 files changed, 3352 insertions(+), 978 deletions(-) create mode 100644 mock-cms/server.js create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.webmanifest delete mode 100644 src/App.css delete mode 100644 src/App.jsx create mode 100644 src/api/auth.ts create mode 100644 src/api/chat.ts create mode 100644 src/api/client.ts create mode 100644 src/api/orders.ts create mode 100644 src/api/telemetry.ts create mode 100644 src/app.tsx delete mode 100644 src/assets/react.svg create mode 100644 src/components/BottomNav.tsx create mode 100644 src/components/Header.tsx delete mode 100644 src/components/ProtectedRoute.jsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/components/SignaturePad.tsx create mode 100644 src/components/Tabs.tsx create mode 100644 src/components/TimerChip.tsx create mode 100644 src/components/VerifyChecklist.tsx delete mode 100644 src/context/AuthContext.jsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/hooks/queryClient.tsx create mode 100644 src/hooks/useInterval.ts create mode 100644 src/hooks/useLocationStreamer.ts create mode 100644 src/hooks/useOrders.ts create mode 100644 src/hooks/usePageVisibility.ts delete mode 100644 src/index.css delete mode 100644 src/main.jsx create mode 100644 src/main.tsx delete mode 100644 src/pages/Login.jsx delete mode 100644 src/pages/Settings.jsx create mode 100644 src/providers/AppProviders.tsx create mode 100644 src/routes/chat/ChatPage.tsx create mode 100644 src/routes/login/LoginPage.tsx create mode 100644 src/routes/orders/OrdersPage.tsx create mode 100644 src/routes/orders/components/OrderCard.tsx create mode 100644 src/routes/orders/components/OrderDetailPage.tsx create mode 100644 src/routes/orders/components/OrderList.tsx create mode 100644 src/routes/profile/ProfilePage.tsx delete mode 100644 src/services/authService.js delete mode 100644 src/services/urlServices.js create mode 100644 src/styles/globals.css create mode 100644 src/types.ts create mode 100644 src/utils/time.ts create mode 100644 src/ws/socket.ts create mode 100644 tests/api-client.test.js create mode 100644 tests/build-tests.js create mode 100755 tests/run-tests.cjs create mode 100644 tests/time.test.js create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json delete mode 100644 vite.config.js create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example index b9ebf11..607951b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -# Base URL for Jason's Liquor API services -VITE_API_BASE_URL=https://api.jasonsliquor.com -# VITE_API_BASE_URL=https://staging-api.jasonsliquor.com +# Jason's CMS mock endpoints +VITE_CMS_BASE_URL=http://localhost:4310 +VITE_WS_URL=ws://localhost:4310 diff --git a/.gitignore b/.gitignore index a547bf3..9947873 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +tests/dist *.local # Editor directories and files diff --git a/README.md b/README.md index 963909f..c5361e0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,94 @@ -# React + Vite +# Jason's Driver App -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +A mobile-first web application for Jason's alcohol delivery drivers. The app is built with React (Vite) and TypeScript-style components, implements a lightweight query client for server state, and mirrors the provided high-fidelity design. Drivers can authenticate, manage orders end-to-end, chat in real time, verify compliance, capture signatures, and stream location updates. -Currently, two official plugins are available: +## Features -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- **Authentication:** Email/password sign-in with session persistence in `sessionStorage`. +- **Orders workflow:** Pending, Active, and Completed tabs with timers, priority highlighting, and order detail flows (Accept → Start → Arrive → Verify → Complete). +- **Compliance tooling:** Verification checklist, signature capture via ``, and completion guardrails. +- **Navigation helpers:** Quick links to Google Maps and Waze from each order. +- **Realtime chat:** WebSocket broadcast for inbound chat messages plus optimistic sends when the driver replies. +- **Location streaming:** Uses the Web Geolocation API with foreground visibility checks and throttled telemetry posts. +- **Mock CMS:** Node HTTP server + manual WebSocket implementation matching the documented REST/WS contracts for local development. +- **PWA scaffold:** Preconfigured manifest + service worker (via `vite-plugin-pwa`) for installability reminders. +- **Unit tests:** `node:test` coverage for timer thresholds and API client auth headers using `esbuild` to compile TypeScript modules. -## React Compiler +## Getting Started -The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. +```bash +npm install +``` -## Expanding the ESLint configuration +### Environment -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +Copy `.env.example` to `.env` (optional). Defaults align with the bundled mock services. + +```bash +VITE_CMS_BASE_URL=http://localhost:4310 +VITE_WS_URL=ws://localhost:4310 +``` + +### Available Scripts + +| Script | Description | +| ------ | ----------- | +| `npm run dev` | Start Vite dev server. | +| `npm run build` | Production build. | +| `npm run preview` | Preview the production build. | +| `npm run lint` | Run ESLint. | +| `npm run test` | Compile test targets with `esbuild` then execute unit tests via `node:test`. | +| `npm run mock` | Launch the mock CMS + WebSocket server (HTTP `:4310`). | + +Start both the mock server and the Vite dev server for a complete experience: + +```bash +npm run mock +# in another terminal +npm run dev +``` + +The driver portal is available at . Login accepts any email plus a password ≥ 6 characters. + +## Mock CMS & WebSocket Details + +The `mock-cms/server.js` file provides: + +- REST endpoints mirroring the specification (auth, driver, orders, telemetry, chat). +- In-memory order and chat datasets seeded with the design's sample orders. +- Minimal WebSocket handshake + framing for realtime order/chat broadcasts. +- Basic telemetry buffering for posted geolocation payloads. + +WebSocket events: + +- `ORDER_UPDATED` — broadcast when an order transitions state. +- `CHAT_MESSAGE` — broadcast for all chat messages (including driver echoes). +- `DRIVER_BROADCAST` — emitted when the driver changes status. + +## Architecture Notes + +- **Routing:** React Router with protected routes and an application shell providing header + sticky bottom navigation. +- **State management:** Custom query client (`src/hooks/queryClient.tsx`) models the React Query API (queries, mutations, cache invalidation) without external dependencies. +- **Auth context:** `AuthProvider` stores the driver profile + token, persists to `sessionStorage`, and exposes status updates. +- **Orders domain:** `useOrders*` hooks wrap API access, reuse the query cache, and provide mutations for state transitions. +- **Signature pad:** Canvas-based implementation using pointer events, forwarding imperative `clear` + `toDataURL` APIs to parent components. +- **Location streaming:** `useLocationStreamer` starts `navigator.geolocation.watchPosition` when the driver is ONLINE/ON_DELIVERY and respects the Page Visibility API before posting telemetry. + +## PWA & Mobile Guidance + +- Manifest + icons live under `public/`. Vite's PWA plugin registers a service worker for precaching. +- Icons are maintained as SVG sources so no binary assets are required in git; adjust the manifest if you regenerate raster variants. +- Drivers are prompted via a “Shift Mode” banner to keep the app in the foreground; document the platform-specific background limits in ops runbooks. +- Encourage installing the PWA (Add to Home Screen) for better persistence. + +## Testing + +Unit tests use Node's built-in runner and `esbuild` for lightweight TS compilation. + +```bash +npm run test +``` + +## Browser Location Limits + +Browsers pause geolocation updates when the tab is backgrounded (especially on iOS). The hook pauses watching when the document is hidden and resumes on visibility. For production, consider native wrappers or background sync strategies to guarantee continuous telemetry. diff --git a/eslint.config.js b/eslint.config.js index cee1e2c..1916899 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'tests/dist']), { files: ['**/*.{js,jsx}'], extends: [ @@ -26,4 +26,15 @@ export default defineConfig([ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], }, }, + { + files: ['mock-cms/**/*.js', 'tests/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + rules: { + 'no-unused-vars': ['error', { args: 'none', vars: 'all' }], + }, + }, ]) diff --git a/index.html b/index.html index 7649bb4..15c13b9 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,13 @@ - + - jason-driver-fe + + Jason's Driver App
- + diff --git a/mock-cms/server.js b/mock-cms/server.js new file mode 100644 index 0000000..75fae56 --- /dev/null +++ b/mock-cms/server.js @@ -0,0 +1,367 @@ +import http from 'node:http' +import { randomUUID } from 'node:crypto' +import { parse } from 'node:url' +import { createHash } from 'node:crypto' + +const PORT = process.env.PORT ? Number(process.env.PORT) : 4310 + +const driver = { + id: 'driver-1', + name: 'Alex Carter', + phone: '(555) 123-4567', + status: 'ONLINE', +} + +let token = 'mock-token' + +const orders = [ + { + id: 'JL-2847', + total: 127.5, + status: 'NEW', + customer: { + name: 'Michael Rodriguez', + phone: '555-123-4567', + address: '500 Market Street, San Francisco, CA', + }, + requiresIdCheck: true, + createdAt: new Date(Date.now() - 35 * 60000).toISOString(), + }, + { + id: 'JL-2846', + total: 156, + status: 'ARRIVED', + customer: { + name: 'John Doe', + phone: '555-246-8135', + address: '123 Market Street, San Francisco, CA 94103', + }, + requiresIdCheck: true, + assignedDriverId: driver.id, + createdAt: new Date(Date.now() - 28 * 60000).toISOString(), + }, + { + id: 'JL-2845', + total: 98.25, + status: 'COMPLETED', + customer: { + name: 'Alice Lee', + phone: '555-432-1000', + address: '700 Mission Street, San Francisco, CA', + }, + requiresIdCheck: true, + assignedDriverId: driver.id, + createdAt: new Date(Date.now() - 90 * 60000).toISOString(), + completedAt: new Date(Date.now() - 10 * 60000).toISOString(), + }, +] + +const threads = [ + { + id: 'thread-1', + participants: ['CUSTOMER', 'DRIVER'], + lastMessageAt: new Date().toISOString(), + orderId: 'JL-2846', + }, +] + +const messages = [ + { + id: randomUUID(), + sender: 'CUSTOMER', + text: 'Hi! Please call when you arrive.', + createdAt: new Date(Date.now() - 2 * 60000).toISOString(), + threadId: 'thread-1', + }, +] + +const locationBuffer = [] + +function json(res, status, body) { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }) + res.end(JSON.stringify(body)) +} + +function parseBody(req) { + return new Promise((resolve) => { + let data = '' + req.on('data', (chunk) => { + data += chunk + }) + req.on('end', () => { + try { + resolve(data ? JSON.parse(data) : {}) + } catch { + resolve({}) + } + }) + }) +} + +function authenticate(req, res) { + const auth = req.headers['authorization'] + if (!auth || auth !== `Bearer ${token}`) { + json(res, 401, { error: 'Unauthorized' }) + return false + } + return true +} + +const server = http.createServer(async (req, res) => { + const { pathname, query } = parse(req.url || '', true) + + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization', + }) + res.end() + return + } + + if (pathname === '/auth/login' && req.method === 'POST') { + const body = await parseBody(req) + if (!body.email || !body.password || body.password.length < 6) { + json(res, 400, { error: 'Invalid credentials' }) + return + } + token = `mock-${randomUUID()}` + json(res, 200, { token, driver }) + return + } + + if (pathname === '/driver/me' && req.method === 'GET') { + if (!authenticate(req, res)) return + json(res, 200, driver) + return + } + + if (pathname === '/driver/status' && req.method === 'PATCH') { + if (!authenticate(req, res)) return + const body = await parseBody(req) + if (!body.status) { + json(res, 400, { error: 'Missing status' }) + return + } + driver.status = body.status + broadcast({ type: 'DRIVER_BROADCAST', payload: { driver } }) + json(res, 200, driver) + return + } + + if (pathname === '/orders' && req.method === 'GET') { + if (!authenticate(req, res)) return + const statusFilter = query.status + const result = statusFilter + ? orders.filter((order) => order.status === statusFilter) + : orders + json(res, 200, result) + return + } + + if (pathname?.startsWith('/orders/') && req.method === 'GET') { + if (!authenticate(req, res)) return + const id = pathname.split('/')[2] + const order = orders.find((item) => item.id === id) + if (!order) { + json(res, 404, { error: 'Not found' }) + return + } + json(res, 200, order) + return + } + + if (pathname?.match(/^\/orders\/.+\/(accept|start|arrive|complete)$/) && req.method === 'POST') { + if (!authenticate(req, res)) return + const [, , orderId, action] = pathname.split('/') + const order = orders.find((item) => item.id === orderId) + if (!order) { + json(res, 404, { error: 'Order not found' }) + return + } + if (action === 'accept') { + order.status = 'ASSIGNED' + order.assignedDriverId = driver.id + } + if (action === 'start') { + order.status = 'IN_PROGRESS' + order.startedAt = new Date().toISOString() + } + if (action === 'arrive') { + order.status = 'ARRIVED' + order.arrivedAt = new Date().toISOString() + } + if (action === 'complete') { + const body = await parseBody(req) + order.status = 'COMPLETED' + order.completedAt = new Date().toISOString() + order.proof = body?.proof + } + broadcast({ type: 'ORDER_UPDATED', payload: order }) + json(res, 200, { ok: true }) + return + } + + if (pathname === '/telemetry/locations' && req.method === 'POST') { + if (!authenticate(req, res)) return + const body = await parseBody(req) + locationBuffer.push(body) + if (locationBuffer.length > 50) { + locationBuffer.shift() + } + json(res, 200, { ok: true }) + return + } + + if (pathname === '/chat/threads' && req.method === 'GET') { + if (!authenticate(req, res)) return + const { orderId } = query + const filtered = orderId + ? threads.filter((thread) => thread.orderId === orderId) + : threads + json(res, 200, filtered) + return + } + + if (pathname?.startsWith('/chat/threads/') && req.method === 'GET') { + if (!authenticate(req, res)) return + const [, , threadId] = pathname.split('/') + const threadMessages = messages.filter((msg) => msg.threadId === threadId) + json(res, 200, threadMessages) + return + } + + if (pathname?.endsWith('/messages') && req.method === 'POST') { + if (!authenticate(req, res)) return + const [, , threadId] = pathname.split('/') + const body = await parseBody(req) + const message = { + id: randomUUID(), + sender: 'DRIVER', + text: body.text, + createdAt: new Date().toISOString(), + threadId, + } + messages.push(message) + broadcast({ type: 'CHAT_MESSAGE', payload: message }) + json(res, 200, message) + return + } + + json(res, 404, { error: 'Not found' }) +}) + +const sockets = new Set() + +function broadcast(message) { + const frame = createFrame(JSON.stringify(message)) + for (const socket of sockets) { + socket.write(frame) + } +} + +function createFrame(data) { + const json = Buffer.from(data) + const length = json.length + let header + if (length < 126) { + header = Buffer.alloc(2) + header[0] = 0x81 + header[1] = length + } else if (length < 65536) { + header = Buffer.alloc(4) + header[0] = 0x81 + header[1] = 126 + header.writeUInt16BE(length, 2) + } else { + header = Buffer.alloc(10) + header[0] = 0x81 + header[1] = 127 + header.writeBigUInt64BE(BigInt(length), 2) + } + return Buffer.concat([header, json]) +} + +function handleData(socket, buffer) { + const firstByte = buffer[0] + const opCode = firstByte & 0x0f + if (opCode === 0x8) { + sockets.delete(socket) + socket.end() + return + } + const secondByte = buffer[1] + const isMasked = Boolean(secondByte & 0x80) + let payloadLength = secondByte & 0x7f + let offset = 2 + if (payloadLength === 126) { + payloadLength = buffer.readUInt16BE(offset) + offset += 2 + } else if (payloadLength === 127) { + payloadLength = Number(buffer.readBigUInt64BE(offset)) + offset += 8 + } + let mask + if (isMasked) { + mask = buffer.slice(offset, offset + 4) + offset += 4 + } + const payload = buffer.slice(offset, offset + payloadLength) + if (isMasked && mask) { + for (let i = 0; i < payload.length; i += 1) { + payload[i] ^= mask[i % 4] + } + } + const message = payload.toString('utf8') + try { + const parsed = JSON.parse(message) + if (parsed.type === 'CHAT_MESSAGE') { + const chatMessage = { + id: randomUUID(), + sender: 'DRIVER', + text: parsed.payload?.text ?? '', + createdAt: new Date().toISOString(), + threadId: parsed.payload?.threadId ?? 'thread-1', + } + messages.push(chatMessage) + broadcast({ type: 'CHAT_MESSAGE', payload: chatMessage }) + } + } catch (error) { + console.error('Failed to parse ws message', error) + } +} + +server.on('upgrade', (req, socket) => { + const key = req.headers['sec-websocket-key'] + if (!key) { + socket.destroy() + return + } + const acceptKey = createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64') + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${acceptKey}`, + '\r\n', + ].join('\r\n'), + ) + sockets.add(socket) + socket.on('data', (buffer) => handleData(socket, buffer)) + socket.on('close', () => sockets.delete(socket)) +}) + +server.listen(PORT, () => { + console.log(`Mock CMS listening on http://localhost:${PORT}`) +}) + +server.on('listening', () => { + console.log(`WebSocket endpoint ready at ws://localhost:${PORT}`) +}) diff --git a/package.json b/package.json index 63c1726..36634c3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "node tests/run-tests.cjs", + "mock": "node mock-cms/server.js" }, "dependencies": { "axios": "^1.12.2", diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..ab9b468 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,16 @@ + + Jason's Driver App Icon + + + + + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..52b2b83 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Jason's Driver App", + "short_name": "Jason Driver", + "start_url": "/", + "display": "standalone", + "background_color": "#f8f9fa", + "theme_color": "#667eea", + "description": "Mobile responsive driver workflow for Jason's delivery team.", + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 83ecd07..0000000 --- a/src/App.css +++ /dev/null @@ -1,274 +0,0 @@ -.app-shell { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -.app-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.25rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); - background: #ffffff; - position: sticky; - top: 0; - z-index: 10; -} - -.brand { - font-size: 1rem; - font-weight: 600; - color: #1b1b1f; -} - -.app-bar-actions { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.user-meta { - display: none; - flex-direction: column; - text-align: right; - line-height: 1.1; - font-size: 0.75rem; -} - -.user-name { - font-weight: 600; -} - -.user-email { - color: #55565a; -} - -.link-button { - background: transparent; - color: #0a67c6; - font-weight: 600; - padding: 0; - border: none; - text-decoration: underline; -} - -.link-button:hover { - color: #084f97; -} - -.app-content { - flex: 1; - display: flex; - padding: 1.25rem; -} - -.screen { - width: 100%; - display: flex; - justify-content: center; - align-items: flex-start; -} - -.login-screen { - align-items: center; -} - -.settings-screen { - align-items: flex-start; -} - -.screen-center { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; -} - -.spinner { - width: 2rem; - height: 2rem; - border-radius: 50%; - border: 0.2rem solid rgba(0, 0, 0, 0.1); - border-top-color: #0a67c6; - animation: spin 0.9s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.card { - width: min(100%, 28rem); - background: #ffffff; - border-radius: 0.75rem; - padding: 1.5rem; - box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08); -} - -.profile-card { - width: min(100%, 40rem); -} - -.card-header { - margin-bottom: 1.5rem; -} - -.card-title { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #111827; -} - -.card-subtitle { - margin: 0.25rem 0 0; - color: #4b5563; - font-size: 0.95rem; -} - -.form { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.form-grid { - display: grid; - gap: 1rem; - grid-template-columns: 1fr; -} - -.form-field { - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.form-label { - font-size: 0.85rem; - font-weight: 600; - color: #1f2937; -} - -.form-input { - border: 1px solid rgba(8, 15, 35, 0.15); - border-radius: 0.6rem; - padding: 0.75rem 0.9rem; - font-size: 1rem; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.form-input:focus { - border-color: #0a67c6; - outline: none; - box-shadow: 0 0 0 3px rgba(10, 103, 198, 0.15); -} - -.form-input:disabled { - background: rgba(229, 231, 235, 0.45); - cursor: not-allowed; -} - -.form-button { - width: 100%; - padding: 0.85rem; - border-radius: 0.6rem; - background: #0a67c6; - color: #ffffff; - font-weight: 600; - font-size: 1rem; - border: none; - transition: background 0.2s ease; -} - -.form-button:disabled { - background: #7aa8d9; - cursor: wait; -} - -.form-button:not(:disabled):hover { - background: #084f97; -} - -.form-error { - margin: 0; - color: #b91c1c; - font-size: 0.9rem; -} - -.form-success { - margin: 0; - color: #047857; - font-size: 0.9rem; -} - -.rating { - padding: 1rem; - border: 1px solid rgba(0, 0, 0, 0.05); - border-radius: 0.75rem; - background: rgba(10, 103, 198, 0.06); - display: flex; - flex-direction: column; - gap: 0.6rem; -} - -.rating-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.rating-title { - font-weight: 600; - color: #0f172a; -} - -.rating-score { - font-weight: 700; - font-size: 1.25rem; - color: #0a67c6; -} - -.rating-bar { - height: 0.6rem; - background: rgba(10, 103, 198, 0.2); - border-radius: 999px; - overflow: hidden; -} - -.rating-bar-fill { - height: 100%; - background: #0a67c6; - transition: width 0.3s ease; -} - -.rating-hint { - font-size: 0.75rem; - color: #475569; -} - -@media (min-width: 640px) { - .user-meta { - display: flex; - } -} - -@media (min-width: 768px) { - .app-bar { - padding: 1rem 2rem; - } - - .app-content { - padding: 2rem; - } - - .form-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 27e5b67..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useCallback } from 'react' -import { Link, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from 'react-router-dom' -import ProtectedRoute from './components/ProtectedRoute.jsx' -import { useAuth } from './context/AuthContext.jsx' -import LoginPage from './pages/Login.jsx' -import SettingsPage from './pages/Settings.jsx' -import './App.css' - -function AppLayout() { - const { user, logout } = useAuth() - const navigate = useNavigate() - const location = useLocation() - - const handleLogout = useCallback(() => { - logout() - navigate('/login', { replace: true }) - }, [logout, navigate]) - - const isSettingsRoute = location.pathname.startsWith('/settings') - - return ( -
-
- - Jason's Liquor Drivers - -
- {isSettingsRoute ? ( -
- {user?.name?.first} {user?.name?.last} - {user?.email} -
- ) : null} - -
-
-
- -
-
- ) -} - -export default function App() { - return ( - - } /> - }> - }> - } /> - } /> - - - } /> - - ) -} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..994229c --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,22 @@ +import { apiClient } from './client' +import type { Driver, DriverStatus } from '../types' + +interface LoginResponse { + token: string + driver: Driver +} + +export async function login(email: string, password: string): Promise { + const { data } = await apiClient.post('/auth/login', { email, password }) + return data +} + +export async function fetchCurrentDriver(): Promise { + const { data } = await apiClient.get('/driver/me') + return data +} + +export async function updateDriverStatus(status: DriverStatus): Promise { + const { data } = await apiClient.patch('/driver/status', { status }) + return data +} diff --git a/src/api/chat.ts b/src/api/chat.ts new file mode 100644 index 0000000..18256e1 --- /dev/null +++ b/src/api/chat.ts @@ -0,0 +1,19 @@ +import { apiClient } from './client' +import type { Message, ThreadSummary } from '../types' + +export async function fetchThreads(orderId?: string): Promise { + const { data } = await apiClient.get(`/chat/threads`, { + params: orderId ? { orderId } : undefined, + }) + return data +} + +export async function fetchMessages(threadId: string): Promise { + const { data } = await apiClient.get(`/chat/threads/${threadId}/messages`) + return data +} + +export async function postMessage(threadId: string, payload: { text?: string; imageUrl?: string }) { + const { data } = await apiClient.post(`/chat/threads/${threadId}/messages`, payload) + return data +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..435d787 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,35 @@ +import axios from 'axios' + +const viteEnv = (typeof import.meta !== 'undefined' && (import.meta as any)?.env) || {} +const nodeEnv = typeof process !== 'undefined' ? process.env : {} +const baseURL = + viteEnv.VITE_CMS_BASE_URL || nodeEnv?.VITE_CMS_BASE_URL || 'http://localhost:4310' + +export const apiClient = axios.create({ + baseURL, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: false, +}) + +apiClient.interceptors.request.use((config) => { + const token = sessionStorage.getItem('jdl:token') + if (token) { + config.headers = config.headers ?? {} + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +export function setAuthToken(token: string | null) { + if (token) { + sessionStorage.setItem('jdl:token', token) + } else { + sessionStorage.removeItem('jdl:token') + } +} + +export function getBaseUrl() { + return baseURL +} diff --git a/src/api/orders.ts b/src/api/orders.ts new file mode 100644 index 0000000..062b8b8 --- /dev/null +++ b/src/api/orders.ts @@ -0,0 +1,34 @@ +import { apiClient } from './client' +import type { Order } from '../types' + +export async function fetchOrdersByStatus(status: string): Promise { + const { data } = await apiClient.get(`/orders`, { params: { status } }) + return data +} + +export async function fetchOrder(id: string): Promise { + const { data } = await apiClient.get(`/orders/${id}`) + return data +} + +export async function acceptOrder(id: string) { + await apiClient.post(`/orders/${id}/accept`) +} + +export async function startOrder(id: string) { + await apiClient.post(`/orders/${id}/start`) +} + +export async function arriveOrder(id: string) { + await apiClient.post(`/orders/${id}/arrive`) +} + +export async function completeOrder( + id: string, + payload: { + signatureUrl: string + notes?: string + }, +) { + await apiClient.post(`/orders/${id}/complete`, { proof: payload }) +} diff --git a/src/api/telemetry.ts b/src/api/telemetry.ts new file mode 100644 index 0000000..3786755 --- /dev/null +++ b/src/api/telemetry.ts @@ -0,0 +1,6 @@ +import { apiClient } from './client' +import type { LocationPayload } from '../types' + +export async function postLocation(payload: LocationPayload) { + await apiClient.post('/telemetry/locations', payload) +} diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..7b0942a --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,67 @@ +import { useCallback, useMemo } from 'react' +import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom' +import { Header } from './components/Header' +import { BottomNav } from './components/BottomNav' +import { ProtectedRoute } from './components/ProtectedRoute' +import { useAuth } from './context/AuthContext' +import { useLocationStreamer } from './hooks/useLocationStreamer' +import LoginPage from './routes/login/LoginPage' +import OrdersPage from './routes/orders/OrdersPage' +import OrderDetailPage from './routes/orders/components/OrderDetailPage' +import ChatPage from './routes/chat/ChatPage' +import ProfilePage from './routes/profile/ProfilePage' + +function AppShell() { + const { driver, setStatus } = useAuth() + const location = useLocation() + + const streamingEnabled = useMemo( + () => driver?.status === 'ONLINE' || driver?.status === 'ON_DELIVERY', + [driver?.status], + ) + + useLocationStreamer({ enabled: streamingEnabled }) + + const handleToggleShift = useCallback(async () => { + if (!driver) return + const nextStatus = driver.status === 'OFFLINE' ? 'ONLINE' : 'OFFLINE' + await setStatus(nextStatus) + }, [driver, setStatus]) + + const showShiftHint = streamingEnabled && location.pathname.startsWith('/orders') + + return ( +
+
+ {showShiftHint ? ( +
+ Keep Jason's Driver App open during your shift to maintain live location updates. +
+ ) : null} +
+ +
+ +
+ ) +} + +export default function App() { + return ( + + + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ) +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx new file mode 100644 index 0000000..1e92713 --- /dev/null +++ b/src/components/BottomNav.tsx @@ -0,0 +1,20 @@ +import { NavLink } from 'react-router-dom' + +export function BottomNav() { + return ( + + ) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..f2cae92 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,29 @@ +import { Link } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +interface HeaderProps { + onToggleShift?: () => void + streaming: boolean +} + +export function Header({ onToggleShift, streaming }: HeaderProps) { + const { driver } = useAuth() + + return ( +
+ + Jason's Delivery + +
+
+
+ ) +} diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx deleted file mode 100644 index b94e007..0000000 --- a/src/components/ProtectedRoute.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Navigate, Outlet } from 'react-router-dom' -import { useAuth } from '../context/AuthContext' - -export default function ProtectedRoute({ redirectTo = '/login' }) { - const { token, initialising } = useAuth() - - if (initialising) { - return ( -
-
-
- ) - } - - if (!token) { - return - } - - return -} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..5d67548 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,22 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export function ProtectedRoute() { + const { driver, loading } = useAuth() + const location = useLocation() + + if (loading) { + return ( +
+ +

Loading driver session…

+
+ ) + } + + if (!driver) { + return + } + + return +} diff --git a/src/components/SignaturePad.tsx b/src/components/SignaturePad.tsx new file mode 100644 index 0000000..259f508 --- /dev/null +++ b/src/components/SignaturePad.tsx @@ -0,0 +1,119 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type ForwardedRef, +} from 'react' + +export interface SignaturePadHandle { + clear: () => void + toDataURL: () => string + hasSignature: () => boolean +} + +interface SignaturePadProps { + onChange?: (signed: boolean) => void +} + +export const SignaturePad = forwardRef(function SignaturePad( + { onChange }: SignaturePadProps, + ref: ForwardedRef, +) { + const canvasRef = useRef(null) + const [signed, setSigned] = useState(false) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const context = canvas.getContext('2d') + if (!context) return + + let drawing = false + let lastX = 0 + let lastY = 0 + + const resize = () => { + const rect = canvas.getBoundingClientRect() + const snapshot = signed ? context.getImageData(0, 0, canvas.width, canvas.height) : null + canvas.width = rect.width + canvas.height = rect.height + context.lineCap = 'round' + context.lineJoin = 'round' + context.lineWidth = 2 + if (snapshot) { + context.putImageData(snapshot, 0, 0) + } + } + + resize() + + const start = (event: PointerEvent) => { + drawing = true + const rect = canvas.getBoundingClientRect() + lastX = event.clientX - rect.left + lastY = event.clientY - rect.top + } + + const move = (event: PointerEvent) => { + if (!drawing) return + const rect = canvas.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + context.beginPath() + context.moveTo(lastX, lastY) + context.lineTo(x, y) + context.stroke() + lastX = x + lastY = y + if (!signed) { + setSigned(true) + onChange?.(true) + } + } + + const end = () => { + drawing = false + } + + canvas.addEventListener('pointerdown', start) + canvas.addEventListener('pointermove', move) + window.addEventListener('pointerup', end) + window.addEventListener('resize', resize) + + return () => { + canvas.removeEventListener('pointerdown', start) + canvas.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', end) + window.removeEventListener('resize', resize) + } + }, [onChange, signed]) + + useImperativeHandle( + ref, + () => ({ + clear() { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.clearRect(0, 0, canvas.width, canvas.height) + setSigned(false) + onChange?.(false) + }, + toDataURL() { + const canvas = canvasRef.current + if (!canvas) return '' + return canvas.toDataURL('image/png') + }, + hasSignature() { + return signed + }, + }), + [signed, onChange], + ) + + return +}) diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx new file mode 100644 index 0000000..90a543c --- /dev/null +++ b/src/components/Tabs.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' + +interface TabItem { + key: string + label: string + badge?: number +} + +interface TabsProps { + items: TabItem[] + activeKey: string + onChange: (key: string) => void +} + +export function Tabs({ items, activeKey, onChange }: TabsProps) { + return ( +
+ {items.map((item) => { + const isActive = item.key === activeKey + return ( + + ) + })} +
+ ) +} + +interface TabPanelProps { + active: boolean + children: ReactNode +} + +export function TabPanel({ active, children }: TabPanelProps) { + return
{children}
+} diff --git a/src/components/TimerChip.tsx b/src/components/TimerChip.tsx new file mode 100644 index 0000000..e04f717 --- /dev/null +++ b/src/components/TimerChip.tsx @@ -0,0 +1,23 @@ +import { useMemo, useState } from 'react' +import { determineTimerVariant, formatMinutesToClock, minutesSince, type TimerVariant } from '../utils/time' +import { useInterval } from '../hooks/useInterval' + +interface TimerChipProps { + createdAt: string +} + +export function TimerChip({ createdAt }: TimerChipProps) { + const [minutes, setMinutes] = useState(() => minutesSince(createdAt)) + useInterval(() => { + setMinutes(minutesSince(createdAt)) + }, 30000) + + const variant: TimerVariant = useMemo(() => determineTimerVariant(minutes), [minutes]) + const display = useMemo(() => formatMinutesToClock(minutes), [minutes]) + + return ( +
+ {display} +
+ ) +} diff --git a/src/components/VerifyChecklist.tsx b/src/components/VerifyChecklist.tsx new file mode 100644 index 0000000..6e7fc68 --- /dev/null +++ b/src/components/VerifyChecklist.tsx @@ -0,0 +1,32 @@ +interface ChecklistItem { + key: string + label: string + checked: boolean +} + +interface VerifyChecklistProps { + items: ChecklistItem[] + onToggle: (key: string) => void +} + +export function VerifyChecklist({ items, onToggle }: VerifyChecklistProps) { + return ( +
+
+

Required Verifications

+
+ {items.map((item) => ( + + ))} +
+ ) +} diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx deleted file mode 100644 index 5031adc..0000000 --- a/src/context/AuthContext.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { fetchOverallRating, logIn, updateDriverProfile } from '../services/authService' - -const AuthContext = createContext(null) - -const USER_STORAGE_KEY = 'jdl:user' -const TOKEN_STORAGE_KEY = 'jdl:token' - -export function AuthProvider({ children }) { - const [user, setUser] = useState(null) - const [token, setToken] = useState(null) - const [initialising, setInitialising] = useState(true) - const [authenticating, setAuthenticating] = useState(false) - const [error, setError] = useState(null) - const clearAuthError = useCallback(() => setError(null), []) - - useEffect(() => { - const storedUser = localStorage.getItem(USER_STORAGE_KEY) - const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY) - - if (storedUser && storedToken) { - try { - setUser(JSON.parse(storedUser)) - setToken(storedToken) - } catch (err) { - console.error('Failed to parse stored user', err) - localStorage.removeItem(USER_STORAGE_KEY) - localStorage.removeItem(TOKEN_STORAGE_KEY) - } - } - - setInitialising(false) - }, []) - - const handleLogin = useCallback(async (email, password) => { - setAuthenticating(true) - setError(null) - - try { - const data = await logIn(email, password) - if (!data?.user || !data?.token) { - throw new Error('Invalid login response from server.') - } - - setUser(data.user) - setToken(data.token) - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user)) - localStorage.setItem(TOKEN_STORAGE_KEY, data.token) - - return data - } catch (err) { - const message = err instanceof Error ? err.message : 'Unable to login.' - setError(message) - throw err - } finally { - setAuthenticating(false) - } - }, []) - - const handleLogout = useCallback(() => { - setUser(null) - setToken(null) - localStorage.removeItem(USER_STORAGE_KEY) - localStorage.removeItem(TOKEN_STORAGE_KEY) - }, []) - - const handleProfileUpdate = useCallback( - async (driverId, payload) => { - if (!token) { - throw new Error('You must be logged in to update profile information.') - } - - const updatedUser = await updateDriverProfile(driverId, payload, token) - setUser(updatedUser) - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updatedUser)) - return updatedUser - }, - [token], - ) - - const handleFetchRating = useCallback( - async (driverId) => { - if (!token) { - throw new Error('You must be logged in to view rating information.') - } - - const rating = await fetchOverallRating(driverId, token) - return rating - }, - [token], - ) - - const value = useMemo( - () => ({ - user, - token, - initialising, - authenticating, - error, - login: handleLogin, - logout: handleLogout, - updateProfile: handleProfileUpdate, - loadOverallRating: handleFetchRating, - clearError: clearAuthError, - }), - [ - user, - token, - initialising, - authenticating, - error, - handleLogin, - handleLogout, - handleProfileUpdate, - handleFetchRating, - clearAuthError, - ], - ) - - return {children} -} - -export function useAuth() { - const context = useContext(AuthContext) - if (!context) { - throw new Error('useAuth must be used within an AuthProvider') - } - return context -} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..48e58c9 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,136 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import { fetchCurrentDriver, login as apiLogin, updateDriverStatus } from '../api/auth' +import { setAuthToken } from '../api/client' +import type { Driver, DriverStatus } from '../types' + +const DRIVER_STORAGE_KEY = 'jdl:driver' +const TOKEN_STORAGE_KEY = 'jdl:token' + +interface AuthContextValue { + driver: Driver | null + token: string | null + loading: boolean + loggingIn: boolean + error: string | null + login: (email: string, password: string) => Promise + logout: () => void + refreshProfile: () => Promise + setStatus: (status: DriverStatus) => Promise +} + +const AuthContext = createContext(undefined) + +interface Props { + children: ReactNode +} + +export function AuthProvider({ children }: Props) { + const [driver, setDriver] = useState(null) + const [token, setToken] = useState(null) + const [loading, setLoading] = useState(true) + const [loggingIn, setLoggingIn] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const storedToken = sessionStorage.getItem(TOKEN_STORAGE_KEY) + const storedDriver = sessionStorage.getItem(DRIVER_STORAGE_KEY) + if (storedToken) { + setToken(storedToken) + setAuthToken(storedToken) + } + if (storedDriver) { + try { + setDriver(JSON.parse(storedDriver)) + } catch (err) { + console.error('Failed to parse stored driver', err) + sessionStorage.removeItem(DRIVER_STORAGE_KEY) + } + } + setLoading(false) + }, []) + + const persist = useCallback((nextDriver: Driver | null, nextToken: string | null) => { + setDriver(nextDriver) + setToken(nextToken) + if (nextDriver) { + sessionStorage.setItem(DRIVER_STORAGE_KEY, JSON.stringify(nextDriver)) + } else { + sessionStorage.removeItem(DRIVER_STORAGE_KEY) + } + setAuthToken(nextToken) + if (nextToken) { + sessionStorage.setItem(TOKEN_STORAGE_KEY, nextToken) + } else { + sessionStorage.removeItem(TOKEN_STORAGE_KEY) + } + }, []) + + const login = useCallback( + async (email: string, password: string) => { + setLoggingIn(true) + setError(null) + try { + const { driver: driverProfile, token: authToken } = await apiLogin(email, password) + persist(driverProfile, authToken) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to sign in.' + setError(message) + throw err + } finally { + setLoggingIn(false) + } + }, + [persist], + ) + + const logout = useCallback(() => { + persist(null, null) + }, [persist]) + + const refreshProfile = useCallback(async () => { + if (!token) return + const latest = await fetchCurrentDriver() + persist(latest, token) + }, [persist, token]) + + const setStatus = useCallback( + async (status: DriverStatus) => { + const next = await updateDriverStatus(status) + persist(next, token) + }, + [persist, token], + ) + + const value = useMemo( + () => ({ + driver, + token, + loading, + loggingIn, + error, + login, + logout, + refreshProfile, + setStatus, + }), + [driver, token, loading, loggingIn, error, login, logout, refreshProfile, setStatus], + ) + + return {children} +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within AuthProvider') + } + return context +} diff --git a/src/hooks/queryClient.tsx b/src/hooks/queryClient.tsx new file mode 100644 index 0000000..185d9d3 --- /dev/null +++ b/src/hooks/queryClient.tsx @@ -0,0 +1,215 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' + +type QueryKey = ReadonlyArray + +type QueryStatus = 'idle' | 'loading' | 'success' | 'error' + +interface QueryCacheEntry { + key: QueryKey + data?: T + error?: unknown + status: QueryStatus + updatedAt?: number + subscribers: Set<() => void> + promise?: Promise +} + +function serializeKey(key: QueryKey): string { + return key.join('::') +} + +class QueryClient { + private cache = new Map>() + + getEntry(key: QueryKey): QueryCacheEntry { + const id = serializeKey(key) + if (!this.cache.has(id)) { + this.cache.set(id, { + key, + status: 'idle', + subscribers: new Set(), + }) + } + return this.cache.get(id)! as QueryCacheEntry + } + + async fetchQuery(key: QueryKey, queryFn: () => Promise): Promise { + const entry = this.getEntry(key) + if (entry.status === 'loading' && entry.promise) { + return entry.promise + } + if (entry.status === 'success' && entry.data !== undefined) { + return entry.data + } + const promise = queryFn() + entry.status = 'loading' + entry.promise = promise + entry.error = undefined + this.notify(entry) + try { + const result = await promise + entry.status = 'success' + entry.data = result + entry.updatedAt = Date.now() + return result + } catch (err) { + entry.status = 'error' + entry.error = err + throw err + } finally { + entry.promise = undefined + this.notify(entry) + } + } + + setQueryData(key: QueryKey, data: T) { + const entry = this.getEntry(key) + entry.data = data + entry.status = 'success' + entry.updatedAt = Date.now() + this.notify(entry) + } + + invalidateQueries(partialKey: string) { + for (const [id, entry] of this.cache.entries()) { + if (entry.key.some((segment) => segment.startsWith(partialKey))) { + this.cache.delete(id) + this.notify(entry) + } + } + } + + subscribe(key: QueryKey, listener: () => void) { + const entry = this.getEntry(key) + entry.subscribers.add(listener) + return () => { + entry.subscribers.delete(listener) + } + } + + private notify(entry: QueryCacheEntry) { + entry.subscribers.forEach((listener) => listener()) + } +} + +const QueryClientContext = createContext(undefined) + +export function QueryClientProvider({ children }: { children: ReactNode }) { + const clientRef = useRef() + if (!clientRef.current) { + clientRef.current = new QueryClient() + } + return {children} +} + +export function useQueryClient() { + const context = useContext(QueryClientContext) + if (!context) { + throw new Error('useQueryClient must be used within QueryClientProvider') + } + return context +} + +interface UseQueryOptions { + enabled?: boolean + staleTime?: number + initialData?: T +} + +interface UseQueryResult { + data: T | undefined + status: QueryStatus + error: unknown + isFetching: boolean + refetch: () => Promise +} + +export function useQuery( + key: QueryKey, + queryFn: () => Promise, + options: UseQueryOptions = {}, +): UseQueryResult { + const client = useQueryClient() + const [state, setState] = useState>(() => client.getEntry(key)) + const { enabled = true, staleTime = 0, initialData } = options + + useEffect(() => { + const unsubscribe = client.subscribe(key, () => { + setState({ ...client.getEntry(key) }) + }) + return unsubscribe + }, [client, key]) + + useEffect(() => { + if (!enabled) return + const entry = client.getEntry(key) + const isStale = !entry.updatedAt || Date.now() - entry.updatedAt > staleTime + if (entry.status === 'idle' && initialData !== undefined) { + client.setQueryData(key, initialData) + return + } + if (isStale) { + void client.fetchQuery(key, queryFn).catch(() => null) + } + }, [client, key, queryFn, enabled, staleTime, initialData]) + + const refetch = useCallback(async () => { + await client.fetchQuery(key, queryFn) + }, [client, key, queryFn]) + + return useMemo( + () => ({ + data: state.data, + status: state.status, + error: state.error, + isFetching: state.status === 'loading', + refetch, + }), + [state.data, state.status, state.error, refetch], + ) +} + +interface MutationOptions { + mutationFn: (variables: TVariables) => Promise + onSuccess?: (data: TData) => void + onError?: (error: unknown) => void +} + +export function useMutation(options: MutationOptions) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const mutateAsync = useCallback( + async (variables: TVariables) => { + setLoading(true) + setError(null) + try { + const result = await options.mutationFn(variables) + options.onSuccess?.(result) + return result + } catch (err) { + setError(err) + options.onError?.(err) + throw err + } finally { + setLoading(false) + } + }, + [options], + ) + + return { + mutateAsync, + isLoading: loading, + error, + } +} diff --git a/src/hooks/useInterval.ts b/src/hooks/useInterval.ts new file mode 100644 index 0000000..3383221 --- /dev/null +++ b/src/hooks/useInterval.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react' + +type Callback = () => void + +export function useInterval(callback: Callback, delay: number | null) { + const savedCallback = useRef(null) + + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + useEffect(() => { + if (delay === null) return + function tick() { + savedCallback.current?.() + } + const id = window.setInterval(tick, delay) + return () => { + window.clearInterval(id) + } + }, [delay]) +} diff --git a/src/hooks/useLocationStreamer.ts b/src/hooks/useLocationStreamer.ts new file mode 100644 index 0000000..70c78da --- /dev/null +++ b/src/hooks/useLocationStreamer.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef } from 'react' +import { postLocation } from '../api/telemetry' +import { usePageVisibility } from './usePageVisibility' + +interface Options { + enabled: boolean + orderId?: string + intervalMs?: number +} + +export function useLocationStreamer({ enabled, orderId, intervalMs = 15000 }: Options) { + const watchId = useRef(null) + const lastSent = useRef(0) + const buffer = useRef([]) + const isVisible = usePageVisibility() + + useEffect(() => { + if (!enabled || !isVisible) { + if (watchId.current !== null && 'geolocation' in navigator) { + navigator.geolocation.clearWatch(watchId.current) + watchId.current = null + } + return + } + + if (!('geolocation' in navigator)) { + console.warn('Geolocation not supported in this browser') + return + } + + watchId.current = navigator.geolocation.watchPosition( + (position) => { + buffer.current.push(position) + void flush() + }, + (error) => { + console.error('Location error', error) + }, + { + enableHighAccuracy: true, + maximumAge: 5000, + timeout: 10000, + }, + ) + + return () => { + if (watchId.current !== null) { + navigator.geolocation.clearWatch(watchId.current) + watchId.current = null + } + } + }, [enabled, isVisible, intervalMs, orderId]) + + async function flush() { + const now = Date.now() + if (now - lastSent.current < intervalMs) { + return + } + const positions = buffer.current.splice(0, buffer.current.length) + if (positions.length === 0) return + lastSent.current = now + const latest = positions[positions.length - 1] + const { latitude, longitude, speed, heading, accuracy } = latest.coords + await postLocation({ + lat: latitude, + lng: longitude, + speed: speed ?? null, + heading: heading ?? null, + accuracy: accuracy ?? null, + orderId, + timestamp: new Date(latest.timestamp).toISOString(), + }) + } +} diff --git a/src/hooks/useOrders.ts b/src/hooks/useOrders.ts new file mode 100644 index 0000000..7a0a6bc --- /dev/null +++ b/src/hooks/useOrders.ts @@ -0,0 +1,71 @@ +import { useCallback } from 'react' +import { + acceptOrder, + arriveOrder, + completeOrder, + fetchOrder, + fetchOrdersByStatus, + startOrder, +} from '../api/orders' +import type { Order } from '../types' +import { useMutation, useQuery, useQueryClient } from './queryClient' + +export function useOrdersByStatus(status: string) { + return useQuery(['orders', status], () => fetchOrdersByStatus(status), { staleTime: 15000 }) +} + +export function useOrder(orderId: string) { + return useQuery(['order', orderId], () => fetchOrder(orderId), { staleTime: 15000 }) +} + +export function useOrderActions(orderId: string) { + const client = useQueryClient() + + const invalidate = useCallback(() => { + client.invalidateQueries('orders') + void client.fetchQuery(['order', orderId], () => fetchOrder(orderId)) + }, [client, orderId]) + + const accept = useMutation({ + mutationFn: async () => acceptOrder(orderId), + onSuccess: invalidate, + }) + + const start = useMutation({ + mutationFn: async () => startOrder(orderId), + onSuccess: invalidate, + }) + + const arrive = useMutation({ + mutationFn: async () => arriveOrder(orderId), + onSuccess: invalidate, + }) + + const complete = useMutation({ + mutationFn: (variables) => completeOrder(orderId, variables), + onSuccess: invalidate, + }) + + return { + accept, + start, + arrive, + complete, + } +} + +export function useOptimisticOrderUpdate(status: string) { + const client = useQueryClient() + return useCallback( + (order: Order) => { + const queryKey = ['orders', status] as const + const entry = client.getEntry(queryKey) + const existing = entry.data ?? [] + const next = existing.some((item) => item.id === order.id) + ? existing.map((item) => (item.id === order.id ? order : item)) + : [...existing, order] + client.setQueryData(queryKey, next) + }, + [client, status], + ) +} diff --git a/src/hooks/usePageVisibility.ts b/src/hooks/usePageVisibility.ts new file mode 100644 index 0000000..095b3d7 --- /dev/null +++ b/src/hooks/usePageVisibility.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' + +export function usePageVisibility() { + const [isVisible, setIsVisible] = useState(() => document.visibilityState === 'visible') + + useEffect(() => { + const handler = () => { + setIsVisible(document.visibilityState === 'visible') + } + document.addEventListener('visibilitychange', handler) + return () => document.removeEventListener('visibilitychange', handler) + }, []) + + return isVisible +} diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 529477f..0000000 --- a/src/index.css +++ /dev/null @@ -1,46 +0,0 @@ -* { - box-sizing: border-box; -} - -:root { - font-family: 'Inter', 'Segoe UI', Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - color: #0f172a; - background-color: #f1f5f9; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - margin: 0; - min-height: 100vh; - background-color: #f1f5f9; -} - -a { - color: inherit; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -button, -input { - font-family: inherit; -} - -button { - cursor: pointer; -} - -button:disabled { - cursor: not-allowed; -} - -#root { - min-height: 100vh; -} diff --git a/src/main.jsx b/src/main.jsx deleted file mode 100644 index 84a9259..0000000 --- a/src/main.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import './index.css' -import App from './App.jsx' -import { AuthProvider } from './context/AuthContext.jsx' - -createRoot(document.getElementById('root')).render( - - - - - - - , -) diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..155ee8f --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './app' +import { AppProviders } from './providers/AppProviders' +import './styles/globals.css' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + , +) diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx deleted file mode 100644 index be0af73..0000000 --- a/src/pages/Login.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' -import { useAuth } from '../context/AuthContext' - -export default function LoginPage() { - const navigate = useNavigate() - const location = useLocation() - const { login, token, authenticating, error, clearError } = useAuth() - - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [localError, setLocalError] = useState('') - - const redirectPath = useMemo(() => { - if (location.state?.from) { - return location.state.from - } - return '/settings' - }, [location.state]) - - useEffect(() => { - if (token) { - navigate(redirectPath, { replace: true }) - } - }, [navigate, redirectPath, token]) - - useEffect(() => { - if (error) { - setLocalError(error) - } - }, [error]) - - const handleEmailChange = (event) => { - setEmail(event.target.value) - if (localError) { - setLocalError('') - clearError() - } - } - - const handlePasswordChange = (event) => { - setPassword(event.target.value) - if (localError) { - setLocalError('') - clearError() - } - } - - const handleSubmit = async (event) => { - event.preventDefault() - if (!email || !password) { - setLocalError('Email and password are required.') - return - } - - try { - await login(email.trim(), password) - } catch (err) { - const message = err instanceof Error ? err.message : 'Unable to login.' - setLocalError(message) - } - } - - return ( -
-
-
-

Driver Login

-

Sign in with your driver credentials to continue.

-
- -
- - - - - {localError ? ( -

- {localError} -

- ) : null} - - -
-
-
- ) -} diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx deleted file mode 100644 index 7b40027..0000000 --- a/src/pages/Settings.jsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { useAuth } from '../context/AuthContext' - -export default function SettingsPage() { - const { user, updateProfile, loadOverallRating, authenticating } = useAuth() - const [firstName, setFirstName] = useState('') - const [lastName, setLastName] = useState('') - const [email, setEmail] = useState('') - const [phone, setPhone] = useState('') - const [isSaving, setIsSaving] = useState(false) - const [feedback, setFeedback] = useState('') - const [error, setError] = useState('') - const [rating, setRating] = useState(null) - - const driverId = useMemo(() => user?._id || user?.id, [user]) - - useEffect(() => { - if (user) { - setFirstName(user.name?.first || '') - setLastName(user.name?.last || '') - setEmail(user.email || '') - setPhone(user.phone || '') - } - }, [user]) - - useEffect(() => { - let ignore = false - - async function loadRating() { - if (!driverId) { - return - } - - try { - const ratingResponse = await loadOverallRating(driverId) - - if (ignore) { - return - } - - if (Array.isArray(ratingResponse)) { - const numeric = Number(ratingResponse[0]?.average ?? ratingResponse[0]?.rating) - if (!Number.isNaN(numeric)) { - setRating(numeric) - } - } else if (ratingResponse && typeof ratingResponse === 'object') { - const value = Number(ratingResponse.average ?? ratingResponse.rating) - if (!Number.isNaN(value)) { - setRating(value) - } - } else if (typeof ratingResponse === 'number') { - setRating(ratingResponse) - } - } catch (err) { - console.warn('Unable to load rating', err) - } - } - - loadRating() - - return () => { - ignore = true - } - }, [driverId, loadOverallRating]) - - const formattedRating = useMemo(() => { - if (typeof rating !== 'number') { - return 'N/A' - } - - return rating.toFixed(2) - }, [rating]) - - const ratingPercentage = useMemo(() => { - if (typeof rating !== 'number') { - return 0 - } - - const clamped = Math.max(0, Math.min(5, rating)) - return (clamped / 5) * 100 - }, [rating]) - - const handleSubmit = async (event) => { - event.preventDefault() - if (!driverId) { - setError('Missing driver information.') - return - } - - setIsSaving(true) - setFeedback('') - setError('') - - try { - await updateProfile(driverId, { - name: { - first: firstName, - last: lastName, - }, - email, - phone, - }) - setFeedback('Profile updated successfully.') - } catch (err) { - const message = err instanceof Error ? err.message : 'Unable to update profile.' - setError(message) - } finally { - setIsSaving(false) - } - } - - return ( -
-
-
-

Driver Profile

-

Update your details to keep dispatch informed.

-
- -
-
- - - - - - - -
- -
-
- Overall rating - {formattedRating} -
-
- - {feedback ? ( -

- {feedback} -

- ) : null} - - {error ? ( -

- {error} -

- ) : null} - - -
-
-
- ) -} diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx new file mode 100644 index 0000000..492c773 --- /dev/null +++ b/src/providers/AppProviders.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' +import { AuthProvider } from '../context/AuthContext' +import { QueryClientProvider } from '../hooks/queryClient' + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/routes/chat/ChatPage.tsx b/src/routes/chat/ChatPage.tsx new file mode 100644 index 0000000..5f6447a --- /dev/null +++ b/src/routes/chat/ChatPage.tsx @@ -0,0 +1,90 @@ +import { FormEvent, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { fetchMessages, postMessage } from '../../api/chat' +import type { Message } from '../../types' +import { useQuery, useQueryClient, useMutation } from '../../hooks/queryClient' +import { useSocket } from '../../ws/socket' + +export default function ChatPage() { + const { threadId = '' } = useParams() + const navigate = useNavigate() + const [text, setText] = useState('') + const client = useQueryClient() + + const { + data: messages, + status, + refetch, + } = useQuery(['chat', threadId], () => fetchMessages(threadId), { enabled: Boolean(threadId), staleTime: 10000 }) + + useEffect(() => { + void refetch() + }, [refetch]) + + const { mutateAsync: send } = useMutation({ + mutationFn: () => postMessage(threadId, { text }), + onSuccess: (message) => { + const cache = client.getEntry(['chat', threadId]).data ?? [] + client.setQueryData(['chat', threadId], [...cache, message]) + setText('') + }, + }) + + useSocket('CHAT_MESSAGE', (payload) => { + const message = payload as Message + if (message && message.id && message.createdAt) { + const cache = client.getEntry(['chat', threadId]).data ?? [] + if (cache.some((item) => item.id === message.id)) return + client.setQueryData(['chat', threadId], [...cache, message]) + } + }) + + if (!threadId) { + return ( +
+ No chat selected. + +
+ ) + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!text.trim()) return + await send() + } + + return ( +
+
+ +

Chat

+
+
+ {status === 'loading' ?

Loading messages…

: null} + {messages?.map((message) => ( +
+

{message.text}

+ {new Date(message.createdAt).toLocaleTimeString()} +
+ ))} +
+
+