diff --git a/src/App.jsx b/src/App.jsx index 38e7dc2..4c07c34 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,80 +1,27 @@ -import { useCallback } from 'react' -import { - Link, - Navigate, - NavLink, - Outlet, - Route, - Routes, - useLocation, - useNavigate, -} from 'react-router-dom' +import { Navigate, Outlet, Route, Routes } from 'react-router-dom' +import LoginRoute from './routes/Login.jsx' +import OrdersRoute from './routes/Orders/Index.jsx' +import ProfileRoute from './routes/Profile.jsx' 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 OrdersFeed from './pages/orders/OrdersFeed.jsx' -import OrderDetails from './pages/orders/OrderDetails.jsx' -import OrderCamera from './pages/orders/OrderCamera.jsx' -import OrderSignature from './pages/orders/OrderSignature.jsx' -import OrderBypass from './pages/orders/OrderBypass.jsx' -import OrderCancel from './pages/orders/OrderCancel.jsx' -import './App.css' +import Header from './components/Header.jsx' +import BottomNav from './components/BottomNav.jsx' +import { useAuth } from './hooks/useAuth.jsx' +import { useLocationTracking } from './hooks/useLocationTracking.ts' +import { OrdersProvider } from './hooks/useOrders.jsx' -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') - - const navItems = [ - { to: '/orders', label: 'Orders' }, - { to: '/settings', label: 'Settings' }, - ] +function AppShell() { + const { driver } = useAuth() + const trackingActive = useLocationTracking({ isActive: driver?.status !== 'OFFLINE' }) return ( -
-
-
- - Jason's Liquor Drivers - - -
-
- {isSettingsRoute ? ( -
- {user?.name?.first} {user?.name?.last} - {user?.email} -
- ) : null} - -
-
-
- -
+
+
+
+ + + +
+
) } @@ -82,19 +29,14 @@ function AppLayout() { export default function App() { return ( - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + } /> + }> + }> + } /> + } /> + } /> + } /> ) diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index ca5860b..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Navigate, Outlet, Route, Routes } from 'react-router-dom' -import LoginRoute from './routes/Login' -import OrdersRoute from './routes/Orders/Index' -import ProfileRoute from './routes/Profile' -import { ProtectedRoute } from './components/ProtectedRoute' -import { Header } from './components/Header' -import { BottomNav } from './components/BottomNav' -import { useAuth } from './hooks/useAuth' -import { useLocationTracking } from './hooks/useLocationTracking' -import { OrdersProvider } from './hooks/useOrders' - -function AppShell(): JSX.Element { - const { driver } = useAuth() - const trackingActive = useLocationTracking({ isActive: driver?.status !== 'OFFLINE' }) - - return ( -
-
-
- - - -
- -
- ) -} - -export default function App(): JSX.Element { - return ( - - } /> - }> - }> - } /> - } /> - } /> - - - } /> - - ) -} diff --git a/src/api/mockData.ts b/src/api/mockData.ts index e381c8e..1dd799a 100644 --- a/src/api/mockData.ts +++ b/src/api/mockData.ts @@ -1,4 +1,4 @@ -import { AuthResponse, Driver, Message, Order } from '../types' +import { AuthResponse, Driver, Message } from '../types' const now = new Date() @@ -14,71 +14,6 @@ export const mockDriver: Driver = { status: 'ONLINE', } -export const mockOrders: Order[] = [ - { - id: 'order-1', - number: 'JL-2847', - total: 127.5, - status: 'NEW', - requiresIdCheck: true, - requiresPaymentCheck: true, - createdAt: minutesAgo(35), - customer: { - name: 'Michael Rodriguez', - phone: '(555) 123-4567', - address: '85 Dolores Street, San Francisco, CA 94110', - }, - priority: true, - assignedDriverId: 'driver-1', - items: [ - { id: 'item-1', name: 'Johnnie Walker Black Label', quantity: 1 }, - { id: 'item-2', name: 'Grey Goose Vodka', quantity: 1 }, - { id: 'item-3', name: 'Corona Extra 6-pack', quantity: 1 }, - ], - }, - { - id: 'order-2', - number: 'JL-2846', - total: 156, - status: 'IN_PROGRESS', - requiresIdCheck: true, - requiresPaymentCheck: true, - createdAt: minutesAgo(28), - customer: { - name: 'John Doe', - phone: '(555) 246-8135', - address: '123 Market Street, San Francisco, CA 94103', - lat: 37.7937, - lng: -122.396, - }, - assignedDriverId: 'driver-1', - items: [ - { id: 'item-4', name: 'Don Julio Blanco', quantity: 1 }, - { id: 'item-5', name: 'Casamigos Reposado', quantity: 1 }, - { id: 'item-6', name: 'Limes', quantity: 6 }, - ], - }, - { - id: 'order-3', - number: 'JL-2845', - total: 98.4, - status: 'COMPLETED', - requiresIdCheck: true, - requiresPaymentCheck: true, - createdAt: minutesAgo(125), - customer: { - name: 'Alice Lee', - phone: '(555) 678-9012', - address: '678 Mission Street, San Francisco, CA 94105', - }, - assignedDriverId: 'driver-1', - items: [ - { id: 'item-7', name: 'Veuve Clicquot Brut', quantity: 2 }, - { id: 'item-8', name: 'San Pellegrino', quantity: 4 }, - ], - }, -] - export const mockAuthResponse: AuthResponse = { token: 'demo-token', driver: mockDriver, diff --git a/src/api/orders.ts b/src/api/orders.ts deleted file mode 100644 index 6766aca..0000000 --- a/src/api/orders.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { apiClient, safeRequest } from './client' -import { mockOrders } from './mockData' -import { Order } from '../types' - -export async function getOrders(): Promise { - return safeRequest( - async () => { - const response = await apiClient.get('/orders') - return response.data - }, - async () => mockOrders, - ) -} - -export async function acceptOrder(orderId: string): Promise { - return safeRequest( - async () => { - const response = await apiClient.post(`/orders/${orderId}/accept`) - return response.data - }, - async () => { - const existing = mockOrders.find((order) => order.id === orderId) - if (!existing) { - throw new Error('Order not found') - } - existing.status = 'IN_PROGRESS' - return existing - }, - ) -} - -export async function arriveOrder(orderId: string): Promise { - return safeRequest( - async () => { - const response = await apiClient.post(`/orders/${orderId}/arrive`) - return response.data - }, - async () => { - const existing = mockOrders.find((order) => order.id === orderId) - if (!existing) { - throw new Error('Order not found') - } - existing.status = 'ARRIVED' - return existing - }, - ) -} - -export async function completeOrder(orderId: string, signature?: string): Promise { - return safeRequest( - async () => { - const response = await apiClient.post(`/orders/${orderId}/complete`, { signature }) - return response.data - }, - async () => { - const existing = mockOrders.find((order) => order.id === orderId) - if (!existing) { - throw new Error('Order not found') - } - existing.status = 'COMPLETED' - return existing - }, - ) -} - -export function getElapsedMinutes(order: Order): number { - const start = new Date(order.createdAt) - const now = new Date() - const diff = (now.getTime() - start.getTime()) / 60000 - return Math.max(0, diff) -} - -export function getEta(order: Order): string { - const minutes = getElapsedMinutes(order) - const eta = new Date(new Date(order.createdAt).getTime() + Math.round(minutes) * 60000) - return eta.toISOString() -} diff --git a/src/components/BottomNav.jsx b/src/components/BottomNav.jsx new file mode 100644 index 0000000..e064d4b --- /dev/null +++ b/src/components/BottomNav.jsx @@ -0,0 +1,35 @@ +import { NavLink } from 'react-router-dom' + +export default function BottomNav() { + return ( + + ) +} diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx deleted file mode 100644 index 5e08fd0..0000000 --- a/src/components/BottomNav.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { NavLink } from 'react-router-dom' - -export function BottomNav(): JSX.Element { - return ( - - ) -} diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..b326644 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom' +import { useAuth } from '../hooks/useAuth.jsx' +import { classNames } from '../utils/classNames.ts' + +const statusLabels = { + ONLINE: 'Online', + OFFLINE: 'Offline', + ON_DELIVERY: 'On Delivery', +} + +export default function Header({ trackingActive }) { + const { driver } = useAuth() + + return ( +
+
+ + Jason's Delivery + + Driver Portal +
+
+
+ {driver?.name ?? 'Driver'} + {driver ? statusLabels[driver.status] : 'Offline'} +
+
+ 📡 +
+
+
+ ) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index e8fdd75..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Link } from 'react-router-dom' -import { DriverStatus } from '../types' -import { useAuth } from '../hooks/useAuth' -import { classNames } from '../utils/classNames' - -interface HeaderProps { - trackingActive: boolean -} - -const statusLabels: Record = { - ONLINE: 'Online', - OFFLINE: 'Offline', - ON_DELIVERY: 'On Delivery', -} - -export function Header({ trackingActive }: HeaderProps): JSX.Element { - const { driver } = useAuth() - - return ( -
-

- - Jason's Delivery - -

-
- -
- {driver?.name ?? 'Driver'} - {driver ? statusLabels[driver.status] : 'Offline'} -
-
- 📡 -
-
-
- ) -} diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx index b94e007..d35e1c4 100644 --- a/src/components/ProtectedRoute.jsx +++ b/src/components/ProtectedRoute.jsx @@ -1,19 +1,20 @@ -import { Navigate, Outlet } from 'react-router-dom' -import { useAuth } from '../context/AuthContext' +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '../hooks/useAuth.jsx' -export default function ProtectedRoute({ redirectTo = '/login' }) { - const { token, initialising } = useAuth() +export default function ProtectedRoute() { + const { driver, loading } = useAuth() + const location = useLocation() - if (initialising) { + if (loading) { return ( -
-
+
+ Loading driver session…
) } - if (!token) { - return + if (!driver) { + return } return diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx deleted file mode 100644 index fc1b12e..0000000 --- a/src/components/ProtectedRoute.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Navigate, Outlet, useLocation } from 'react-router-dom' -import { useAuth } from '../hooks/useAuth' - -export function ProtectedRoute(): JSX.Element { - 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.jsx similarity index 87% rename from src/components/SignaturePad.tsx rename to src/components/SignaturePad.jsx index ca7c591..224ebbe 100644 --- a/src/components/SignaturePad.tsx +++ b/src/components/SignaturePad.jsx @@ -1,12 +1,7 @@ import { useEffect, useRef } from 'react' -interface SignaturePadProps { - value: string | null - onChange: (value: string | null) => void -} - -export function SignaturePad({ value, onChange }: SignaturePadProps): JSX.Element { - const canvasRef = useRef(null) +export default function SignaturePad({ value, onChange }) { + const canvasRef = useRef(null) const drawing = useRef(false) const hasSignature = useRef(false) @@ -49,7 +44,7 @@ export function SignaturePad({ value, onChange }: SignaturePadProps): JSX.Elemen const ctx = canvas.getContext('2d') if (!ctx) return - function getPoint(event: PointerEvent) { + function getPoint(event) { const rect = canvas.getBoundingClientRect() return { x: event.clientX - rect.left, @@ -57,7 +52,7 @@ export function SignaturePad({ value, onChange }: SignaturePadProps): JSX.Elemen } } - function handlePointerDown(event: PointerEvent) { + function handlePointerDown(event) { drawing.current = true const { x, y } = getPoint(event) ctx.beginPath() @@ -65,7 +60,7 @@ export function SignaturePad({ value, onChange }: SignaturePadProps): JSX.Elemen event.preventDefault() } - function handlePointerMove(event: PointerEvent) { + function handlePointerMove(event) { if (!drawing.current) return const { x, y } = getPoint(event) ctx.lineTo(x, y) @@ -74,7 +69,7 @@ export function SignaturePad({ value, onChange }: SignaturePadProps): JSX.Elemen event.preventDefault() } - function handlePointerUp(event: PointerEvent) { + function handlePointerUp(event) { if (!drawing.current) return handlePointerMove(event) drawing.current = false diff --git a/src/components/Tabs.jsx b/src/components/Tabs.jsx new file mode 100644 index 0000000..709ed7b --- /dev/null +++ b/src/components/Tabs.jsx @@ -0,0 +1,19 @@ +export default function Tabs({ tabs, activeId, onChange }) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ) +} diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx deleted file mode 100644 index f899ab9..0000000 --- a/src/components/Tabs.tsx +++ /dev/null @@ -1,29 +0,0 @@ -interface TabOption { - id: string - label: string - badge?: number -} - -interface TabsProps { - tabs: TabOption[] - activeId: string - onChange: (id: string) => void -} - -export function Tabs({ tabs, activeId, onChange }: TabsProps): JSX.Element { - return ( -
- {tabs.map((tab) => ( - - ))} -
- ) -} diff --git a/src/components/TimerChip.tsx b/src/components/TimerChip.jsx similarity index 57% rename from src/components/TimerChip.tsx rename to src/components/TimerChip.jsx index 889cc6a..fdc6b5e 100644 --- a/src/components/TimerChip.tsx +++ b/src/components/TimerChip.jsx @@ -1,17 +1,26 @@ -import { Order } from '../types' -import { getElapsedMinutes } from '../api/orders' - -interface TimerChipProps { - order: Order +function getElapsedMinutes(order) { + if (!order) { + return 0 + } + const timestamp = order.createdAt ?? order.created_at ?? order.updatedAt + if (!timestamp) { + return 0 + } + const start = new Date(timestamp) + if (Number.isNaN(start.getTime())) { + return 0 + } + const diff = (Date.now() - start.getTime()) / 60000 + return diff > 0 ? diff : 0 } -function formatDuration(minutes: number): string { +function formatDuration(minutes) { const wholeMinutes = Math.floor(minutes) const seconds = Math.floor((minutes - wholeMinutes) * 60) return `${wholeMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } -export function TimerChip({ order }: TimerChipProps): JSX.Element { +export default function TimerChip({ order }) { const elapsed = getElapsedMinutes(order) const variant = elapsed < 20 ? 'normal' : elapsed < 35 ? 'warning' : 'priority' diff --git a/src/components/ToastContainer.tsx b/src/components/ToastContainer.jsx similarity index 64% rename from src/components/ToastContainer.tsx rename to src/components/ToastContainer.jsx index a19041b..fffc562 100644 --- a/src/components/ToastContainer.tsx +++ b/src/components/ToastContainer.jsx @@ -1,6 +1,6 @@ -import { useToast } from '../hooks/useToast' +import { useToast } from '../hooks/useToast.jsx' -export function ToastContainer(): JSX.Element { +export default function ToastContainer() { const { toasts, dismiss } = useToast() return ( @@ -11,7 +11,12 @@ export function ToastContainer(): JSX.Element { {toast.title} {toast.description ?

{toast.description}

: null}
-
diff --git a/src/components/VerifyChecklist.tsx b/src/components/VerifyChecklist.jsx similarity index 77% rename from src/components/VerifyChecklist.tsx rename to src/components/VerifyChecklist.jsx index 335518f..c91c920 100644 --- a/src/components/VerifyChecklist.tsx +++ b/src/components/VerifyChecklist.jsx @@ -1,10 +1,4 @@ -interface VerifyChecklistProps { - idChecked: boolean - paymentChecked: boolean - onChange: (next: { idChecked: boolean; paymentChecked: boolean }) => void -} - -export function VerifyChecklist({ idChecked, paymentChecked, onChange }: VerifyChecklistProps): JSX.Element { +export default function VerifyChecklist({ idChecked, paymentChecked, onChange }) { return (
diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.jsx similarity index 58% rename from src/hooks/useAuth.tsx rename to src/hooks/useAuth.jsx index 8a59be3..fc74bb8 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.jsx @@ -1,33 +1,15 @@ -import { - PropsWithChildren, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react' -import { getCurrentDriver, login as loginApi, updateDriverStatus } from '../api/auth' -import { apiClient } from '../api/client' -import { Driver, DriverStatus } from '../types' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { getCurrentDriver, login as loginApi, updateDriverStatus } from '../api/auth.ts' +import { apiClient } from '../api/client.ts' -interface AuthContextValue { - driver?: Driver - token?: string - loading: boolean - login: (email: string, password: string) => Promise - logout: () => void - setStatus: (status: DriverStatus) => Promise -} - -const AuthContext = createContext(undefined) +const AuthContext = createContext(undefined) const TOKEN_KEY = 'jason-driver-token' const DRIVER_KEY = 'jason-driver-profile' -export function AuthProvider({ children }: PropsWithChildren): JSX.Element { - const [driver, setDriver] = useState(undefined) - const [token, setToken] = useState(undefined) +export function AuthProvider({ children }) { + const [driver, setDriver] = useState() + const [token, setToken] = useState() const [loading, setLoading] = useState(true) useEffect(() => { @@ -39,7 +21,7 @@ export function AuthProvider({ children }: PropsWithChildren): JSX.Element { } if (storedDriver) { try { - setDriver(JSON.parse(storedDriver) as Driver) + setDriver(JSON.parse(storedDriver)) } catch (error) { console.warn('Failed to parse stored driver', error) } @@ -47,7 +29,7 @@ export function AuthProvider({ children }: PropsWithChildren): JSX.Element { setLoading(false) }, []) - const persist = useCallback((nextDriver: Driver, nextToken?: string) => { + const persist = useCallback((nextDriver, nextToken) => { setDriver(nextDriver) if (nextToken) { setToken(nextToken) @@ -57,15 +39,18 @@ export function AuthProvider({ children }: PropsWithChildren): JSX.Element { sessionStorage.setItem(DRIVER_KEY, JSON.stringify(nextDriver)) }, []) - const login = useCallback(async (email: string, password: string) => { - setLoading(true) - try { - const response = await loginApi({ email, password }) - persist(response.driver, response.token) - } finally { - setLoading(false) - } - }, [persist]) + const login = useCallback( + async (email, password) => { + setLoading(true) + try { + const response = await loginApi({ email, password }) + persist(response.driver, response.token) + } finally { + setLoading(false) + } + }, + [persist], + ) const logout = useCallback(() => { setDriver(undefined) @@ -95,13 +80,16 @@ export function AuthProvider({ children }: PropsWithChildren): JSX.Element { bootstrap() }, [logout, persist, token]) - const setStatus = useCallback(async (status: DriverStatus) => { - if (!driver) return - const updated = await updateDriverStatus(status) - persist(updated, token) - }, [driver, persist, token]) + const setStatus = useCallback( + async (status) => { + if (!driver) return + const updated = await updateDriverStatus(status) + persist(updated, token) + }, + [driver, persist, token], + ) - const value = useMemo( + const value = useMemo( () => ({ driver, token, loading, login, logout, setStatus }), [driver, token, loading, login, logout, setStatus], ) @@ -109,7 +97,7 @@ export function AuthProvider({ children }: PropsWithChildren): JSX.Element { return {children} } -export function useAuth(): AuthContextValue { +export function useAuth() { const context = useContext(AuthContext) if (!context) { throw new Error('useAuth must be used within AuthProvider') diff --git a/src/hooks/useOrders.jsx b/src/hooks/useOrders.jsx new file mode 100644 index 0000000..8762b7d --- /dev/null +++ b/src/hooks/useOrders.jsx @@ -0,0 +1,338 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { fetchOrders, updateOrder, updateOrderStatus, uploadImage } from '../services/orderService' +import { useSocket } from './useSocket.jsx' +import { useToast } from './useToast.jsx' +import { useInterval } from './useInterval.ts' +import { useAuth } from './useAuth.jsx' + +const OrdersContext = createContext(undefined) + +function resolveStage(status) { + const normalized = (status ?? '').toString().trim().toLowerCase() + if (['accepted', 'acknowledged'].includes(normalized)) { + return 'accepted' + } + if ( + ['in progress', 'out for delivery', 'out-for-delivery', 'delivering', 'arrived'].includes( + normalized, + ) + ) { + return 'out-for-delivery' + } + if (['completed', 'delivered'].includes(normalized)) { + return 'completed' + } + return 'assigned' +} + +function coerceDate(value) { + if (!value) { + return new Date().toISOString() + } + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return new Date().toISOString() + } + return parsed.toISOString() +} + +function formatAddress(...sources) { + for (const source of sources) { + if (!source) continue + if (typeof source === 'string') { + const trimmed = source.trim() + if (trimmed) { + return trimmed + } + continue + } + if (typeof source === 'object') { + const apartment = + source.apartment ?? source.apartmentNumber ?? source.unit ?? source.suite ?? source.flat + const description = + source.description ?? + source.line1 ?? + source.street ?? + source.address1 ?? + source.address ?? + source.formatted ?? + source.formattedAddress + const line2 = source.line2 ?? source.street2 ?? source.address2 ?? '' + const city = source.city ?? source.town ?? source.locality ?? '' + const state = source.state ?? source.region ?? '' + const postal = source.zip ?? source.postalCode ?? source.postcode ?? '' + const parts = [ + apartment ? `Apt ${apartment}` : null, + description, + line2, + [city, state, postal].filter(Boolean).join(', '), + ] + .filter(Boolean) + .map((part) => part.trim()) + .filter(Boolean) + + if (parts.length > 0) { + return parts.join(', ') + } + } + } + return 'Address unavailable' +} + +function extractItems(order) { + if (!order) return [] + if (Array.isArray(order.items) && order.items.length > 0) { + return order.items.map((item, index) => ({ + id: String(item._id ?? item.id ?? index), + name: + item.name ?? + item.Description ?? + item.title ?? + item.productName ?? + `Item ${index + 1}`, + quantity: Number(item.quantity ?? item.qty ?? item.count ?? 1) || 1, + })) + } + + const products = Array.isArray(order.products) ? order.products : [] + const quantities = Array.isArray(order.qty) ? order.qty : [] + return products.map((product, index) => ({ + id: String(product._id ?? product.id ?? index), + name: product.Description ?? product.name ?? product.title ?? `Item ${index + 1}`, + quantity: Number(quantities[index]) || Number(product.quantity ?? 1) || 1, + })) +} + +function normalizeOrder(order) { + if (!order || typeof order !== 'object') { + return null + } + const idCandidate = + order._id ?? + order.id ?? + order.orderId ?? + order.orderID ?? + order.reference ?? + order.number ?? + order.uuid + if (!idCandidate) { + return null + } + const id = String(idCandidate) + const rawStatus = order.status ?? order.orderStatus ?? order.currentStatus ?? '' + const stage = resolveStage(rawStatus) + const owner = order.owner ?? order.customer ?? {} + const firstName = + owner?.name?.first ?? owner?.firstName ?? owner?.firstname ?? owner?.first ?? '' + const lastName = owner?.name?.last ?? owner?.lastName ?? owner?.lastname ?? owner?.last ?? '' + const combinedName = [firstName, lastName].filter(Boolean).join(' ') + const fallbackName = + owner?.name?.full ?? owner?.fullName ?? owner?.name ?? owner?.displayName ?? combinedName + const name = (fallbackName || 'Customer').trim() + const phone = + owner?.phone ?? owner?.phoneNumber ?? order.phone ?? order.customerPhone ?? order.contact ?? '' + const address = formatAddress(order.address, order.deliveryAddress, order.shippingAddress) + const number = + order.orderId ?? + order.number ?? + order.reference ?? + order.shortId ?? + (typeof id === 'string' && id.length >= 6 ? id.slice(-6).toUpperCase() : id) + const total = Number( + order.total ?? order.totalAmount ?? order.orderTotal ?? order.amount ?? order.paymentTotal ?? 0, + ) + const createdAt = coerceDate(order.createdAt ?? order.created_at ?? order.updatedAt) + const assignedDriverId = + order.assignedDriverId ?? + order.driverId ?? + order.driver_id ?? + order.driver?.id ?? + order.driver?._id ?? + undefined + + return { + id, + number: String(number), + total: Number.isFinite(total) ? total : 0, + status: stage, + rawStatus: rawStatus ?? '', + createdAt, + customer: { + name, + phone: phone ?? '', + address, + }, + priority: Boolean(order.priority || order.isPriority || order.priorityOrder), + items: extractItems(order), + assignedDriverId, + } +} + +export function OrdersProvider({ children }) { + const [orders, setOrders] = useState([]) + const [isFetching, setIsFetching] = useState(false) + const { subscribe } = useSocket() + const { push } = useToast() + const { token } = useAuth() + + const mergeOrder = useCallback((next) => { + const normalized = normalizeOrder(next) + if (!normalized) { + return + } + setOrders((current) => { + const existing = current.find((order) => order.id === normalized.id) + if (existing) { + return current.map((order) => + order.id === normalized.id ? { ...existing, ...normalized } : order, + ) + } + return [normalized, ...current] + }) + }, []) + + const refresh = useCallback(async () => { + if (!token) { + setOrders([]) + return + } + setIsFetching(true) + try { + const data = await fetchOrders(token) + setOrders(data.map((order) => normalizeOrder(order)).filter(Boolean)) + } catch (error) { + console.error('Failed to load orders', error) + } finally { + setIsFetching(false) + } + }, [token]) + + useEffect(() => { + refresh() + }, [refresh]) + + useInterval(refresh, 15000) + + useEffect(() => { + const unsubCreated = subscribe('ORDER_CREATED', (payload) => { + mergeOrder(payload) + const normalized = normalizeOrder(payload) + if (normalized?.number) { + push({ title: 'New delivery assigned', description: normalized.number, variant: 'info' }) + } + }) + const unsubUpdated = subscribe('ORDER_UPDATED', (payload) => { + mergeOrder(payload) + }) + return () => { + unsubCreated() + unsubUpdated() + } + }, [mergeOrder, push, subscribe]) + + const accept = useCallback( + async (orderId) => { + if (!token) { + push({ + title: 'Unable to accept order', + description: 'Authentication required.', + variant: 'error', + }) + return false + } + try { + await updateOrderStatus(orderId, 'Accepted', token) + push({ title: 'Order accepted', description: 'Order moved to Accepted.', variant: 'success' }) + await refresh() + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to accept order.' + push({ title: 'Unable to accept order', description: message, variant: 'error' }) + return false + } + }, + [push, refresh, token], + ) + + const markArrived = useCallback( + async (orderId) => { + if (!token) { + push({ + title: 'Unable to update order', + description: 'Authentication required.', + variant: 'error', + }) + return false + } + try { + await updateOrderStatus(orderId, 'In Progress', token) + push({ + title: 'Customer arrival logged', + description: 'Order is now out for delivery.', + variant: 'info', + }) + await refresh() + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to update order.' + push({ title: 'Unable to update order', description: message, variant: 'error' }) + return false + } + }, + [push, refresh, token], + ) + + const markComplete = useCallback( + async (orderId, signature) => { + if (!token) { + push({ + title: 'Unable to complete order', + description: 'Authentication required.', + variant: 'error', + }) + return false + } + try { + let signatureUrl = signature ?? undefined + if (signature && typeof signature === 'string' && signature.startsWith('data:')) { + signatureUrl = await uploadImage(signature) + } + const payload = { + _id: orderId, + status: 'Completed', + } + if (signatureUrl) { + payload.signature = signatureUrl + } + await updateOrder(payload, token) + push({ + title: 'Delivery completed', + description: 'Order marked as delivered.', + variant: 'success', + }) + await refresh() + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to complete order.' + push({ title: 'Unable to complete order', description: message, variant: 'error' }) + return false + } + }, + [push, refresh, token], + ) + + const value = useMemo( + () => ({ orders, refresh, accept, markArrived, markComplete, isFetching }), + [accept, isFetching, markArrived, markComplete, orders, refresh], + ) + + return {children} +} + +export function useOrders() { + const context = useContext(OrdersContext) + if (!context) { + throw new Error('useOrders must be used within OrdersProvider') + } + return context +} diff --git a/src/hooks/useOrders.tsx b/src/hooks/useOrders.tsx deleted file mode 100644 index 478792e..0000000 --- a/src/hooks/useOrders.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - PropsWithChildren, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react' -import { acceptOrder, completeOrder, getOrders } from '../api/orders' -import { arriveOrder } from '../api/orders' -import { Order } from '../types' -import { useSocket } from './useSocket' -import { useToast } from './useToast' -import { useInterval } from './useInterval' - -interface OrdersContextValue { - orders: Order[] - refresh: () => Promise - accept: (orderId: string) => Promise - markArrived: (orderId: string) => Promise - markComplete: (orderId: string, signature?: string) => Promise - isFetching: boolean -} - -const OrdersContext = createContext(undefined) - -export function OrdersProvider({ children }: PropsWithChildren): JSX.Element { - const [orders, setOrders] = useState([]) - const [isFetching, setIsFetching] = useState(false) - const { subscribe } = useSocket() - const { push } = useToast() - - const mergeOrder = useCallback((next: Order) => { - const normalized = { ...next } - setOrders((current) => { - const existing = current.find((order) => order.id === normalized.id) - if (existing) { - return current.map((order) => (order.id === normalized.id ? { ...existing, ...normalized } : order)) - } - return [normalized, ...current] - }) - }, []) - - const refresh = useCallback(async () => { - setIsFetching(true) - try { - const data = await getOrders() - setOrders(data.map((order) => ({ ...order }))) - } finally { - setIsFetching(false) - } - }, []) - - useEffect(() => { - refresh() - }, [refresh]) - - useInterval(refresh, 15000) - - useEffect(() => { - const unsubCreated = subscribe('ORDER_CREATED', (payload) => { - mergeOrder(payload) - push({ title: 'New delivery assigned', description: payload.number, variant: 'info' }) - }) - const unsubUpdated = subscribe('ORDER_UPDATED', (payload) => { - mergeOrder(payload) - }) - return () => { - unsubCreated() - unsubUpdated() - } - }, [mergeOrder, push, subscribe]) - - const accept = useCallback(async (orderId: string) => { - const order = await acceptOrder(orderId) - mergeOrder(order) - push({ title: 'Order accepted', description: order.number, variant: 'success' }) - }, [mergeOrder, push]) - - const markArrived = useCallback(async (orderId: string) => { - const order = await arriveOrder(orderId) - mergeOrder(order) - push({ title: 'Customer arrival logged', description: order.number, variant: 'info' }) - }, [mergeOrder, push]) - - const markComplete = useCallback(async (orderId: string, signature?: string) => { - const order = await completeOrder(orderId, signature) - mergeOrder(order) - push({ title: 'Delivery completed', description: order.number, variant: 'success' }) - }, [mergeOrder, push]) - - const value = useMemo( - () => ({ orders, refresh, accept, markArrived, markComplete, isFetching }), - [accept, isFetching, markArrived, markComplete, orders, refresh], - ) - - return {children} -} - -export function useOrders(): OrdersContextValue { - const context = useContext(OrdersContext) - if (!context) { - throw new Error('useOrders must be used within OrdersProvider') - } - return context -} diff --git a/src/hooks/useSocket.jsx b/src/hooks/useSocket.jsx new file mode 100644 index 0000000..0c71e8f --- /dev/null +++ b/src/hooks/useSocket.jsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useEffect, useMemo } from 'react' +import { createSocket } from '../ws/socket.ts' + +const SocketContext = createContext(undefined) + +export function SocketProvider({ children }) { + const socket = useMemo(() => createSocket(), []) + + useEffect(() => { + socket.connect() + return () => socket.disconnect() + }, [socket]) + + const value = useMemo( + () => ({ + socket, + subscribe: (type, handler) => socket.on(type, handler), + emit: (type, payload) => socket.emit(type, payload), + }), + [socket], + ) + + return {children} +} + +export function useSocket() { + const context = useContext(SocketContext) + if (!context) { + throw new Error('useSocket must be used within SocketProvider') + } + return context +} diff --git a/src/hooks/useSocket.tsx b/src/hooks/useSocket.tsx deleted file mode 100644 index af6ee9a..0000000 --- a/src/hooks/useSocket.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { PropsWithChildren, createContext, useContext, useEffect, useMemo } from 'react' -import { createSocket, SocketClient, SocketHandler } from '../ws/socket' - -interface SocketContextValue { - socket: SocketClient - subscribe: (type: string, handler: SocketHandler) => () => void - emit: (type: string, payload: T) => void -} - -const SocketContext = createContext(undefined) - -export function SocketProvider({ children }: PropsWithChildren): JSX.Element { - const socket = useMemo(() => createSocket(), []) - - useEffect(() => { - socket.connect() - return () => socket.disconnect() - }, [socket]) - - const value = useMemo( - () => ({ - socket, - subscribe: (type, handler) => socket.on(type, handler), - emit: (type, payload) => socket.emit(type, payload), - }), - [socket], - ) - - return {children} -} - -export function useSocket(): SocketContextValue { - const context = useContext(SocketContext) - if (!context) { - throw new Error('useSocket must be used within SocketProvider') - } - return context -} diff --git a/src/hooks/useToast.jsx b/src/hooks/useToast.jsx new file mode 100644 index 0000000..07b81f4 --- /dev/null +++ b/src/hooks/useToast.jsx @@ -0,0 +1,31 @@ +import { createContext, useCallback, useContext, useMemo, useState } from 'react' + +const ToastContext = createContext(undefined) + +export function ToastProvider({ children }) { + const [toasts, setToasts] = useState([]) + + const push = useCallback((toast) => { + const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `toast-${Date.now()}` + setToasts((current) => [...current, { id, ...toast }]) + window.setTimeout(() => { + setToasts((current) => current.filter((item) => item.id !== id)) + }, 5000) + }, []) + + const dismiss = useCallback((id) => { + setToasts((current) => current.filter((item) => item.id !== id)) + }, []) + + const value = useMemo(() => ({ toasts, push, dismiss }), [dismiss, push, toasts]) + + return {children} +} + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} diff --git a/src/hooks/useToast.tsx b/src/hooks/useToast.tsx deleted file mode 100644 index 471e131..0000000 --- a/src/hooks/useToast.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from 'react' - -export type ToastVariant = 'info' | 'success' | 'error' - -export interface ToastMessage { - id: string - title: string - description?: string - variant: ToastVariant -} - -interface ToastContextValue { - toasts: ToastMessage[] - push: (toast: Omit) => void - dismiss: (id: string) => void -} - -const ToastContext = createContext(undefined) - -export function ToastProvider({ children }: PropsWithChildren): JSX.Element { - const [toasts, setToasts] = useState([]) - - const push = useCallback((toast: Omit) => { - const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `toast-${Date.now()}` - setToasts((current) => [...current, { id, ...toast }]) - window.setTimeout(() => { - setToasts((current) => current.filter((item) => item.id !== id)) - }, 5000) - }, []) - - const dismiss = useCallback((id: string) => { - setToasts((current) => current.filter((item) => item.id !== id)) - }, []) - - const value = useMemo(() => ({ toasts, push, dismiss }), [dismiss, push, toasts]) - - return {children} -} - -export function useToast(): ToastContextValue { - const context = useContext(ToastContext) - if (!context) { - throw new Error('useToast must be used within ToastProvider') - } - return context -} diff --git a/src/main.jsx b/src/main.jsx index 84a9259..e55a62a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,16 +1,21 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' +import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' -import './index.css' import App from './App.jsx' -import { AuthProvider } from './context/AuthContext.jsx' +import './styles/globals.css' +import { AuthProvider } from './hooks/useAuth.jsx' +import { ToastProvider } from './hooks/useToast.jsx' +import { SocketProvider } from './hooks/useSocket.jsx' +import ToastContainer from './components/ToastContainer.jsx' -createRoot(document.getElementById('root')).render( - - +ReactDOM.createRoot(document.getElementById('root')).render( + + - + + + - - , + + + , ) diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index ac2a5f1..0000000 --- a/src/main.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ReactDOM from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import App from './App' -import './styles/globals.css' -import { AuthProvider } from './hooks/useAuth' -import { ToastProvider } from './hooks/useToast' -import { SocketProvider } from './hooks/useSocket' -import { ToastContainer } from './components/ToastContainer' - -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - - - - - - - - , -) diff --git a/src/routes/Chat/Thread.tsx b/src/routes/Chat/Thread.jsx similarity index 78% rename from src/routes/Chat/Thread.tsx rename to src/routes/Chat/Thread.jsx index 53935ed..195883f 100644 --- a/src/routes/Chat/Thread.tsx +++ b/src/routes/Chat/Thread.jsx @@ -1,13 +1,12 @@ -import { FormEvent, useEffect, useRef, useState } from 'react' -import { getMessages, sendMessage } from '../../api/chat' -import { Message } from '../../types' -import { useSocket } from '../../hooks/useSocket' -import { useToast } from '../../hooks/useToast' +import { useEffect, useRef, useState } from 'react' +import { getMessages, sendMessage } from '../../api/chat.ts' +import { useSocket } from '../../hooks/useSocket.jsx' +import { useToast } from '../../hooks/useToast.jsx' -export default function ChatThread(): JSX.Element { - const [messages, setMessages] = useState([]) +export default function ChatThread() { + const [messages, setMessages] = useState([]) const [input, setInput] = useState('') - const listRef = useRef(null) + const listRef = useRef(null) const { subscribe, emit } = useSocket() const { push } = useToast() @@ -21,7 +20,7 @@ export default function ChatThread(): JSX.Element { }, []) useEffect(() => { - const unsubscribe = subscribe('CHAT_MESSAGE', (payload) => { + const unsubscribe = subscribe('CHAT_MESSAGE', (payload) => { setMessages((current) => { if (current.some((message) => message.id === payload.id)) { return current @@ -41,10 +40,10 @@ export default function ChatThread(): JSX.Element { }) } - async function handleSubmit(event: FormEvent) { + async function handleSubmit(event) { event.preventDefault() if (!input.trim()) return - const optimistic: Message = { + const optimistic = { id: `optimistic-${Date.now()}`, sender: 'DRIVER', text: input, diff --git a/src/routes/Login.tsx b/src/routes/Login.jsx similarity index 53% rename from src/routes/Login.tsx rename to src/routes/Login.jsx index e3b2ab5..5c45893 100644 --- a/src/routes/Login.tsx +++ b/src/routes/Login.jsx @@ -1,9 +1,9 @@ -import { FormEvent, useState } from 'react' +import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { useAuth } from '../hooks/useAuth' -import { useToast } from '../hooks/useToast' +import { useAuth } from '../hooks/useAuth.jsx' +import { useToast } from '../hooks/useToast.jsx' -export default function LoginRoute(): JSX.Element { +export default function LoginRoute() { const { login } = useAuth() const navigate = useNavigate() const { push } = useToast() @@ -11,7 +11,7 @@ export default function LoginRoute(): JSX.Element { const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) - async function handleSubmit(event: FormEvent) { + async function handleSubmit(event) { event.preventDefault() setLoading(true) try { @@ -25,24 +25,26 @@ export default function LoginRoute(): JSX.Element { } } - function fillDemo() { - setEmail('driver@example.com') - setPassword('password123') - } - return ( -
-
-
-
+
+
+
+
+
📦
-

Jason's Delivery

-

Driver Portal

-
-
-
- +
+

Jason's Delivery

+

Driver Portal

+
+ + +
+

Sign in to continue

+

Use the credentials provided by dispatch.

+
+
+
-
+
setPassword(event.target.value)} - placeholder="Min. 6 characters" + placeholder="At least 6 characters" minLength={6} required />
- -
-

Demo: Use any email + 6+ char password

- +
+ Having trouble? + Contact your dispatcher for support.
diff --git a/src/routes/Orders/CompletedOrderCard.tsx b/src/routes/Orders/CompletedOrderCard.jsx similarity index 73% rename from src/routes/Orders/CompletedOrderCard.tsx rename to src/routes/Orders/CompletedOrderCard.jsx index 755711b..2d4dcda 100644 --- a/src/routes/Orders/CompletedOrderCard.tsx +++ b/src/routes/Orders/CompletedOrderCard.jsx @@ -1,23 +1,8 @@ -import { Order } from '../../types' -import { OrderDetail } from './OrderDetail' -import { classNames } from '../../utils/classNames' -import { formatTimeOfDay, getInitials } from '../../utils/format' +import OrderDetail from './OrderDetail.jsx' +import { classNames } from '../../utils/classNames.ts' +import { formatTimeOfDay, getInitials } from '../../utils/format.ts' -interface CompletedOrderCardProps { - order: Order - expanded: boolean - onToggle: (order: Order) => void - onArrive: (orderId: string) => Promise - onComplete: (orderId: string, signature: string) => Promise -} - -export function CompletedOrderCard({ - order, - expanded, - onToggle, - onArrive, - onComplete, -}: CompletedOrderCardProps): JSX.Element { +export default function CompletedOrderCard({ order, expanded, onToggle, onArrive, onComplete }) { const deliveredTime = formatTimeOfDay(order.createdAt) return ( diff --git a/src/routes/Orders/Index.jsx b/src/routes/Orders/Index.jsx new file mode 100644 index 0000000..3967f31 --- /dev/null +++ b/src/routes/Orders/Index.jsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useState } from 'react' +import { useOrders } from '../../hooks/useOrders.jsx' +import Tabs from '../../components/Tabs.jsx' +import OrderCard from './OrderCard.jsx' +import OrderDetail from './OrderDetail.jsx' +import CompletedOrderCard from './CompletedOrderCard.jsx' +import { useToast } from '../../hooks/useToast.jsx' +import { useAuth } from '../../hooks/useAuth.jsx' + +const tabConfig = [ + { id: 'assigned', label: 'Assigned' }, + { id: 'accepted', label: 'Accepted' }, + { id: 'out-for-delivery', label: 'Out for delivery' }, +] + +export default function OrdersRoute() { + const { orders, accept, markArrived, markComplete } = useOrders() + const { push } = useToast() + const { driver } = useAuth() + const [activeTab, setActiveTab] = useState('assigned') + const [expandedCompletedId, setExpandedCompletedId] = useState() + const [completedVisibleCount, setCompletedVisibleCount] = useState(10) + + const segmented = useMemo(() => { + const assigned = orders.filter((order) => order.status === 'assigned') + const accepted = orders.filter((order) => { + if (order.status !== 'accepted') { + return false + } + + if (!driver) return true + + return order.assignedDriverId ? order.assignedDriverId === driver.id : true + }) + const outForDelivery = orders.filter((order) => { + if (order.status !== 'out-for-delivery') { + return false + } + + if (!driver) return true + + return order.assignedDriverId ? order.assignedDriverId === driver.id : true + }) + const completed = orders + .filter((order) => { + if (order.status !== 'completed') return false + if (!driver) return true + return order.assignedDriverId ? order.assignedDriverId === driver.id : true + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + return { assigned, accepted, outForDelivery, completed } + }, [driver, orders]) + + const visibleCompletedOrders = useMemo( + () => segmented.completed.slice(0, completedVisibleCount), + [completedVisibleCount, segmented.completed], + ) + + const canShowMoreCompleted = segmented.completed.length > completedVisibleCount + + const handleAccept = async (order) => { + const success = await accept(order.id) + if (success) { + setActiveTab('accepted') + } + } + + const handleArrive = async (orderId) => { + const success = await markArrived(orderId) + if (success) { + setActiveTab('out-for-delivery') + } + } + + const handleComplete = async (orderId, signature) => { + const success = await markComplete(orderId, signature) + if (success) { + push({ title: 'Signature captured', description: 'Delivery is complete.', variant: 'success' }) + } + } + + useEffect(() => { + if (activeTab !== 'out-for-delivery') { + setExpandedCompletedId(undefined) + } + }, [activeTab]) + + useEffect(() => { + if (!expandedCompletedId) return + + const stillExists = segmented.completed.some((order) => order.id === expandedCompletedId) + if (!stillExists) { + setExpandedCompletedId(undefined) + } + }, [expandedCompletedId, segmented.completed]) + + useEffect(() => { + setCompletedVisibleCount(10) + }, [segmented.completed.length]) + + const assignedOrders = segmented.assigned + const acceptedOrders = segmented.accepted + const outForDeliveryOrders = segmented.outForDelivery + const driverFirstName = driver?.name ? driver.name.split(' ')[0] : undefined + const heroLabel = driverFirstName ? `${driverFirstName}'s Delivery` : 'Delivery overview' + + return ( +
+
+ {heroLabel} +
+

Orders

+

Keep track of every stop on your route today.

+
+
+ ({ + ...tab, + badge: + tab.id === 'assigned' + ? assignedOrders.length + : tab.id === 'accepted' + ? acceptedOrders.length + : tab.id === 'out-for-delivery' + ? outForDeliveryOrders.length + : undefined, + }))} + activeId={activeTab} + onChange={(id) => setActiveTab(id)} + /> + {activeTab === 'assigned' ? ( +
+ {assignedOrders.length === 0 ? ( +

No orders waiting on you.

+ ) : ( +
+ {assignedOrders.map((order) => ( + + ))} +
+ )} +
+ ) : activeTab === 'accepted' ? ( +
+ {acceptedOrders.length === 0 ? ( +

No accepted orders right now.

+ ) : ( +
+ {acceptedOrders.map((order) => ( + + ))} +
+ )} +
+ ) : ( +
+ {outForDeliveryOrders.length === 0 ? ( +

No stops are currently in transit.

+ ) : ( +
+ {outForDeliveryOrders.map((order) => ( + + ))} +
+ )} +
+

Recent deliveries

+ {segmented.completed.length === 0 ? ( +

No completed deliveries yet today.

+ ) : ( + <> +
+ {visibleCompletedOrders.map((order) => ( + + setExpandedCompletedId((current) => (current === next.id ? undefined : next.id)) + } + onArrive={handleArrive} + onComplete={handleComplete} + /> + ))} +
+ {canShowMoreCompleted ? ( + + ) : null} + + )} +
+
+ )} +
+ ) +} diff --git a/src/routes/Orders/Index.tsx b/src/routes/Orders/Index.tsx deleted file mode 100644 index e4684ed..0000000 --- a/src/routes/Orders/Index.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { useOrders } from '../../hooks/useOrders' -import { Tabs } from '../../components/Tabs' -import { OrderCard } from './OrderCard' -import { OrderDetail } from './OrderDetail' -import { CompletedOrderCard } from './CompletedOrderCard' -import { Order } from '../../types' -import { useToast } from '../../hooks/useToast' -import { useAuth } from '../../hooks/useAuth' - -const tabConfig = [ - { id: 'pending', label: 'Pending' }, - { id: 'active', label: 'Active' }, - { id: 'completed', label: 'Completed' }, -] - -type TabId = (typeof tabConfig)[number]['id'] - -export default function OrdersRoute(): JSX.Element { - const { orders, accept, markArrived, markComplete } = useOrders() - const { push } = useToast() - const { driver } = useAuth() - const [activeTab, setActiveTab] = useState('pending') - const [expandedCompletedId, setExpandedCompletedId] = useState(undefined) - const [completedVisibleCount, setCompletedVisibleCount] = useState(10) - - const segmented = useMemo(() => { - const pending = orders.filter((order) => order.status === 'NEW') - const active = orders.filter((order) => { - if (order.status !== 'IN_PROGRESS' && order.status !== 'ARRIVED') { - return false - } - - if (!driver) return true - - return order.assignedDriverId === driver.id - }) - const completed = orders - .filter((order) => { - if (order.status !== 'COMPLETED') return false - if (!driver) return true - return order.assignedDriverId === driver.id - }) - .sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - return { pending, active, completed } - }, [driver, orders]) - - const listForTab = useMemo(() => { - if (activeTab === 'pending') return segmented.pending - if (activeTab === 'active') return segmented.active - return segmented.completed - }, [activeTab, segmented]) - - const visibleCompletedOrders = useMemo( - () => segmented.completed.slice(0, completedVisibleCount), - [completedVisibleCount, segmented.completed], - ) - - const canShowMoreCompleted = segmented.completed.length > completedVisibleCount - - const handleAccept = async (order: Order) => { - await accept(order.id) - setActiveTab('active') - } - - const handleArrive = async (orderId: string) => { - await markArrived(orderId) - } - - const handleComplete = async (orderId: string, signature: string) => { - await markComplete(orderId, signature) - push({ title: 'Signature captured', description: 'Delivery is complete.', variant: 'success' }) - } - - useEffect(() => { - if (activeTab !== 'completed') { - setExpandedCompletedId(undefined) - } - }, [activeTab]) - - useEffect(() => { - if (!expandedCompletedId) return - - const stillExists = segmented.completed.some((order) => order.id === expandedCompletedId) - if (!stillExists) { - setExpandedCompletedId(undefined) - } - }, [expandedCompletedId, segmented.completed]) - - useEffect(() => { - setCompletedVisibleCount(10) - }, [segmented.completed.length]) - - return ( -
- ({ ...tab, badge: tab.id === 'pending' ? segmented.pending.length : undefined }))} - activeId={activeTab} - onChange={(id) => setActiveTab(id as TabId)} - /> - {activeTab === 'pending' ? ( -
-
- {listForTab.length === 0 ? ( -

No orders in this state.

- ) : ( - listForTab.map((order) => ( - - )) - )} -
-
- ) : activeTab === 'active' ? ( -
- {listForTab.length === 0 ? ( -

No orders in this state.

- ) : ( - listForTab.map((order) => ( - - )) - )} -
- ) : ( -
- {segmented.completed.length === 0 ? ( -

No orders in this state.

- ) : ( - <> -
- {visibleCompletedOrders.map((order) => ( - - setExpandedCompletedId((current) => - current === next.id ? undefined : next.id, - ) - } - onArrive={handleArrive} - onComplete={handleComplete} - /> - ))} -
- {canShowMoreCompleted ? ( - - ) : null} - - )} -
- )} -
- ) -} diff --git a/src/routes/Orders/OrderCard.tsx b/src/routes/Orders/OrderCard.jsx similarity index 77% rename from src/routes/Orders/OrderCard.tsx rename to src/routes/Orders/OrderCard.jsx index 6811b35..cea1510 100644 --- a/src/routes/Orders/OrderCard.tsx +++ b/src/routes/Orders/OrderCard.jsx @@ -1,18 +1,10 @@ -import { Order } from '../../types' -import { formatCurrency, getInitials } from '../../utils/format' -import { TimerChip } from '../../components/TimerChip' -import { classNames } from '../../utils/classNames' +import { formatCurrency, getInitials } from '../../utils/format.ts' +import TimerChip from '../../components/TimerChip.jsx' +import { classNames } from '../../utils/classNames.ts' -interface OrderCardProps { - order: Order - onAccept?: (order: Order) => void - onSelect?: (order: Order) => void - isSelected?: boolean -} - -export function OrderCard({ order, onAccept, onSelect, isSelected }: OrderCardProps): JSX.Element { - const isPending = order.status === 'NEW' - const statusLabel = order.status === 'COMPLETED' ? 'Completed' : undefined +export default function OrderCard({ order, onAccept, onSelect, isSelected }) { + const isPending = order.status === 'assigned' + const statusLabel = order.status === 'completed' ? 'Completed' : undefined const isSelectable = Boolean(onSelect) return ( diff --git a/src/routes/Orders/OrderDetail.tsx b/src/routes/Orders/OrderDetail.jsx similarity index 79% rename from src/routes/Orders/OrderDetail.tsx rename to src/routes/Orders/OrderDetail.jsx index 72bbf7e..9680dde 100644 --- a/src/routes/Orders/OrderDetail.tsx +++ b/src/routes/Orders/OrderDetail.jsx @@ -1,32 +1,26 @@ import { useEffect, useMemo, useState } from 'react' -import { Order } from '../../types' -import { formatCurrency, getInitials } from '../../utils/format' -import { TimerChip } from '../../components/TimerChip' -import { VerifyChecklist } from '../../components/VerifyChecklist' -import { SignaturePad } from '../../components/SignaturePad' +import { formatCurrency, getInitials } from '../../utils/format.ts' +import TimerChip from '../../components/TimerChip.jsx' +import VerifyChecklist from '../../components/VerifyChecklist.jsx' +import SignaturePad from '../../components/SignaturePad.jsx' -interface OrderDetailProps { - order: Order - onArrive: (orderId: string) => Promise - onComplete: (orderId: string, signature: string) => Promise -} - -export function OrderDetail({ order, onArrive, onComplete }: OrderDetailProps): JSX.Element { +export default function OrderDetail({ order, onArrive, onComplete }) { const [idChecked, setIdChecked] = useState(false) const [paymentChecked, setPaymentChecked] = useState(false) - const [signature, setSignature] = useState(null) + const [signature, setSignature] = useState(null) const [submitting, setSubmitting] = useState(false) + const stage = order.status ?? 'assigned' useEffect(() => { setIdChecked(false) setPaymentChecked(false) setSignature(null) setSubmitting(false) - }, [order.id, order.status]) + }, [order.id, stage]) const canComplete = idChecked && paymentChecked && Boolean(signature) - const showVerification = order.status === 'ARRIVED' - const showArriveButton = order.status === 'IN_PROGRESS' + const showVerification = stage === 'out-for-delivery' + const showArriveButton = stage === 'accepted' const mapsQuery = useMemo(() => encodeURIComponent(order.customer.address), [order.customer.address]) @@ -99,12 +93,7 @@ export function OrderDetail({ order, onArrive, onComplete }: OrderDetailProps):
) : null} {showArriveButton ? ( - ) : null} @@ -131,7 +120,7 @@ export function OrderDetail({ order, onArrive, onComplete }: OrderDetailProps): Complete Delivery
- ) : order.status === 'COMPLETED' ? ( + ) : stage === 'completed' ? (
Delivery completed · Signature on file.
) : null} diff --git a/src/routes/Profile.tsx b/src/routes/Profile.jsx similarity index 83% rename from src/routes/Profile.tsx rename to src/routes/Profile.jsx index 3b80c06..1e3a62f 100644 --- a/src/routes/Profile.tsx +++ b/src/routes/Profile.jsx @@ -1,20 +1,15 @@ import { useState } from 'react' import { useOutletContext } from 'react-router-dom' -import { useAuth } from '../hooks/useAuth' -import { useToast } from '../hooks/useToast' -import { DriverStatus } from '../types' +import { useAuth } from '../hooks/useAuth.jsx' +import { useToast } from '../hooks/useToast.jsx' -interface AppShellContext { - trackingActive: boolean -} - -export default function ProfileRoute(): JSX.Element { - const { trackingActive } = useOutletContext() +export default function ProfileRoute() { + const { trackingActive } = useOutletContext() const { driver, logout, setStatus } = useAuth() const { push } = useToast() const [updating, setUpdating] = useState(false) - async function handleStatusChange(nextStatus: DriverStatus) { + async function handleStatusChange(nextStatus) { if (!driver || driver.status === nextStatus) return setUpdating(true) try { diff --git a/src/styles/globals.css b/src/styles/globals.css index a93c382..ad64b80 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -13,7 +13,11 @@ body { margin: 0; min-height: 100vh; color: #1f2937; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg,#667eea,#764ba2); +} + +#root { + min-height: 100vh; } a { @@ -24,7 +28,7 @@ button { font: inherit; } -.app-container { +.driver-shell { max-width: 430px; margin: 0 auto; min-height: 100vh; @@ -35,117 +39,80 @@ button { box-shadow: 0 0 40px rgba(0, 0, 0, 0.15); } -.app-main { +.driver-shell__main { flex: 1; overflow-y: auto; - padding-bottom: 80px; + padding: 20px 20px 100px; } -.app-header { +.driver-shell__header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - padding: 20px; + padding: 24px 20px; display: flex; align-items: center; justify-content: space-between; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); position: sticky; top: 0; - z-index: 20; + z-index: 30; } -.app-title { - margin: 0; - font-size: 20px; - font-weight: 600; +.driver-shell__brand { + display: flex; + flex-direction: column; + gap: 4px; } -.app-title-link { +.driver-shell__brand-link { color: inherit; text-decoration: none; + font-size: 20px; + font-weight: 600; +} + +.driver-shell__brand-subtitle { + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + opacity: 0.8; } -.driver-pill { +.driver-shell__status { display: flex; align-items: center; - gap: 10px; - font-size: 14px; + gap: 12px; background: rgba(255, 255, 255, 0.2); - padding: 8px 12px; + padding: 8px 14px; border-radius: 999px; } -.status-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: #9ca3af; - position: relative; -} - -.status-dot::after { - content: ''; - position: absolute; - inset: -4px; - border-radius: inherit; - border: 1px solid rgba(255, 255, 255, 0.4); -} - -.status-dot.online { - background: #4ade80; - animation: pulse 2s infinite; -} - -.status-dot.onduty { - background: #facc15; - animation: pulse 2s infinite; -} - -.status-dot.offline { - background: #ef4444; -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.driver-pill-text { +.driver-shell__status-text { display: flex; flex-direction: column; line-height: 1.1; } -.driver-pill-name { +.driver-shell__status-name { font-weight: 600; font-size: 13px; } -.driver-pill-status { - font-size: 11px; - opacity: 0.9; -} - -.tracking-indicator { +.driver-shell__tracking-chip { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; border: 1px solid rgba(255, 255, 255, 0.4); - background: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.2); } -.tracking-indicator.active { +.driver-shell__tracking-chip--active { background: rgba(16, 185, 129, 0.2); } -.bottom-nav { +.driver-shell__nav { position: sticky; bottom: 0; left: 0; @@ -157,7 +124,7 @@ button { padding: 10px 0; } -.nav-item { +.driver-shell__nav-item { flex: 1; display: flex; flex-direction: column; @@ -166,146 +133,208 @@ button { color: #6b7280; text-decoration: none; font-size: 12px; + padding: 8px 0; } -.nav-item span:first-child { +.driver-shell__nav-item span:first-child { font-size: 20px; } -.nav-item.active { +.driver-shell__nav-item--active { color: #667eea; font-weight: 600; } -.login-container { +.driver-login { + position: relative; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; - padding: 20px; + padding: 32px 16px; + overflow: hidden; } -.login-content { - width: 100%; - max-width: 420px; +.driver-login__glow { + position: absolute; + inset: 10% 0; + margin: auto; + width: min(420px, 80vw); + max-width: 440px; + height: 70%; + border-radius: 999px; + background: rgba(255, 255, 255, 0.25); + filter: blur(80px); + opacity: 0.9; } -.login-logo { +.driver-login__panel { + position: relative; + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 28px; + align-items: center; text-align: center; - margin-bottom: 40px; color: white; } -.logo-icon { - width: 100px; - height: 100px; - border-radius: 25px; - background: rgba(255, 255, 255, 0.15); +.driver-login__brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.driver-login__logo { + width: 88px; + height: 88px; + border-radius: 24px; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.05) 100%); display: grid; place-items: center; - font-size: 48px; - margin: 0 auto 20px; - backdrop-filter: blur(10px); - box-shadow: 0 20px 45px rgba(0, 0, 0, 0.2); + font-size: 40px; + box-shadow: 0 24px 60px rgba(17, 24, 39, 0.4); + backdrop-filter: blur(18px); } -.login-form { - background: white; - border-radius: 20px; - padding: 30px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); +.driver-login__brand-text h1 { + margin: 0; + font-size: 28px; + font-weight: 700; +} + +.driver-login__brand-text p { + margin: 4px 0 0; + font-size: 14px; + letter-spacing: 0.24em; + text-transform: uppercase; + opacity: 0.85; +} + +.driver-login__form { + width: 100%; + background: rgba(255, 255, 255, 0.92); + border-radius: 22px; + padding: 34px 32px; + box-shadow: 0 32px 70px rgba(17, 24, 39, 0.25); + display: flex; + flex-direction: column; + gap: 20px; + color: #1f2937; + backdrop-filter: blur(8px); +} + +.driver-login__form-header { + display: flex; + flex-direction: column; + gap: 6px; + text-align: left; +} + +.driver-login__form-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #1f2937; +} + +.driver-login__form-header p { + margin: 0; + font-size: 14px; + color: #6b7280; } -.form-group { +.driver-login__field { display: flex; flex-direction: column; - margin-bottom: 20px; + gap: 8px; } -.form-group label { +.driver-login__field label { font-size: 12px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; color: #4b5563; - margin-bottom: 8px; } -.form-group input { - border: 2px solid #e5e7eb; - border-radius: 10px; +.driver-login__field input { + border: 1px solid #d1d5db; + border-radius: 12px; padding: 14px 16px; font-size: 16px; background: #f9fafb; - transition: border-color 0.3s ease, box-shadow 0.3s ease; + transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease; } -.form-group input:focus { +.driver-login__field input:focus { outline: none; - border-color: #667eea; - background: white; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); + border-color: #6366f1; + background: #fff; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.18); } -.login-btn { +.driver-login__submit { width: 100%; padding: 16px; - border-radius: 10px; + border-radius: 12px; border: none; cursor: pointer; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%); color: white; font-weight: 600; - letter-spacing: 0.6px; + letter-spacing: 0.08em; text-transform: uppercase; - transition: transform 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: 0 16px 40px rgba(99, 102, 241, 0.35); } -.login-btn:disabled { +.driver-login__submit:disabled { opacity: 0.7; cursor: wait; + box-shadow: none; } -.login-btn:not(:disabled):active { - transform: scale(0.98); +.driver-login__submit:not(:disabled):active { + transform: translateY(1px); } -.login-footer { +.driver-login__helper { text-align: center; - margin-top: 25px; - padding-top: 25px; - border-top: 1px solid #e5e7eb; -} - -.demo-note { - color: #667eea; + margin-top: 4px; + padding-top: 16px; + border-top: 1px solid rgba(209, 213, 219, 0.8); + display: flex; + flex-direction: column; + gap: 8px; font-size: 12px; - margin-bottom: 10px; + color: #6366f1; } -.demo-button { - background: transparent; - border: 1px solid #667eea; - color: #667eea; - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; - font-size: 12px; +.driver-orders { + display: flex; + flex-direction: column; + gap: 20px; + min-height: 100%; } -.tabs { +.driver-orders__tabs { display: flex; background: white; border-bottom: 1px solid #e5e7eb; position: sticky; top: 0; z-index: 10; + margin: -20px -20px 0; + padding: 0 20px; } -.tab { +.driver-orders__tab { flex: 1; - padding: 15px; + padding: 16px; text-align: center; font-weight: 500; color: #6b7280; @@ -316,11 +345,11 @@ button { font-size: 14px; } -.tab.active { +.driver-orders__tab--active { color: #667eea; } -.tab.active::after { +.driver-orders__tab--active::after { content: ''; position: absolute; left: 0; @@ -330,59 +359,680 @@ button { background: #667eea; } -.tab-badge { +.driver-orders__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + min-width: 22px; + padding: 2px 6px; + border-radius: 999px; background: #ef4444; color: white; font-size: 11px; - padding: 2px 6px; - border-radius: 999px; - margin-left: 6px; + font-weight: 600; } -.orders-page { +.driver-orders__content { display: flex; flex-direction: column; - min-height: 100%; + gap: 20px; + padding-top: 20px; } -.orders-content { +.driver-orders__section { display: flex; flex-direction: column; - gap: 20px; - padding: 20px; + gap: 16px; +} + +.driver-orders__empty { + text-align: center; + color: #6b7280; + padding: 40px 20px; } -.pending-orders { +.driver-orders__completed-list { display: flex; flex-direction: column; - gap: 20px; + gap: 12px; +} + +.driver-orders__see-more { + border: none; + background: #667eea; + color: white; + padding: 12px 16px; + border-radius: 10px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.driver-order-card { + background: white; + border-radius: 16px; padding: 20px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.driver-order-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: #667eea; +} + +.driver-order-card--priority::before { + background: #dc2626; +} + +.driver-order-card--selected { + box-shadow: 0 12px 30px rgba(102, 126, 234, 0.25); + transform: translateY(-2px); +} + +.driver-order-card--static { + cursor: default; } -.active-orders { +.driver-order-card__header { display: flex; - flex-direction: column; - gap: 20px; - padding: 20px; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; } -.orders-list { +.driver-order-card__number { + font-size: 18px; + font-weight: 600; +} + +.driver-order-card__customer { display: flex; - flex-direction: column; - gap: 15px; + align-items: center; + gap: 12px; + padding: 12px; + background: #f9fafb; + border-radius: 10px; + margin-bottom: 16px; +} + +.driver-order-card__avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: grid; + place-items: center; + font-weight: 600; } -.completed-orders { +.driver-order-card__customer-details { display: flex; flex-direction: column; - gap: 16px; - padding: 20px; + gap: 4px; } -.completed-orders-list { +.driver-order-card__customer-details h3 { + margin: 0; + font-size: 14px; +} + +.driver-order-card__phone { + text-decoration: none; + color: #667eea; + font-size: 12px; + font-weight: 600; +} + +.driver-order-card__address { display: flex; - flex-direction: column; gap: 12px; + margin-bottom: 16px; +} + +.driver-order-card__address-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: #ede9fe; + display: grid; + place-items: center; + font-size: 16px; +} + +.driver-order-card__address h4 { + margin: 0 0 4px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7280; +} + +.driver-order-card__address p { + margin: 0; + line-height: 1.4; +} + +.driver-order-card__address-links { + margin-top: 10px; + display: flex; + gap: 12px; +} + +.driver-order-card__address-links a { + color: #667eea; + font-size: 12px; + font-weight: 600; + text-decoration: none; +} + +.driver-order-card__items { + background: #f9fafb; + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} + +.driver-order-card__items h4 { + margin: 0 0 12px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7280; +} + +.driver-order-card__items ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.driver-order-card__items li { + display: flex; + justify-content: space-between; + font-size: 14px; + color: #1f2937; +} + +.driver-order-card__total { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #f3f4f6; + border-radius: 10px; + font-weight: 600; + margin-bottom: 16px; +} + +.driver-order-card__primary-action { + width: 100%; + padding: 16px; + border-radius: 12px; + border: none; + cursor: pointer; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.driver-active-order { + background: white; + border-radius: 16px; + padding: 22px; + box-shadow: 0 18px 40px rgba(79, 70, 229, 0.15); + display: flex; + flex-direction: column; + gap: 16px; +} + +.driver-active-order__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.driver-active-order__number { + font-size: 18px; + font-weight: 600; +} + +.driver-active-order__customer { + display: flex; + align-items: center; + gap: 16px; + padding: 14px; + background: #f9fafb; + border-radius: 12px; +} + +.driver-active-order__avatar { + width: 52px; + height: 52px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: grid; + place-items: center; + font-weight: 600; +} + +.driver-active-order__customer-details { + display: flex; + flex-direction: column; + gap: 4px; +} + +.driver-active-order__customer-details h3 { + margin: 0; + font-size: 16px; +} + +.driver-active-order__phone { + text-decoration: none; + color: #667eea; + font-weight: 600; +} + +.driver-active-order__address { + display: flex; + gap: 12px; +} + +.driver-active-order__address-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: #ede9fe; + display: grid; + place-items: center; +} + +.driver-active-order__address h4 { + margin: 0 0 4px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7280; +} + +.driver-active-order__address p { + margin: 0; + line-height: 1.4; +} + +.driver-active-order__address-links { + margin-top: 10px; + display: flex; + gap: 12px; +} + +.driver-active-order__address-links a { + color: #667eea; + font-size: 12px; + font-weight: 600; + text-decoration: none; +} + +.driver-active-order__total { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #f3f4f6; + border-radius: 12px; + font-weight: 600; +} + +.driver-active-order__items { + background: #f9fafb; + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.driver-active-order__items h4 { + margin: 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7280; +} + +.driver-active-order__items ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.driver-active-order__items li { + display: flex; + gap: 12px; + font-size: 14px; +} + +.driver-active-order__primary { + width: 100%; + padding: 16px; + border-radius: 12px; + border: none; + cursor: pointer; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + background: #10b981; + color: white; +} + +.driver-active-order__verification { + display: flex; + flex-direction: column; + gap: 16px; +} + +.driver-active-order__signature h3 { + margin: 0 0 12px; +} + +.driver-active-order__complete-banner { + padding: 14px; + border-radius: 12px; + background: rgba(16, 185, 129, 0.15); + color: #047857; + font-weight: 600; + text-align: center; +} + +.driver-completed-card { + display: flex; + flex-direction: column; +} + +.driver-completed-card__summary { + background: white; + border: none; + border-radius: 16px; + padding: 20px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + gap: 16px; + text-align: left; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.driver-completed-card__summary:focus { + outline: 2px solid #667eea; + outline-offset: 3px; +} + +.driver-completed-card__summary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(102, 126, 234, 0.2); +} + +.driver-completed-card--expanded .driver-completed-card__summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.driver-completed-card__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.driver-completed-card__number { + font-size: 16px; + font-weight: 600; +} + +.driver-completed-card__meta { + display: flex; + align-items: center; + gap: 8px; +} + +.driver-completed-card__tag { + padding: 6px 10px; + border-radius: 8px; + background: #d1fae5; + color: #047857; + font-size: 12px; + font-weight: 600; +} + +.driver-completed-card__caret { + color: #6b7280; + font-size: 14px; +} + +.driver-completed-card--expanded .driver-completed-card__caret { + transform: rotate(180deg); +} + +.driver-completed-card__body { + display: flex; + align-items: center; + gap: 12px; +} + +.driver-completed-card__avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: #ede9fe; + display: grid; + place-items: center; + font-weight: 600; + color: #4c1d95; +} + +.driver-completed-card__details { + display: flex; + flex-direction: column; + gap: 4px; +} + +.driver-completed-card__details h3 { + margin: 0; + font-size: 16px; +} + +.driver-completed-card__details p { + margin: 0; + color: #6b7280; + font-size: 13px; +} + +.driver-completed-card__detail { + background: white; + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + box-shadow: 0 18px 40px rgba(79, 70, 229, 0.15); + margin-top: -2px; +} + +.tabs { + display: flex; + gap: 8px; + background: rgba(255, 255, 255, 0.9); + border-radius: 16px; + padding: 6px; + position: sticky; + top: 16px; + z-index: 10; + box-shadow: 0 12px 30px rgba(79, 70, 229, 0.15); + backdrop-filter: blur(14px); + margin-top: -18px; +} + +.tab { + flex: 1; + padding: 14px 0; + text-align: center; + font-weight: 600; + color: #5b21b6; + cursor: pointer; + background: transparent; + border: none; + border-radius: 12px; + position: relative; + font-size: 13px; + letter-spacing: 0.4px; + transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.tab.active { + color: white; + background: linear-gradient(135deg, #667eea, #764ba2); + box-shadow: 0 12px 24px rgba(102, 126, 234, 0.35); +} + +.tab.active::after { + display: none; +} + +.tab-badge { + background: rgba(102, 126, 234, 0.18); + color: #4338ca; + font-size: 11px; + padding: 3px 8px; + border-radius: 999px; + margin-left: 8px; + font-weight: 600; +} + +.orders-page { + display: flex; + flex-direction: column; + gap: 20px; + min-height: 100%; +} + +.orders-hero { + position: relative; + overflow: hidden; + border-radius: 20px; + padding: 24px 24px 28px; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.15), rgba(118, 75, 162, 0.25)); + backdrop-filter: blur(14px); + box-shadow: 0 24px 50px rgba(79, 70, 229, 0.2); + display: flex; + flex-direction: column; + gap: 16px; +} + +.orders-hero::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.45), transparent 60%); + opacity: 0.6; + pointer-events: none; +} + +.orders-hero__badge { + position: relative; + align-self: flex-start; + padding: 6px 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.85); + color: #4c1d95; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; +} + +.orders-hero__heading { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; +} + +.orders-hero__heading h1 { + margin: 0; + font-size: 26px; + color: #312e81; +} + +.orders-hero__heading p { + margin: 0; + color: #4338ca; + font-size: 14px; + opacity: 0.85; +} + +.orders-section { + display: flex; + flex-direction: column; + gap: 24px; +} + +.orders-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.orders-detail-stack { + display: flex; + flex-direction: column; + gap: 20px; +} + +.completed-orders-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.orders-history { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + background: rgba(255, 255, 255, 0.88); + border-radius: 18px; + box-shadow: 0 12px 35px rgba(102, 126, 234, 0.18); +} + +.orders-history h2 { + margin: 0; + font-size: 18px; + color: #312e81; +} + +.orders-history__empty { + margin: 0; + color: #6b7280; + font-size: 14px; } .completed-order-card { @@ -1081,16 +1731,15 @@ button { right: calc(50% - 210px - 20px); } - .orders-content { - flex-direction: row; - align-items: flex-start; + .tabs { + top: 24px; } - .orders-list { - flex: 1; + .orders-section { + gap: 28px; } - .order-detail { - flex: 1; + .orders-detail-stack { + gap: 24px; } } diff --git a/src/ws/socket.ts b/src/ws/socket.ts index 070a239..7f5a09b 100644 --- a/src/ws/socket.ts +++ b/src/ws/socket.ts @@ -1,4 +1,4 @@ -import { mockMessages, mockOrders } from '../api/mockData' +import { mockMessages } from '../api/mockData' import { SocketEvent } from '../types' export type SocketHandler = (payload: T) => void @@ -82,12 +82,10 @@ export class MockSocket extends SocketClient { connect(): void { this.timer = window.setInterval(() => { - const newest = mockOrders.find((order) => order.status === 'NEW') - if (newest) { - this.dispatch('ORDER_UPDATED', newest) - } const lastMessage = mockMessages[mockMessages.length - 1] - this.dispatch('CHAT_MESSAGE', lastMessage) + if (lastMessage) { + this.dispatch('CHAT_MESSAGE', lastMessage) + } }, 15000) }