feat: add frontend in /frontend
2854
dist-worker/unified-worker.js
Normal file
4
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.env
|
||||||
26
frontend/components.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"catalogVersion": "shadcn-radix-v1",
|
||||||
|
"style": "radix",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"lucide-react": "^0.378.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"tailwind-merge": "^2.0.0"
|
||||||
|
},
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/design/DESIGN.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# RideFlow design preview
|
||||||
|
|
||||||
|
**Personality:** Urban Signal Precision
|
||||||
|
|
||||||
|
Tactical urban transit utility rendered with geometric clarity, signal-level status accenting, and instant state feedback.
|
||||||
|
|
||||||
|
**Design read:** Reading RideFlow as a high-velocity, real-time urban transit platform demanding spatial clarity, instant state feedback, and driver-centric legibility under active movement. The visual language marries clean map-first interfaces with high-contrast signal status indicators for seamless transition between passenger booking and driver navigation.
|
||||||
|
|
||||||
|
## Taste dials
|
||||||
|
- Design variance: 6/10
|
||||||
|
- Motion intensity: 5/10
|
||||||
|
- Visual density: 4/10
|
||||||
|
|
||||||
|
## System
|
||||||
|
- Direction: High-Contrast Transit Navigation
|
||||||
|
- Default mode: light
|
||||||
|
- Display font: Plus Jakarta Sans
|
||||||
|
- Body font: DM Sans
|
||||||
|
- Landing composition: Asymmetric hero pairing a dynamic interactive route preview stage on the right with a bold, left-aligned booking callout. Features transition into structured split-screen workstation previews.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
### landing
|
||||||
|
Navigation: Top-tier sticky header navigation bar with brand logotype, quick links for Passenger and Driver role features, and a prominent M-Auth sign-in button.
|
||||||
|
- Public Landing Shell (/*): Introduce RideFlow transit capabilities, showcase live map routing and driver tracking, and route incoming passengers and drivers into M-Auth login.
|
||||||
|
- hero-interactive-preview: Asymmetric Interactive Hero Stage - High-contrast light slate canvas (#f8fafc) with crisp border enclosure around vector route preview. Telemetry blue accents draw eyes to pickup actions.
|
||||||
|
- dual-role-workstation-overview: Split Navigation Workstation Showcase - Side-by-side elevated slate cards with 1px border outlines (#cbd5e1), signal badges in emerald green and telemetry blue.
|
||||||
|
- live-telemetry-capabilities: Linear Dual-Workflow Process Strip - Full-bleed muted slate strip (#f1f5f9) delimited by sharp top and bottom hairline borders (#e2e8f0).
|
||||||
|
- safety-companion-framework: Full-Width Signal Capability Deck - Deep dark inverse surface (#0f172a) with crisp white typography and emerald verification accents for high visual contrast.
|
||||||
|
- authentication-access-cta: Centered High-Contrast Signal Banner - Crisp slate background with dark primary CTA button (#0f172a) and telemetry blue focus rings.
|
||||||
|
|
||||||
|
### application
|
||||||
|
Navigation: Top bar shell with prominent active trip banner, role-aware navigation switches (Passenger / Driver / Circles / History), and drawer access on small screens.
|
||||||
|
- Onboarding & Safety Setup (/app/onboarding): Guide new users through role selection, driver vehicle registration, and private companion safety consent setup.
|
||||||
|
- role-selection: Linear Single-Column Setup Form - Crisp white bordered surface with signal indigo selected state
|
||||||
|
- driver-vehicle-form: Linear Single-Column Setup Form - Muted slate inset form field group with distinct border boundaries
|
||||||
|
- companion-safety-profile: Linear Single-Column Setup Form - Light slate card with emerald verified border accenting
|
||||||
|
- Passenger Workstation (/app/ride): Interactive map interface for booking rides, estimating route fare, tracking driver movement live, and rating completed trips.
|
||||||
|
- booking-panel: Floating Bottom Sheet over Interactive Canvas - Crisp surface with high-contrast primary button and signal accents
|
||||||
|
- live-tracking-panel: Floating Bottom Sheet over Interactive Canvas - Slate inverse card background with vibrant telemetry status badges
|
||||||
|
- trip-rating-modal: Linear Single-Column Setup Form - Clean dialog with crisp divider and prominent primary star selectors
|
||||||
|
- Driver Workstation (/app/driver): Control panel for drivers to toggle online availability, review incoming ride requests, navigate active trips, and report live GPS location.
|
||||||
|
- availability-header: Split View Navigation Workstation - Dark slate bar with bright emerald status glow when online
|
||||||
|
- incoming-requests: Split View Navigation Workstation - High-contrast white cards with thick slate left border
|
||||||
|
- active-navigation-controls: Floating Bottom Sheet over Interactive Canvas - Tactical dark bottom bar with large high-contrast primary action button
|
||||||
|
- Companion Circles & Activity Hub (/app/circles): View companion circles, handle invitations, manage recurring activity plans, and register RSVPs.
|
||||||
|
- circles-overview: Split View Navigation Workstation - Bordered card stack with crisp badge indicators
|
||||||
|
- activity-planning: Linear Single-Column Setup Form - Clean dialog surface with clear form row grouping
|
||||||
|
- activity-rsvp-list: Split View Navigation Workstation - Crisp white card with left emerald accent line for 'Going' status
|
||||||
|
- Ride & Activity History (/app/history): Comprehensive log of past ride bookings and companion activities with statuses, drivers, timestamps, and ratings.
|
||||||
|
- history-filters: Dense Tactical Data Table - Light slate filter toolbar with clear active tab indicator
|
||||||
|
- history-table-list: Dense Tactical Data Table - Dense table with subtle hover row highlights and crisp status badges
|
||||||
|
|
||||||
|
### admin
|
||||||
|
Navigation: Sidebar Navigation with Top System Header and Tabbed Context Panels
|
||||||
|
- System Overview (/admin): Provide system operators with real-time operational telemetry, active ride counts, user volume stats, and system health status.
|
||||||
|
- admin-kpi-summary: Dense Tactical Data Table - Slate container with crisp subtle borders, emerald telemetry badge indicators, and high-contrast bold typography.
|
||||||
|
- admin-quick-navigation: Split View Navigation Workstation - Clean slate background with strong dark borders and distinct action buttons.
|
||||||
|
- User Directory (/admin/users): Enable system operators to inspect registered passengers, drivers, and admin accounts in a structured table.
|
||||||
|
- user-table-controls: Linear Single-Column Setup Form - Framed surface bar with muted slate border and explicit input labels.
|
||||||
|
- user-directory-table: Dense Tactical Data Table - Clean white table surface with subtle slate row dividers and high-contrast text contrast.
|
||||||
|
- System Ride Logs (/admin/rides): Allow system admins to audit all historical and active ride requests, driver assignments, locations, status progressions, and passenger ratings.
|
||||||
|
- ride-log-filters: Split View Navigation Workstation - Slate tabs with active slate-900 high-contrast underline visual indicator.
|
||||||
|
- ride-log-table: Dense Tactical Data Table - Tabular grid with alternating slight surface contrast for easy line scanning.
|
||||||
|
|
||||||
|
## Generated image placements
|
||||||
|
- landing-01-hero-interactive-preview: landing /* / hero-interactive-preview
|
||||||
|
- landing-02-dual-role-workstation-overview: landing /* / dual-role-workstation-overview
|
||||||
|
- landing-03-live-telemetry-capabilities: landing /* / live-telemetry-capabilities
|
||||||
|
- landing-04-safety-companion-framework: landing /* / safety-companion-framework
|
||||||
|
- application-01-role-selection: application /app/onboarding / role-selection
|
||||||
|
- application-02-driver-vehicle-form: application /app/onboarding / driver-vehicle-form
|
||||||
|
- admin-01-admin-kpi-summary: admin /admin / admin-kpi-summary
|
||||||
93
frontend/design/asset-manifest.json
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "landing-01-hero-interactive-preview",
|
||||||
|
"src": "/assets/landing-01-hero-interactive-preview.png",
|
||||||
|
"alt": "Interactive Vector Route Canvas for RideFlow",
|
||||||
|
"role": "Interactive Vector Route Canvas",
|
||||||
|
"width": 1600,
|
||||||
|
"height": 900,
|
||||||
|
"surfaceId": "landing",
|
||||||
|
"screenRoute": "/*",
|
||||||
|
"sectionId": "hero-interactive-preview",
|
||||||
|
"usage": "High-contrast light slate canvas (#f8fafc) with crisp border enclosure around vector route preview. Telemetry blue accents draw eyes to pickup actions.",
|
||||||
|
"cropFocus": "Keep the primary subject in the central-right safe area so concise hero copy can sit to the left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landing-02-dual-role-workstation-overview",
|
||||||
|
"src": "/assets/landing-02-dual-role-workstation-overview.png",
|
||||||
|
"alt": "Passenger Booking Interface Vector Diagram for RideFlow",
|
||||||
|
"role": "Passenger Booking Interface Vector Diagram",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 900,
|
||||||
|
"surfaceId": "landing",
|
||||||
|
"screenRoute": "/*",
|
||||||
|
"sectionId": "dual-role-workstation-overview",
|
||||||
|
"usage": "Side-by-side elevated slate cards with 1px border outlines (#cbd5e1), signal badges in emerald green and telemetry blue.",
|
||||||
|
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landing-03-live-telemetry-capabilities",
|
||||||
|
"src": "/assets/landing-03-live-telemetry-capabilities.png",
|
||||||
|
"alt": "Highlight technical platform reliability including GPS location sharing, for RideFlow",
|
||||||
|
"role": "Highlight technical platform reliability including GPS location sharing,",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 900,
|
||||||
|
"surfaceId": "landing",
|
||||||
|
"screenRoute": "/*",
|
||||||
|
"sectionId": "live-telemetry-capabilities",
|
||||||
|
"usage": "Full-bleed muted slate strip (#f1f5f9) delimited by sharp top and bottom hairline borders (#e2e8f0).",
|
||||||
|
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landing-04-safety-companion-framework",
|
||||||
|
"src": "/assets/landing-04-safety-companion-framework.png",
|
||||||
|
"alt": "Companion Safety Verification Badge Graphic for RideFlow",
|
||||||
|
"role": "Companion Safety Verification Badge Graphic",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 900,
|
||||||
|
"surfaceId": "landing",
|
||||||
|
"screenRoute": "/*",
|
||||||
|
"sectionId": "safety-companion-framework",
|
||||||
|
"usage": "Deep dark inverse surface (#0f172a) with crisp white typography and emerald verification accents for high visual contrast.",
|
||||||
|
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "application-01-role-selection",
|
||||||
|
"src": "/assets/application-01-role-selection.png",
|
||||||
|
"alt": "Vehicle silhouette visual orientation for RideFlow",
|
||||||
|
"role": "Vehicle silhouette visual orientation",
|
||||||
|
"width": 1000,
|
||||||
|
"height": 1000,
|
||||||
|
"surfaceId": "application",
|
||||||
|
"screenRoute": "/app/onboarding",
|
||||||
|
"sectionId": "role-selection",
|
||||||
|
"usage": "Crisp white bordered surface with signal indigo selected state",
|
||||||
|
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "application-02-driver-vehicle-form",
|
||||||
|
"src": "/assets/application-02-driver-vehicle-form.png",
|
||||||
|
"alt": "Collect driver vehicle make, model, license plate, and color visual for RideFlow",
|
||||||
|
"role": "Collect driver vehicle make, model, license plate, and color visual",
|
||||||
|
"width": 1000,
|
||||||
|
"height": 1000,
|
||||||
|
"surfaceId": "application",
|
||||||
|
"screenRoute": "/app/onboarding",
|
||||||
|
"sectionId": "driver-vehicle-form",
|
||||||
|
"usage": "Muted slate inset form field group with distinct border boundaries",
|
||||||
|
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-01-admin-kpi-summary",
|
||||||
|
"src": "/assets/admin-01-admin-kpi-summary.png",
|
||||||
|
"alt": "Display top-level system stats including total users, online drivers, system for RideFlow",
|
||||||
|
"role": "Display top-level system stats including total users, online drivers, system",
|
||||||
|
"width": 1000,
|
||||||
|
"height": 1000,
|
||||||
|
"surfaceId": "admin",
|
||||||
|
"screenRoute": "/admin",
|
||||||
|
"sectionId": "admin-kpi-summary",
|
||||||
|
"usage": "Slate container with crisp subtle borders, emerald telemetry badge indicators, and high-contrast bold typography.",
|
||||||
|
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||||
|
}
|
||||||
|
]
|
||||||
1530
frontend/design/design-contract.json
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RideFlow</title>
|
||||||
|
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="s01qyw"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3280
frontend/package-lock.json
generated
Normal file
48
frontend/package.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "rideflow-s01qyw",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"tailwind-merge": "^2.0.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"sonner": "latest",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"@tanstack/react-table": "^8.20.5",
|
||||||
|
"framer-motion": "^10.16.4",
|
||||||
|
"recharts": "^2.13.0",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
|
"lucide-react": "^0.378.0",
|
||||||
|
"react-router-dom": "^6.18.0",
|
||||||
|
"date-fns": "^3.0.0",
|
||||||
|
"vite": "^4.5.0",
|
||||||
|
"@vitejs/plugin-react": "^4.1.1",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"@types/leaflet": "^1.9.12",
|
||||||
|
"socket.io-client": "4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@types/node": "^20.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||||
BIN
frontend/public/assets/admin-01-admin-kpi-summary.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/application-01-role-selection.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/application-02-driver-vehicle-form.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/landing-01-hero-interactive-preview.png
Normal file
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 940 KiB |
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/assets/landing-04-safety-companion-framework.png
Normal file
|
After Width: | Height: | Size: 793 KiB |
55
frontend/src/App.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import Auth from './pages/Auth';
|
||||||
|
import AdminTokenGate from './components/AdminTokenGate';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import LandingPage from './pages/Landing';
|
||||||
|
import OnboardingPage from './pages/Onboarding';
|
||||||
|
import PassengerRidePage from './pages/PassengerRide';
|
||||||
|
import DriverWorkstationPage from './pages/DriverWorkstation';
|
||||||
|
import CirclesPage from './pages/Circles';
|
||||||
|
import HistoryPage from './pages/History';
|
||||||
|
import OnboardingPage from './pages/Onboarding';
|
||||||
|
import AdminPage from './pages/Admin';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading session...</div>;
|
||||||
|
if (!user) {
|
||||||
|
const returnTo = encodeURIComponent(location.pathname + location.search);
|
||||||
|
return <Navigate to={'/auth?returnTo=' + returnTo} replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isPublicShellRoute = location.pathname === '/' || location.pathname === '/auth' || location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{!isPublicShellRoute && <Navbar />}
|
||||||
|
<main className="flex-1">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth" element={<Auth />} />
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/app" element={<ProtectedRoute><OnboardingPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/app/ride" element={<ProtectedRoute><PassengerRidePage /></ProtectedRoute>} />
|
||||||
|
<Route path="/app/driver" element={<ProtectedRoute><DriverWorkstationPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/app/circles" element={<ProtectedRoute><CirclesPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/app/history" element={<ProtectedRoute><HistoryPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/app/onboarding" element={<ProtectedRoute><OnboardingPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/admin" element={<AdminTokenGate><AdminPage /></AdminTokenGate>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/src/api.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||||
|
? "http://127.0.0.1:8787"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
export function resolveMediaUrl(value?: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.startsWith('/api/media/') ? API_URL + value : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMediaUrls(value: unknown): unknown {
|
||||||
|
if (typeof value === 'string') return resolveMediaUrl(value);
|
||||||
|
if (Array.isArray(value)) return value.map(resolveMediaUrls);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [key, resolveMediaUrls(nested)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminApi = axios.create({
|
||||||
|
baseURL: API_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
// Force the /api prefix on all internal API calls that forgot it
|
||||||
|
if (config.url && !config.url.startsWith('/api') && !config.url.startsWith('http')) {
|
||||||
|
config.url = `/api${config.url.startsWith('/') ? '' : '/'}${config.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('user_auth_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||||
|
config.headers['Content-Type'] = undefined;
|
||||||
|
}
|
||||||
|
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||||
|
config.headers['x-mworld-origin'] = currentOrigin;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
response.data = resolveMediaUrls(response.data);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Prevent infinite page reloads on check-in failures
|
||||||
|
const isCheckIn = error.config?.url?.includes('/user/check-in');
|
||||||
|
if (!isCheckIn) {
|
||||||
|
const hadToken = localStorage.getItem('user_auth_token');
|
||||||
|
localStorage.removeItem('user_auth_token');
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
if (hadToken) window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
adminApi.interceptors.request.use((config) => {
|
||||||
|
if (config.url && !config.url.startsWith('http')) {
|
||||||
|
if (config.url.startsWith('/api/admin')) {
|
||||||
|
// Already namespaced.
|
||||||
|
} else if (config.url.startsWith('/api/')) {
|
||||||
|
config.url = `/api/admin${config.url.slice('/api'.length)}`;
|
||||||
|
} else {
|
||||||
|
config.url = `/api/admin${config.url.startsWith('/') ? '' : '/'}${config.url}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = sessionStorage.getItem('admin_auth_token');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||||
|
config.headers['Content-Type'] = undefined;
|
||||||
|
}
|
||||||
|
config.headers['x-mworld-origin'] = window.location.origin;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
response.data = resolveMediaUrls(response.data);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
117
frontend/src/components/AdminTokenGate.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
import React, { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { KeyRound, Loader2, LockKeyhole, LogOut } from 'lucide-react';
|
||||||
|
import { adminApi } from '../api';
|
||||||
|
|
||||||
|
export default function AdminTokenGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const siteTitle = "RideFlow";
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const tokenParamNames = ['agentic_token', 'mAuthAgenticToken', 'mAgenticAuthToken', 'mAgenticAuth'];
|
||||||
|
|
||||||
|
const normalizeAgenticToken = (value: string | null): string => {
|
||||||
|
let normalized = String(value || '').trim().replace(/^["']|["']$/g, '').trim();
|
||||||
|
normalized = normalized.replace(/^Bearer[ ]+/i, '').trim();
|
||||||
|
if (normalized.toLowerCase().startsWith('http://') || normalized.toLowerCase().startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
const pastedUrl = new URL(normalized);
|
||||||
|
const hashParams = new URLSearchParams(pastedUrl.hash.startsWith('#') ? pastedUrl.hash.slice(1) : '');
|
||||||
|
normalized = tokenParamNames.map(name => pastedUrl.searchParams.get(name) || hashParams.get(name)).find(Boolean) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/%[0-9a-f]{2}/i.test(normalized)) {
|
||||||
|
try { normalized = decodeURIComponent(normalized); } catch { /* keep the original token */ }
|
||||||
|
}
|
||||||
|
return normalized.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAgenticHandoff = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : '');
|
||||||
|
const handoff = tokenParamNames.map(name => url.searchParams.get(name) || hashParams.get(name)).find(Boolean) || null;
|
||||||
|
if (handoff) {
|
||||||
|
tokenParamNames.forEach(name => {
|
||||||
|
url.searchParams.delete(name);
|
||||||
|
hashParams.delete(name);
|
||||||
|
});
|
||||||
|
url.hash = hashParams.toString() ? '#' + hashParams.toString() : '';
|
||||||
|
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
}
|
||||||
|
return normalizeAgenticToken(handoff);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verify = async (candidate: string) => {
|
||||||
|
const normalized = normalizeAgenticToken(candidate);
|
||||||
|
setToken(normalized);
|
||||||
|
if (!normalized) {
|
||||||
|
setError('A valid M-Auth agentic token is required.');
|
||||||
|
setChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChecking(true);
|
||||||
|
setError('');
|
||||||
|
sessionStorage.setItem('admin_auth_token', normalized);
|
||||||
|
try {
|
||||||
|
await adminApi.get('/api/admin/verify-token');
|
||||||
|
setVerified(true);
|
||||||
|
} catch (requestError: any) {
|
||||||
|
const status = Number(requestError?.response?.status || 0);
|
||||||
|
setVerified(false);
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
sessionStorage.removeItem('admin_auth_token');
|
||||||
|
setError('That M-Auth agentic token is invalid or expired. Open the admin panel from Fullstack Builder again.');
|
||||||
|
} else {
|
||||||
|
setError('M-Auth could not be reached right now. Your token has been kept; retry in a moment.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = readAgenticHandoff() || normalizeAgenticToken(sessionStorage.getItem('admin_auth_token'));
|
||||||
|
if (saved) verify(saved);
|
||||||
|
else setChecking(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
return <div className="min-h-screen bg-background text-foreground">
|
||||||
|
<div className="fixed right-4 top-4 z-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Lock admin panel"
|
||||||
|
onClick={() => { sessionStorage.removeItem('admin_auth_token'); setVerified(false); setToken(''); }}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:text-foreground"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main className="flex min-h-screen items-center justify-center bg-background px-5 text-foreground">
|
||||||
|
<section className="w-full max-w-md border border-border bg-background p-7 shadow-xl">
|
||||||
|
<div className="mb-7 flex items-center gap-4">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground"><LockKeyhole className="h-5 w-5" /></div>
|
||||||
|
<div><h1 className="text-xl font-semibold">{siteTitle} Admin</h1><p className="mt-1 text-sm text-muted-foreground">Restricted operator access</p></div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={(event: FormEvent) => { event.preventDefault(); verify(token); }} className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="admin-token">M-Auth agentic token</label>
|
||||||
|
<div className="relative">
|
||||||
|
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input id="admin-token" type="password" autoComplete="off" value={token} onChange={event => setToken(event.target.value)} className="h-10 w-full border border-input bg-background pl-10 pr-3 text-sm outline-none focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||||
|
<button disabled={checking || !token.trim()} className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-50">
|
||||||
|
{checking && <Loader2 className="h-4 w-4 animate-spin" />} Unlock admin panel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
163
frontend/src/components/MapView.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
export interface MapMarker {
|
||||||
|
id?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
label?: string;
|
||||||
|
type?: 'pickup' | 'destination' | 'driver' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewProps {
|
||||||
|
center: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
markers?: MapMarker[];
|
||||||
|
polyline?: [number, number][];
|
||||||
|
className?: string;
|
||||||
|
onMapClick?: (lat: number, lng: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCustomIcon = (type: MapMarker['type'] = 'default') => {
|
||||||
|
let bgColor = '#0f172a';
|
||||||
|
let ringColor = 'rgba(15, 23, 42, 0.2)';
|
||||||
|
let symbol = '•';
|
||||||
|
|
||||||
|
if (type === 'pickup') {
|
||||||
|
bgColor = '#15803d';
|
||||||
|
ringColor = 'rgba(21, 128, 61, 0.3)';
|
||||||
|
symbol = 'A';
|
||||||
|
} else if (type === 'destination') {
|
||||||
|
bgColor = '#dc2626';
|
||||||
|
ringColor = 'rgba(220, 38, 38, 0.3)';
|
||||||
|
symbol = 'B';
|
||||||
|
} else if (type === 'driver') {
|
||||||
|
bgColor = '#0284c7';
|
||||||
|
ringColor = 'rgba(2, 132, 199, 0.35)';
|
||||||
|
symbol = '🚕';
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background-color: ${bgColor};
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
box-shadow: 0 0 0 4px ${ringColor}, 0 4px 10px rgba(0,0,0,0.2);
|
||||||
|
">
|
||||||
|
${symbol}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
html,
|
||||||
|
className: 'custom-leaflet-marker-icon',
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16],
|
||||||
|
popupAnchor: [0, -16],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MapView({
|
||||||
|
center,
|
||||||
|
zoom = 13,
|
||||||
|
markers = [],
|
||||||
|
polyline = [],
|
||||||
|
className,
|
||||||
|
onMapClick,
|
||||||
|
}: MapViewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const markerLayerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const polylineLayerRef = useRef<L.Polyline | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
const map = L.map(containerRef.current, {
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: false,
|
||||||
|
}).setView(center, zoom);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
markerLayerRef.current = L.layerGroup().addTo(map);
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
if (onMapClick) {
|
||||||
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.setView(center, zoom);
|
||||||
|
}
|
||||||
|
}, [center[0], center[1], zoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = markerLayerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
|
||||||
|
markers.forEach((m) => {
|
||||||
|
const icon = createCustomIcon(m.type);
|
||||||
|
const marker = L.marker([m.lat, m.lng], { icon });
|
||||||
|
if (m.label) {
|
||||||
|
marker.bindPopup(
|
||||||
|
`<div style="font-family: sans-serif; font-weight: 600; font-size: 13px; color: #0f172a;">${m.label}</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
layer.addLayer(marker);
|
||||||
|
});
|
||||||
|
}, [markers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
if (polylineLayerRef.current) {
|
||||||
|
polylineLayerRef.current.remove();
|
||||||
|
polylineLayerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (polyline && polyline.length > 0) {
|
||||||
|
polylineLayerRef.current = L.polyline(polyline, {
|
||||||
|
color: '#0284c7',
|
||||||
|
weight: 5,
|
||||||
|
opacity: 0.85,
|
||||||
|
lineCap: 'round',
|
||||||
|
lineJoin: 'round',
|
||||||
|
}).addTo(mapRef.current);
|
||||||
|
}
|
||||||
|
}, [polyline]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={className || 'h-[450px] w-full rounded-xl border border-slate-300 shadow-sm bg-slate-100 overflow-hidden'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { Bell, Car, History, LogIn, LogOut, Menu, Navigation, ShieldCheck, Users, X, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: notifData } = useQuery({
|
||||||
|
queryKey: ['notification-count'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/notifications/count');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = typeof notifData?.count === 'number' ? notifData.count : 0;
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') return location.pathname === '/';
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = isAuthenticated
|
||||||
|
? [
|
||||||
|
{ path: '/app/ride', label: 'Passenger', icon: Navigation },
|
||||||
|
{ path: '/app/driver', label: 'Driver Workstation', icon: Car },
|
||||||
|
{ path: '/app/circles', label: 'Circles', icon: Users },
|
||||||
|
{ path: '/app/history', label: 'History', icon: History },
|
||||||
|
{ path: '/app/onboarding', label: 'Safety Setup', icon: ShieldCheck },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ path: '/', label: 'Overview', icon: Zap },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 h-16 w-full border-b border-border bg-slate-900 text-slate-100 shadow-sm backdrop-blur">
|
||||||
|
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<Link to="/" className="flex items-center gap-2.5 transition-opacity hover:opacity-90">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-sky-600 font-bold text-white shadow-md shadow-sky-600/30">
|
||||||
|
<Navigation className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-lg font-bold tracking-tight text-white">RideFlow</span>
|
||||||
|
<span className="-mt-1 text-[10px] font-medium tracking-wider text-sky-400 uppercase">Urban Transit</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex md:items-center md:gap-1">
|
||||||
|
{navLinks.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-slate-800 text-sky-400 font-semibold shadow-inner'
|
||||||
|
: 'text-slate-300 hover:bg-slate-800/60 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex md:items-center md:gap-4">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Notifications"
|
||||||
|
className="relative rounded-full p-2 text-slate-300 hover:bg-slate-800 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-[10px] font-bold text-white shadow">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 border-l border-slate-800 pl-3">
|
||||||
|
<div className="flex flex-col text-right">
|
||||||
|
<span className="text-xs font-semibold text-slate-200">
|
||||||
|
{user?.full_name || user?.email?.split('@')[0] || 'User'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
{user?.role || 'passenger'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-300 hover:bg-slate-700 hover:text-white transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
<span>Exit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="flex items-center gap-2 rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-500 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
<span>Sign In</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="rounded-md p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="border-b border-slate-800 bg-slate-900 px-4 pt-2 pb-4 shadow-xl md:hidden">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navLinks.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-base font-medium ${
|
||||||
|
active
|
||||||
|
? 'bg-slate-800 text-sky-400 font-semibold'
|
||||||
|
: 'text-slate-300 hover:bg-slate-800/60 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-t border-slate-800 pt-4">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-200">
|
||||||
|
{user?.full_name || user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 capitalize">Role: {user?.role || 'passenger'}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-2 text-xs font-medium text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-md bg-sky-600 py-2.5 text-center text-sm font-semibold text-white shadow hover:bg-sky-500"
|
||||||
|
>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
<span>Sign In to RideFlow</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
frontend/src/components/NotificationBell.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Bell, Car, Check, CheckCheck, MapPin, X } from 'lucide-react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
type RideItem = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
destination_location?: string;
|
||||||
|
pickup_location?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [readIds, setReadIds] = useState<string[]>([]);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: countData } = useQuery({
|
||||||
|
queryKey: ['notification-count'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/notifications/count');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: ridesData } = useQuery({
|
||||||
|
queryKey: ['notification-rides'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/rides/my');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: isAuthenticated && isOpen,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
const rawCount = typeof countData?.count === 'number' ? countData.count : 0;
|
||||||
|
const rides: RideItem[] = Array.isArray(ridesData?.rides) ? ridesData.rides : [];
|
||||||
|
|
||||||
|
const notifications = rides.slice(0, 5).map((ride) => {
|
||||||
|
const isCompleted = ride.status === 'completed';
|
||||||
|
const isAccepted = ride.status === 'accepted';
|
||||||
|
const isPickedUp = ride.status === 'picked_up';
|
||||||
|
const isCancelled = ride.status === 'cancelled';
|
||||||
|
|
||||||
|
let title = 'Ride Status Update';
|
||||||
|
let body = `Trip to ${ride.destination_location || 'destination'} is ${ride.status}`;
|
||||||
|
let type = 'info';
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
title = 'Trip Completed';
|
||||||
|
body = `Your ride to ${ride.destination_location || 'destination'} is completed.`;
|
||||||
|
type = 'success';
|
||||||
|
} else if (isPickedUp) {
|
||||||
|
title = 'Passenger Picked Up';
|
||||||
|
body = `En route to ${ride.destination_location || 'destination'}.`;
|
||||||
|
type = 'info';
|
||||||
|
} else if (isAccepted) {
|
||||||
|
title = 'Driver Assigned';
|
||||||
|
body = `Driver assigned for pickup at ${ride.pickup_location || 'origin'}.`;
|
||||||
|
type = 'warning';
|
||||||
|
} else if (isCancelled) {
|
||||||
|
title = 'Trip Cancelled';
|
||||||
|
body = `Ride request to ${ride.destination_location || 'destination'} was cancelled.`;
|
||||||
|
type = 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ride.id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
type,
|
||||||
|
status: ride.status,
|
||||||
|
link: '/app/ride',
|
||||||
|
time: ride.created_at
|
||||||
|
? new Date(ride.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: 'Recently',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadItems = notifications.filter((n) => !readIds.includes(n.id));
|
||||||
|
const displayCount = Math.max(rawCount, unreadItems.length);
|
||||||
|
|
||||||
|
const markAllRead = () => {
|
||||||
|
setReadIds(notifications.map((n) => n.id));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemClick = (link: string, id: string) => {
|
||||||
|
if (!readIds.includes(id)) {
|
||||||
|
setReadIds((prev) => [...prev, id]);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
navigate(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block" ref={panelRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="relative flex items-center justify-center w-10 h-10 rounded-full border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 hover:text-slate-900 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||||
|
aria-label="View notifications"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
{displayCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-sky-600 px-1 text-[11px] font-bold text-white shadow-sm ring-2 ring-white">
|
||||||
|
{displayCount > 99 ? '99+' : displayCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-80 sm:w-96 rounded-xl border border-slate-200 bg-white shadow-2xl z-50 overflow-hidden text-slate-900 animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 bg-slate-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="w-4 h-4 text-slate-600" />
|
||||||
|
<h3 className="font-semibold text-sm text-slate-900">Notifications</h3>
|
||||||
|
{displayCount > 0 && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 font-medium">
|
||||||
|
{displayCount} new
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{unreadItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={markAllRead}
|
||||||
|
className="text-xs font-medium text-sky-600 hover:text-sky-800 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCheck className="w-3.5 h-3.5" />
|
||||||
|
Read all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 p-1 rounded-md"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-100">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-slate-500">
|
||||||
|
<Bell className="w-8 h-8 mx-auto text-slate-300 mb-2" />
|
||||||
|
<p className="text-sm font-medium text-slate-700">No new notifications</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Updates on your active rides and activities will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((item) => {
|
||||||
|
const isRead = readIds.includes(item.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemClick(item.link, item.id)}
|
||||||
|
className={`w-full text-left p-3.5 flex items-start gap-3 hover:bg-slate-50 transition-colors ${
|
||||||
|
isRead ? 'opacity-70' : 'bg-sky-50/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 p-2 rounded-lg bg-slate-100 text-slate-700 shrink-0">
|
||||||
|
{item.type === 'success' ? (
|
||||||
|
<Check className="w-4 h-4 text-emerald-600" />
|
||||||
|
) : item.type === 'warning' ? (
|
||||||
|
<Car className="w-4 h-4 text-amber-600" />
|
||||||
|
) : (
|
||||||
|
<MapPin className="w-4 h-4 text-sky-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold text-slate-900 truncate">{item.title}</p>
|
||||||
|
<span className="text-[10px] text-slate-400 shrink-0">{item.time}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600 line-clamp-2 mt-0.5">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
{!isRead && <span className="w-2 h-2 rounded-full bg-sky-600 shrink-0 mt-1.5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-slate-100 bg-slate-50 text-center">
|
||||||
|
<Link
|
||||||
|
to="/app/history"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="text-xs font-medium text-slate-600 hover:text-slate-900 transition-colors"
|
||||||
|
>
|
||||||
|
View all ride history →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/StarRating.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StarRatingProps {
|
||||||
|
value: number;
|
||||||
|
onChange?: (rating: number) => void;
|
||||||
|
size?: number;
|
||||||
|
readonly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarRating({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = 20,
|
||||||
|
readonly = false,
|
||||||
|
className = '',
|
||||||
|
}: StarRatingProps) {
|
||||||
|
const [hover, setHover] = useState(0);
|
||||||
|
const interactive = !readonly && !!onChange;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1 ${className}`}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const filled = (hover || value) >= star;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
disabled={!interactive}
|
||||||
|
onClick={() => interactive && onChange && onChange(star)}
|
||||||
|
onMouseEnter={() => interactive && setHover(star)}
|
||||||
|
onMouseLeave={() => interactive && setHover(0)}
|
||||||
|
className={`${interactive ? 'cursor-pointer transition-transform hover:scale-110' : 'cursor-default'} rounded p-0.5 focus:outline-none focus-visible:ring-1 focus-visible:ring-sky-500`}
|
||||||
|
aria-label={`${star} star${star > 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={size}
|
||||||
|
className={filled ? 'fill-amber-400 text-amber-500' : 'fill-none text-slate-300 dark:text-slate-600'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StarRating;
|
||||||
410
frontend/src/components/features/AdminRideLogsTable.tsx
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowRight, Car, CheckCircle2, Clock, MapPin, Navigation, Phone, RefreshCw, Search, ShieldAlert, Star, User, XCircle } from 'lucide-react';
|
||||||
|
import { adminApi } from '../../api';
|
||||||
|
|
||||||
|
interface RideRecord {
|
||||||
|
id: string;
|
||||||
|
passenger_email: string;
|
||||||
|
passenger_name?: string | null;
|
||||||
|
passenger_phone?: string | null;
|
||||||
|
driver_email?: string | null;
|
||||||
|
driver_name?: string | null;
|
||||||
|
driver_phone?: string | null;
|
||||||
|
pickup_location: string;
|
||||||
|
pickup_lat: number;
|
||||||
|
pickup_lng: number;
|
||||||
|
destination_location: string;
|
||||||
|
destination_lat: number;
|
||||||
|
destination_lng: number;
|
||||||
|
status: 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||||
|
driver_lat?: number | null;
|
||||||
|
driver_lng?: number | null;
|
||||||
|
rating?: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export default function AdminRideLogsTable() {
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<StatusFilter>('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error, refetch, isRefetching } = useQuery({
|
||||||
|
queryKey: ['admin-rides'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await adminApi.get('/api/admin/rides');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawRides: RideRecord[] = Array.isArray(data?.rides) ? data.rides : [];
|
||||||
|
|
||||||
|
const filteredRides = useMemo(() => {
|
||||||
|
return rawRides.filter((ride) => {
|
||||||
|
const matchesStatus = selectedStatus === 'all' || ride.status === selectedStatus;
|
||||||
|
|
||||||
|
const term = String((String(searchTerm ?? '').toLowerCase()) ?? '').trim();
|
||||||
|
if (!term) return matchesStatus;
|
||||||
|
|
||||||
|
const pName = String(ride.passenger_name ?? '').toLowerCase();
|
||||||
|
const pEmail = String(ride.passenger_email ?? '').toLowerCase();
|
||||||
|
const dName = String(ride.driver_name ?? '').toLowerCase();
|
||||||
|
const dEmail = String(ride.driver_email ?? '').toLowerCase();
|
||||||
|
const pickup = String(ride.pickup_location ?? '').toLowerCase();
|
||||||
|
const dest = String(ride.destination_location ?? '').toLowerCase();
|
||||||
|
const rideId = String(ride.id ?? '').toLowerCase();
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
pName.includes(term) ||
|
||||||
|
pEmail.includes(term) ||
|
||||||
|
dName.includes(term) ||
|
||||||
|
dEmail.includes(term) ||
|
||||||
|
pickup.includes(term) ||
|
||||||
|
dest.includes(term) ||
|
||||||
|
rideId.includes(term);
|
||||||
|
|
||||||
|
return matchesStatus && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [rawRides, selectedStatus, searchTerm]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredRides.length / pageSize) || 1;
|
||||||
|
const paginatedRides = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return filteredRides.slice(start, start + pageSize);
|
||||||
|
}, [filteredRides, currentPage, pageSize]);
|
||||||
|
|
||||||
|
const getStatusBadge = (status: RideRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'requested':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 border border-sky-300">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-sky-600" />
|
||||||
|
Requested
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'accepted':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-300">
|
||||||
|
<Car className="w-3.5 h-3.5 text-amber-600" />
|
||||||
|
Accepted
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'picked_up':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-teal-100 text-teal-800 border border-teal-300">
|
||||||
|
<Navigation className="w-3.5 h-3.5 text-teal-600" />
|
||||||
|
In Transit
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'cancelled':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-rose-100 text-rose-800 border border-rose-300">
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-rose-600" />
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
|
||||||
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm space-y-4">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Car className="w-5 h-5 text-sky-600" />
|
||||||
|
System Ride Logs
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Real-time audit log of all system requests, assignments, and completed trips
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-semibold text-slate-700 bg-slate-100 hover:bg-slate-200 border border-slate-300 rounded-lg transition-colors cursor-pointer disabled:opacity-50 self-start md:self-auto"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||||
|
{isRefetching ? 'Refreshing...' : 'Refresh Logs'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter Tabs */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-100">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: 'all', label: 'All Rides', count: rawRides.length },
|
||||||
|
{ id: 'requested', label: 'Requested', count: rawRides.filter((r) => r.status === 'requested').length },
|
||||||
|
{ id: 'accepted', label: 'Accepted', count: rawRides.filter((r) => r.status === 'accepted').length },
|
||||||
|
{ id: 'picked_up', label: 'Picked Up', count: rawRides.filter((r) => r.status === 'picked_up').length },
|
||||||
|
{ id: 'completed', label: 'Completed', count: rawRides.filter((r) => r.status === 'completed').length },
|
||||||
|
{ id: 'cancelled', label: 'Cancelled', count: rawRides.filter((r) => r.status === 'cancelled').length },
|
||||||
|
] as const
|
||||||
|
).map((tab) => {
|
||||||
|
const active = selectedStatus === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus(tab.id);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-slate-900 text-white shadow-sm'
|
||||||
|
: 'bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 rounded-full text-[10px] ${
|
||||||
|
active ? 'bg-slate-800 text-slate-200' : 'bg-slate-200 text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Search by passenger, driver, locations, or ride ID..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-xs bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent text-slate-900 placeholder:text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-12 text-center text-slate-500 space-y-3">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-slate-400" />
|
||||||
|
<p className="text-xs font-medium">Loading system ride logs...</p>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="p-8 text-center text-rose-600 space-y-3 bg-rose-50/50">
|
||||||
|
<ShieldAlert className="w-8 h-8 mx-auto text-rose-500" />
|
||||||
|
<p className="text-xs font-semibold">
|
||||||
|
{String((error as Error)?.message || 'Failed to retrieve system ride logs.')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="px-4 py-1.5 text-xs font-medium text-rose-700 bg-rose-100 hover:bg-rose-200 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : paginatedRides.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-slate-500 space-y-2">
|
||||||
|
<Car className="w-8 h-8 mx-auto text-slate-300" />
|
||||||
|
<p className="text-sm font-semibold text-slate-700">No ride records found</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{searchTerm || selectedStatus !== 'all'
|
||||||
|
? 'Try adjusting your search query or status filter.'
|
||||||
|
: 'No rides have been requested in the system yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-700 border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-100/80 border-b border-slate-200 text-slate-600 font-semibold uppercase tracking-wider text-[11px]">
|
||||||
|
<th className="py-3 px-4">Ride ID</th>
|
||||||
|
<th className="py-3 px-4">Passenger</th>
|
||||||
|
<th className="py-3 px-4">Driver</th>
|
||||||
|
<th className="py-3 px-4">Route (Pickup → Destination)</th>
|
||||||
|
<th className="py-3 px-4">Status</th>
|
||||||
|
<th className="py-3 px-4">Rating</th>
|
||||||
|
<th className="py-3 px-4">Requested Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{paginatedRides.map((ride) => {
|
||||||
|
const passengerDisplay =
|
||||||
|
ride.passenger_name ?? ride.passenger_email.split('@')[0];
|
||||||
|
const driverDisplay = ride.driver_name
|
||||||
|
? ride.driver_name
|
||||||
|
: ride.driver_email
|
||||||
|
? ride.driver_email.split('@')[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={ride.id} className="hover:bg-slate-50/80 transition-colors">
|
||||||
|
|
||||||
|
<td className="py-3 px-4 font-mono font-medium text-slate-900 whitespace-nowrap">
|
||||||
|
<span className="bg-slate-100 text-slate-800 px-2 py-0.5 rounded text-[11px]">
|
||||||
|
#{ride.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-semibold text-slate-900 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3 text-slate-400 inline" />
|
||||||
|
{passengerDisplay}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-500">{ride.passenger_email}</span>
|
||||||
|
{ride.passenger_phone && (
|
||||||
|
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||||
|
<Phone className="w-2.5 h-2.5" />
|
||||||
|
{ride.passenger_phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{driverDisplay ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-semibold text-slate-900 flex items-center gap-1">
|
||||||
|
<Car className="w-3 h-3 text-emerald-600 inline" />
|
||||||
|
{driverDisplay}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-500">{ride.driver_email}</span>
|
||||||
|
{ride.driver_phone && (
|
||||||
|
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||||
|
<Phone className="w-2.5 h-2.5" />
|
||||||
|
{ride.driver_phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="italic text-slate-400 text-[11px]">Unassigned</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
<td className="py-3 px-4 max-w-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-start gap-1.5 text-slate-800">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-emerald-600 shrink-0 mt-0.5" />
|
||||||
|
<span className="truncate font-medium" title={ride.pickup_location}>
|
||||||
|
{ride.pickup_location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-slate-400 pl-1">
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-1.5 text-slate-800">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-600 shrink-0 mt-0.5" />
|
||||||
|
<span className="truncate font-medium" title={ride.destination_location}>
|
||||||
|
{ride.destination_location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
<td className="py-3 px-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(ride.status)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
<td className="py-3 px-4 whitespace-nowrap">
|
||||||
|
{ride.rating ? (
|
||||||
|
<div className="flex items-center gap-1 font-semibold text-amber-700 bg-amber-50 px-2 py-0.5 rounded border border-amber-200 inline-flex">
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
<span>{ride.rating} / 5</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400 text-[11px]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
|
||||||
|
<td className="py-3 px-4 whitespace-nowrap text-slate-500 font-medium">
|
||||||
|
{formatDate(ride.created_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{!isLoading && !isError && filteredRides.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 border-t border-slate-200 text-xs text-slate-600">
|
||||||
|
<div>
|
||||||
|
Showing <span className="font-semibold text-slate-900">{(currentPage - 1) * pageSize + 1}</span> to{' '}
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{Math.min(currentPage * pageSize, filteredRides.length)}
|
||||||
|
</span>{' '}
|
||||||
|
of <span className="font-semibold text-slate-900">{filteredRides.length}</span> records
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-2.5 py-1 font-medium bg-white border border-slate-300 rounded hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-white cursor-pointer"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-2 font-medium text-slate-700">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-2.5 py-1 font-medium bg-white border border-slate-300 rounded hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-white cursor-pointer"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/components/features/AdminStatsCards.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi, api } from '../../api';
|
||||||
|
import { Activity, AlertTriangle, Car, CheckCircle2, RefreshCw, Route, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminStatsCards() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-stats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminApi.get('/api/admin/stats');
|
||||||
|
return res.data;
|
||||||
|
} catch {
|
||||||
|
const res = await api.get('/api/admin/stats');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="h-28 rounded-xl bg-slate-100 dark:bg-slate-800 animate-pulse border border-slate-200 dark:border-slate-800 p-4" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Failed to load platform telemetry stats.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-red-100 hover:bg-red-200 text-red-900 dark:bg-red-900 dark:hover:bg-red-800 dark:text-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = data?.stats || {
|
||||||
|
total_users: 0,
|
||||||
|
active_drivers: 0,
|
||||||
|
total_rides: 0,
|
||||||
|
active_rides: 0,
|
||||||
|
completed_rides: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: 'Total Users',
|
||||||
|
value: stats.total_users ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
color: 'text-slate-900 dark:text-slate-100',
|
||||||
|
bg: 'bg-slate-100 dark:bg-slate-800',
|
||||||
|
description: 'Registered passengers & drivers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Online Drivers',
|
||||||
|
value: stats.active_drivers ?? 0,
|
||||||
|
icon: Car,
|
||||||
|
color: 'text-emerald-600 dark:text-emerald-400',
|
||||||
|
bg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||||
|
description: 'Available for instant dispatch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total System Rides',
|
||||||
|
value: stats.total_rides ?? 0,
|
||||||
|
icon: Route,
|
||||||
|
color: 'text-sky-600 dark:text-sky-400',
|
||||||
|
bg: 'bg-sky-50 dark:bg-sky-950/40',
|
||||||
|
description: 'All-time ride requests',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active Ongoing',
|
||||||
|
value: stats.active_rides ?? 0,
|
||||||
|
icon: Activity,
|
||||||
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
|
bg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||||
|
description: 'Requested, accepted, or picked up',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Completed Trips',
|
||||||
|
value: stats.completed_rides ?? 0,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-teal-600 dark:text-teal-400',
|
||||||
|
bg: 'bg-teal-50 dark:bg-teal-950/40',
|
||||||
|
description: 'Successfully finished rides',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.title}
|
||||||
|
className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4 shadow-sm flex flex-col justify-between transition-all hover:border-slate-300 dark:hover:border-slate-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
<div className={`p-2 rounded-lg ${card.bg}`}>
|
||||||
|
<Icon className={`h-4 w-4 ${card.color}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||||
|
{(card.value ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 line-clamp-1">
|
||||||
|
{card.description}
|
||||||
|
</p> // Fixed text formatting
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
frontend/src/components/features/AdminUserTable.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi, api } from '../../api';
|
||||||
|
import { AlertCircle, Car, Filter, RefreshCw, Search, Shield, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminUserTable() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminApi.get('/api/admin/users');
|
||||||
|
return res.data;
|
||||||
|
} catch {
|
||||||
|
const res = await api.get('/api/admin/users');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawUsers = Array.isArray(data?.users) ? data.users : [];
|
||||||
|
|
||||||
|
const filteredUsers = rawUsers.filter((u: any) => {
|
||||||
|
const name = String(u.full_name ?? u.username ?? '').toLowerCase();
|
||||||
|
const email = String(u.email ?? '').toLowerCase();
|
||||||
|
const phone = String(u.phone ?? '').toLowerCase();
|
||||||
|
const citizenId = String(u.citizen_id ?? '').toLowerCase();
|
||||||
|
const query = String((searchQuery.toLowerCase()) ?? '').trim();
|
||||||
|
const matchesSearch =
|
||||||
|
!query ||
|
||||||
|
name.includes(query) ||
|
||||||
|
email.includes(query) ||
|
||||||
|
phone.includes(query) ||
|
||||||
|
citizenId.includes(query);
|
||||||
|
|
||||||
|
const role = String(u.role ?? '').toLowerCase();
|
||||||
|
const matchesRole = roleFilter === 'all' || role === roleFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRoleBadge = (roleStr: string) => {
|
||||||
|
const role = String(roleStr ?? '').toLowerCase();
|
||||||
|
if (role === 'admin') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300 border border-teal-200 dark:border-teal-800">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (role === 'driver') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
||||||
|
<Car className="w-3 h-3" />
|
||||||
|
Driver
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
Passenger
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return isNaN(date.getTime()) ? dateStr : date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name, email, phone, or citizen ID..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 dark:focus:ring-slate-100 text-slate-900 dark:text-white placeholder-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 px-1 font-medium">
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
<span>Role:</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 dark:focus:ring-slate-100 text-slate-900 dark:text-white font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
<option value="passenger">Passengers</option>
|
||||||
|
<option value="driver">Drivers</option>
|
||||||
|
<option value="admin">Admins</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
title="Refresh Directory"
|
||||||
|
className="p-2 text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center space-y-3">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin mx-auto text-slate-400" />
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Loading registered user directory...</p>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="p-8 text-center space-y-3">
|
||||||
|
<AlertCircle className="h-8 w-8 text-red-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-white">Unable to retrieve user records.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold rounded-lg bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="p-12 text-center space-y-3">
|
||||||
|
<Users className="h-10 w-10 text-slate-300 dark:text-slate-600 mx-auto" />
|
||||||
|
<p className="text-base font-semibold text-slate-900 dark:text-white">No users found</p>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm mx-auto">
|
||||||
|
{searchQuery || roleFilter !== 'all'
|
||||||
|
? 'No registered users match your search criteria. Try adjusting filters.'
|
||||||
|
: 'There are currently no registered users in the database.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/50 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||||
|
<th className="py-3.5 px-4">User</th>
|
||||||
|
<th className="py-3.5 px-4">Role</th>
|
||||||
|
<th className="py-3.5 px-4">Contact Info</th>
|
||||||
|
<th className="py-3.5 px-4">Citizen ID</th>
|
||||||
|
<th className="py-3.5 px-4">Joined Date</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-800 text-sm">
|
||||||
|
{filteredUsers.map((u: any) => {
|
||||||
|
const name = u.full_name || u.username || u.email?.split('@')[0] || 'User';
|
||||||
|
const email = u.email || 'N/A';
|
||||||
|
const phone = u.phone || 'N/A';
|
||||||
|
const citizenId = u.citizen_id || 'N/A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={u.id || u.email} className="hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-colors">
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-center text-slate-700 dark:text-slate-300 font-bold text-xs uppercase shrink-0">
|
||||||
|
{name.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-slate-900 dark:text-white">{name}</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">{email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||||
|
{getRoleBadge(u.role)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||||
|
<div className="text-slate-900 dark:text-slate-100 font-mono text-xs">{phone}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||||
|
<div className="text-slate-600 dark:text-slate-400 font-mono text-xs">{citizenId}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap text-slate-600 dark:text-slate-400 text-xs">
|
||||||
|
{formatDate(u.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap text-right">
|
||||||
|
{u.is_banned ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300">
|
||||||
|
Banned
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||||
|
<span>Showing {filteredUsers.length} of {rawUsers.length} total registered users</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
459
frontend/src/components/features/CircleActivityPlanner.tsx
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { CompanionCircle, CompanionActivity, RsvpStatus } from '../../types/ride';
|
||||||
|
import { AlertCircle, Calendar, CheckCircle, Clock, Loader2, MapPin, Plus, Sparkles, UserCheck, Users, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function CircleActivityPlanner() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedCircleId, setSelectedCircleId] = useState<string | null>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [locationLabel, setLocationLabel] = useState('');
|
||||||
|
const [startsAt, setStartsAt] = useState('');
|
||||||
|
const [maxAttendees, setMaxAttendees] = useState('');
|
||||||
|
|
||||||
|
// Fetch My Circles
|
||||||
|
const { data: circlesData, isLoading: isLoadingCircles, isError: isCirclesError } = useQuery({
|
||||||
|
queryKey: ['companion-circles'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/companion/circles/my');
|
||||||
|
return res.data?.circles as CompanionCircle[] || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const circles = Array.isArray(circlesData) ? circlesData : [];
|
||||||
|
const activeCircle = circles.find((c) => c.id === selectedCircleId) || circles[0] || null;
|
||||||
|
const activeCircleId = activeCircle?.id;
|
||||||
|
|
||||||
|
// Find user's member status in active circle
|
||||||
|
const userMember = activeCircle?.members?.find((m) => m.user_email === user?.email);
|
||||||
|
const userStatus = userMember?.status || 'invited';
|
||||||
|
|
||||||
|
// Fetch Activities for Active Circle
|
||||||
|
const { data: activitiesData, isLoading: isLoadingActivities } = useQuery({
|
||||||
|
queryKey: ['companion-activities', activeCircleId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!activeCircleId) return [];
|
||||||
|
const res = await api.get(`/api/companion/circles/${activeCircleId}/activities`);
|
||||||
|
return res.data?.activities as CompanionActivity[] || [];
|
||||||
|
},
|
||||||
|
enabled: !!activeCircleId && userStatus === 'accepted',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activities = Array.isArray(activitiesData) ? activitiesData : [];
|
||||||
|
|
||||||
|
// Mutation: Respond to Invitation
|
||||||
|
const respondMutation = useMutation({
|
||||||
|
mutationFn: async ({ circleId, decision }: { circleId: string; decision: 'accepted' | 'declined' }) => {
|
||||||
|
const res = await api.post(`/api/companion/circles/${circleId}/respond`, { decision });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionSuccess('Circle invite response recorded.');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companion-circles'] });
|
||||||
|
setTimeout(() => setActionSuccess(null), 3000);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setFormError(err?.response?.data?.msg || 'Failed to submit response.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation: Create Activity
|
||||||
|
const createActivityMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!activeCircleId) throw new Error('No active circle selected');
|
||||||
|
const payload = {
|
||||||
|
title: String(title).trim(),
|
||||||
|
description: String(description).trim(),
|
||||||
|
location_label: String(locationLabel).trim(),
|
||||||
|
starts_at: startsAt,
|
||||||
|
max_attendees: maxAttendees ? Number(maxAttendees) : undefined,
|
||||||
|
};
|
||||||
|
const res = await api.post(`/api/companion/circles/${activeCircleId}/activities`, payload);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionSuccess('Activity scheduled successfully!');
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setLocationLabel('');
|
||||||
|
setStartsAt('');
|
||||||
|
setMaxAttendees('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companion-activities', activeCircleId] });
|
||||||
|
setTimeout(() => setActionSuccess(null), 3000);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setFormError(err?.response?.data?.msg || 'Failed to create activity.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation: RSVP to Activity
|
||||||
|
const rsvpMutation = useMutation({
|
||||||
|
mutationFn: async ({ activityId, status }: { activityId: string; status: RsvpStatus }) => {
|
||||||
|
const res = await api.post(`/api/companion/activities/${activityId}/rsvp`, { status });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionSuccess('RSVP updated successfully!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companion-activities', activeCircleId] });
|
||||||
|
setTimeout(() => setActionSuccess(null), 3000);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setFormError(err?.response?.data?.msg || 'Failed to update RSVP.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
if (!title || !locationLabel || !startsAt) {
|
||||||
|
setFormError('Please fill in title, location, and start time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createActivityMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{actionSuccess && (
|
||||||
|
<div className="p-3 text-sm bg-emerald-500/10 border border-emerald-500/30 text-emerald-600 rounded-lg flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{actionSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<div className="p-3 text-sm bg-destructive/10 border border-destructive/30 text-destructive rounded-lg flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{formError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<div className="space-y-4 md:col-span-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-sky-600" />
|
||||||
|
<span>My Companion Circles</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingCircles ? (
|
||||||
|
<div className="p-8 text-center bg-white rounded-xl border border-slate-200">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-sky-600 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-slate-500">Loading circles...</p>
|
||||||
|
</div>
|
||||||
|
) : isCirclesError ? (
|
||||||
|
<div className="p-4 text-center bg-destructive/5 text-destructive rounded-xl border border-destructive/20 text-sm">
|
||||||
|
Failed to load circles.
|
||||||
|
</div>
|
||||||
|
) : circles.length === 0 ? (
|
||||||
|
<div className="p-6 text-center bg-white rounded-xl border border-slate-200">
|
||||||
|
<Users className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-slate-700">No Circles Found</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
You are not currently part of any companion safety circle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{circles.map((circle) => {
|
||||||
|
const myMember = circle.members?.find((m) => m.user_email === user?.email);
|
||||||
|
const isSelected = (selectedCircleId || activeCircleId) === circle.id;
|
||||||
|
const status = myMember?.status || 'invited';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={circle.id}
|
||||||
|
onClick={() => setSelectedCircleId(circle.id)}
|
||||||
|
className={`p-4 rounded-xl border transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-slate-900 text-white border-slate-900 shadow-sm'
|
||||||
|
: 'bg-white text-slate-900 border-slate-200 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h3 className="font-semibold text-base leading-tight">{circle.name}</h3>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs rounded-full font-medium capitalize ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-sky-500/20 text-sky-300 border border-sky-400/30'
|
||||||
|
: 'bg-slate-100 text-slate-700 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{circle.cadence}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs flex items-center gap-2 opacity-80 mb-3">
|
||||||
|
<span>Members: {circle.members?.length || 0}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="capitalize">Status: {circle.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invitation Response Buttons if pending */}
|
||||||
|
{status === 'invited' && (
|
||||||
|
<div className="pt-2 border-t border-slate-200/20 flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={() => respondMutation.mutate({ circleId: circle.id, decision: 'accepted' })}
|
||||||
|
disabled={respondMutation.isPending}
|
||||||
|
className="flex-1 py-1.5 px-3 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold rounded-md flex items-center justify-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Accept</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => respondMutation.mutate({ circleId: circle.id, decision: 'declined' })}
|
||||||
|
disabled={respondMutation.isPending}
|
||||||
|
className="flex-1 py-1.5 px-3 bg-slate-700 hover:bg-slate-800 text-white text-xs font-semibold rounded-md flex items-center justify-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Decline</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-4 md:col-span-2">
|
||||||
|
{!activeCircle ? (
|
||||||
|
<div className="p-12 text-center bg-white rounded-xl border border-slate-200 text-slate-500">
|
||||||
|
Select a circle from the directory to view or plan activities.
|
||||||
|
</div>
|
||||||
|
) : userStatus !== 'accepted' ? (
|
||||||
|
<div className="p-8 text-center bg-amber-500/10 border border-amber-500/20 text-amber-900 rounded-xl space-y-3">
|
||||||
|
<AlertCircle className="w-8 h-8 text-amber-600 mx-auto" />
|
||||||
|
<h3 className="font-semibold text-lg">Invitation Pending or Declined</h3>
|
||||||
|
<p className="text-sm text-amber-800 max-w-md mx-auto">
|
||||||
|
You must accept the invitation to <strong>{activeCircle.name}</strong> to view and participate in recurring safety activities.
|
||||||
|
</p>
|
||||||
|
{userStatus === 'invited' && (
|
||||||
|
<button
|
||||||
|
onClick={() => respondMutation.mutate({ circleId: activeCircle.id, decision: 'accepted' })}
|
||||||
|
disabled={respondMutation.isPending}
|
||||||
|
className="mt-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<UserCheck className="w-4 h-4" />
|
||||||
|
<span>Accept Invitation Now</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">{activeCircle.name}</h2>
|
||||||
|
<span className="px-2.5 py-0.5 text-xs font-semibold bg-emerald-100 text-emerald-800 rounded-full">
|
||||||
|
Joined
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Recurring cadence: {activeCircle.cadence} • Members: {activeCircle.members?.length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-white text-sm font-semibold rounded-lg flex items-center gap-2 shadow-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>New Activity</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{isLoadingActivities ? (
|
||||||
|
<div className="p-8 text-center bg-white rounded-xl border border-slate-200">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-sky-600 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-slate-500">Fetching circle activities...</p>
|
||||||
|
</div>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<div className="p-12 text-center bg-white rounded-xl border border-slate-200 space-y-3">
|
||||||
|
<Sparkles className="w-8 h-8 text-slate-400 mx-auto" />
|
||||||
|
<h3 className="text-base font-semibold text-slate-800">No Activities Planned Yet</h3>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||||
|
Start coordinating group rides or meetup activities with your companion circle members.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-semibold rounded-lg inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Schedule First Activity</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activities.map((act) => (
|
||||||
|
<div key={act.id} className="p-4 bg-white rounded-xl border border-slate-200 space-y-3 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 text-base">{act.title}</h3>
|
||||||
|
{act.description && (
|
||||||
|
<p className="text-xs text-slate-600 mt-1 leading-relaxed">{act.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-slate-100 text-slate-700 border border-slate-200 rounded-md capitalize shrink-0">
|
||||||
|
{act.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-slate-600 bg-slate-50 p-2.5 rounded-lg border border-slate-100">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{act.location_label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>{new Date(act.starts_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-100 text-xs">
|
||||||
|
<span className="text-slate-500 font-medium">
|
||||||
|
RSVP Status:
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(['going', 'maybe', 'not_going'] as RsvpStatus[]).map((st) => (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
onClick={() => rsvpMutation.mutate({ activityId: act.id, status: st })}
|
||||||
|
disabled={rsvpMutation.isPending}
|
||||||
|
className={`px-3 py-1 rounded-md capitalize font-semibold transition-colors ${
|
||||||
|
st === 'going'
|
||||||
|
? 'hover:bg-emerald-600 hover:text-white bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||||
|
: st === 'maybe'
|
||||||
|
? 'hover:bg-amber-600 hover:text-white bg-amber-50 text-amber-700 border border-amber-200'
|
||||||
|
: 'hover:bg-slate-700 hover:text-white bg-slate-100 text-slate-600 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{st.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Create Activity */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white w-full max-w-md rounded-2xl border border-slate-200 p-6 space-y-4 shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-sky-600" />
|
||||||
|
<span>Plan New Activity</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 p-1 text-lg font-bold leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateSubmit} className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">Activity Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Group Commute to Tech Park"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">Location Label *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={locationLabel}
|
||||||
|
onChange={(e) => setLocationLabel(e.target.value)}
|
||||||
|
placeholder="e.g. Central Station Entrance"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">Start Date & Time *</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startsAt}
|
||||||
|
onChange={(e) => setStartsAt(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Additional details or safety instructions..."
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-slate-700 mb-1">Max Attendees (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={maxAttendees}
|
||||||
|
onChange={(e) => setMaxAttendees(e.target.value)}
|
||||||
|
placeholder="e.g. 4"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createActivityMutation.isPending}
|
||||||
|
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-white font-semibold rounded-lg flex items-center gap-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
{createActivityMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
<span>Schedule Activity</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/components/features/DriverAvailabilityToggle.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { AlertCircle, Car, Power } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DriverAvailabilityToggleProps {
|
||||||
|
onStatusChange?: (isOnline: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverAvailabilityToggle({ onStatusChange }: DriverAvailabilityToggleProps = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: profileData, isLoading } = useQuery({
|
||||||
|
queryKey: ['driver-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/driver/profile');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = profileData?.profile;
|
||||||
|
const isOnline = Boolean(profile?.is_available);
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async (nextStatus: boolean) => {
|
||||||
|
const res = await api.put('/api/driver/availability', {
|
||||||
|
is_available: nextStatus ? 1 : 0,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['driver-profile'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides', 'nearby'] });
|
||||||
|
if (onStatusChange) {
|
||||||
|
onStatusChange(Boolean(data?.profile?.is_available));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
toggleMutation.mutate(!isOnline);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl animate-pulse">
|
||||||
|
<div className="h-6 w-32 bg-slate-200 dark:bg-slate-800 rounded" />
|
||||||
|
<div className="h-8 w-16 bg-slate-200 dark:bg-slate-800 rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/50 rounded-xl flex items-center justify-between text-amber-800 dark:text-amber-300">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-600" />
|
||||||
|
<span>Driver vehicle profile required to go online.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`p-2.5 rounded-lg ${
|
||||||
|
isOnline
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
|
||||||
|
: 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Power className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Dispatch Availability
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
|
isOnline
|
||||||
|
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isOnline ? 'ONLINE' : 'OFFLINE'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
{isOnline
|
||||||
|
? 'Receiving nearby trip requests'
|
||||||
|
: 'Turn on to start accepting rides'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
className={`relative inline-flex h-7 w-14 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 ${
|
||||||
|
isOnline ? 'bg-emerald-600' : 'bg-slate-300 dark:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
isOnline ? 'translate-x-7' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Car className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>
|
||||||
|
{profile.vehicle_color} {profile.vehicle_make} {profile.vehicle_model}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 font-mono font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">
|
||||||
|
{profile.license_plate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/components/features/DriverProfileForm.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { AlertCircle, Car, CheckCircle2, Loader2, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DriverProfileFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverProfileForm({ onSuccess }: DriverProfileFormProps = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
|
||||||
|
const [vehicleMake, setVehicleMake] = useState('');
|
||||||
|
const [vehicleModel, setVehicleModel] = useState('');
|
||||||
|
const [licensePlate, setLicensePlate] = useState('');
|
||||||
|
const [vehicleColor, setVehicleColor] = useState('');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
const { data: profileData, isLoading: isFetching } = useQuery({
|
||||||
|
queryKey: ['driver-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/driver/profile');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = profileData?.profile;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
setVehicleMake(profile.vehicle_make || '');
|
||||||
|
setVehicleModel(profile.vehicle_model || '');
|
||||||
|
setLicensePlate(profile.license_plate || '');
|
||||||
|
setVehicleColor(profile.vehicle_color || '');
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (payload: {
|
||||||
|
vehicle_make: string;
|
||||||
|
vehicle_model: string;
|
||||||
|
license_plate: string;
|
||||||
|
vehicle_color: string;
|
||||||
|
}) => {
|
||||||
|
const res = await api.post('/api/driver/profile', payload);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['driver-profile'] });
|
||||||
|
if (user) {
|
||||||
|
updateUser({ ...user, role: 'driver' });
|
||||||
|
}
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.response?.data?.msg || err?.message || 'Failed to save vehicle profile.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
if (!String((vehicleMake) ?? '').trim() || !String((vehicleModel) ?? '').trim() || !String((licensePlate) ?? '').trim() || !String((vehicleColor) ?? '').trim()) {
|
||||||
|
setErrorMsg('All vehicle fields are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMutation.mutate({
|
||||||
|
vehicle_make: String((vehicleMake) ?? '').trim(),
|
||||||
|
vehicle_model: String((vehicleModel) ?? '').trim(),
|
||||||
|
license_plate: String((licensePlate) ?? '').trim(),
|
||||||
|
vehicle_color: String((vehicleColor) ?? '').trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 bg-white rounded-xl border border-slate-200">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-slate-600" />
|
||||||
|
<span className="ml-2 text-sm text-slate-600">Loading driver vehicle profile...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||||
|
<div className="w-full md:w-1/3 flex flex-col items-center text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
|
<img
|
||||||
|
src="/assets/application-02-driver-vehicle-form.png"
|
||||||
|
alt="Collect driver vehicle details visual"
|
||||||
|
className="w-full h-36 object-cover rounded-md mb-3"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-slate-900 font-semibold text-base">
|
||||||
|
<Car className="w-5 h-5 text-sky-600" />
|
||||||
|
<span>Driver Vehicle Info</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Passengers identify your vehicle by make, model, color, and plate number during pick-up.
|
||||||
|
</p>
|
||||||
|
{profile && (
|
||||||
|
<div className="mt-3 w-full bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs rounded-md p-2 flex items-center justify-center gap-1.5 font-medium">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||||
|
<span>Registered Vehicle Saved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="w-full md:w-2/3 flex flex-col gap-4">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||||
|
Vehicle Make
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vehicleMake}
|
||||||
|
onChange={(e) => setVehicleMake(e.target.value)}
|
||||||
|
placeholder="e.g. Toyota, Honda, Tesla"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||||
|
Vehicle Model
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vehicleModel}
|
||||||
|
onChange={(e) => setVehicleModel(e.target.value)}
|
||||||
|
placeholder="e.g. Camry, Civic, Model 3"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||||
|
License Plate
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={licensePlate}
|
||||||
|
onChange={(e) => setLicensePlate(e.target.value)}
|
||||||
|
placeholder="e.g. 7XYZ890"
|
||||||
|
className="w-full px-3 py-2 text-sm uppercase tracking-wider border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||||
|
Vehicle Color
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vehicleColor}
|
||||||
|
onChange={(e) => setVehicleColor(e.target.value)}
|
||||||
|
placeholder="e.g. Silver, Black, White"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-slate-900 hover:bg-slate-800 text-white font-medium text-sm rounded-md transition-colors disabled:opacity-50 min-h-[44px]"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>{profile ? 'Update Vehicle Details' : 'Save Vehicle Details'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/src/components/features/DriverRequestCard.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { AlertCircle, Check, Clock, MapPin, Navigation, RefreshCw, User, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DriverRequestCardProps {
|
||||||
|
onAcceptRide?: (ride: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverRequestCard({ onAcceptRide }: DriverRequestCardProps = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dismissedIds, setDismissedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['rides', 'nearby'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/rides/nearby');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawRides = Array.isArray(data?.rides) ? data.rides : [];
|
||||||
|
const nearbyRides = rawRides.filter(
|
||||||
|
(r: any) => r && r.status === 'requested' && !dismissedIds.includes(r.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptMutation = useMutation({
|
||||||
|
mutationFn: async (rideId: string) => {
|
||||||
|
const res = await api.post(`/api/rides/${rideId}/accept`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (responseData) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides', 'nearby'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides', 'my'] });
|
||||||
|
if (onAcceptRide && responseData?.ride) {
|
||||||
|
onAcceptRide(responseData.ride);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDecline = (rideId: string) => {
|
||||||
|
setDismissedIds((prev) => [...prev, rideId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-5 w-36 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-12 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="h-24 bg-slate-100 dark:bg-slate-800/50 rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 text-center">
|
||||||
|
<AlertCircle className="w-8 h-8 text-amber-500 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
Unable to load incoming requests
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-700 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearbyRides.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 text-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center mx-auto mb-3 text-slate-400">
|
||||||
|
<Clock className="w-5 h-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Searching for Nearby Requests
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-xs mx-auto">
|
||||||
|
Keep your availability switch ON. Incoming rider dispatch cards will show up here automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-sm font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||||
|
Incoming Requests ({nearbyRides.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 dark:text-slate-500">Live Polling</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nearbyRides.map((ride: any) => {
|
||||||
|
const passengerName =
|
||||||
|
ride.passenger_name ??
|
||||||
|
(ride.passenger_email ? ride.passenger_email.split('@')[0] : 'Passenger');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ride.id}
|
||||||
|
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 shadow-sm hover:border-slate-300 dark:hover:border-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-300 font-semibold text-xs">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
{passengerName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{ride.passenger_phone || 'Passenger Request'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="px-2.5 py-1 text-xs font-semibold rounded-full bg-blue-50 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
||||||
|
New Request
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
Pickup Location
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 mt-0.5">
|
||||||
|
{ride.pickup_location || 'Pickup Point'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 text-sky-600 dark:text-sky-400">
|
||||||
|
<Navigation className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
Destination
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 mt-0.5">
|
||||||
|
{ride.destination_location || 'Dropoff Point'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-slate-100 dark:border-slate-800 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDecline(ride.id)}
|
||||||
|
className="flex-1 py-2 px-3 inline-flex items-center justify-center gap-1.5 text-xs font-semibold rounded-lg border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => acceptMutation.mutate(ride.id)}
|
||||||
|
disabled={acceptMutation.isPending}
|
||||||
|
className="flex-1 py-2 px-3 inline-flex items-center justify-center gap-1.5 text-xs font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white transition-colors shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
{acceptMutation.isPending ? 'Accepting...' : 'Accept Ride'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
frontend/src/components/features/DriverTripNavigation.tsx
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AlertCircle, CheckCircle2, Compass, MapPin, Navigation, Radio, ShieldAlert, UserCheck } from 'lucide-react';
|
||||||
|
import { api } from '../../api';
|
||||||
|
|
||||||
|
export interface Ride {
|
||||||
|
id: string;
|
||||||
|
passenger_email: string;
|
||||||
|
passenger_name?: string;
|
||||||
|
passenger_phone?: string;
|
||||||
|
driver_email?: string;
|
||||||
|
driver_name?: string;
|
||||||
|
driver_phone?: string;
|
||||||
|
pickup_location: string;
|
||||||
|
pickup_lat: number;
|
||||||
|
pickup_lng: number;
|
||||||
|
destination_location: string;
|
||||||
|
destination_lat: number;
|
||||||
|
destination_lng: number;
|
||||||
|
status: 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||||
|
driver_lat?: number | null;
|
||||||
|
driver_lng?: number | null;
|
||||||
|
rating?: number | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverTripNavigationProps {
|
||||||
|
ride?: Ride;
|
||||||
|
onRideUpdated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverTripNavigation({ ride, onRideUpdated }: DriverTripNavigationProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [transmitting, setTransmitting] = useState(true);
|
||||||
|
const [, setSimulatedProgress] = useState(0);
|
||||||
|
const [currentCoords, setCurrentCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
const [lastPingTime, setLastPingTime] = useState<string | null>(null);
|
||||||
|
const [pingError, setPingError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ride) {
|
||||||
|
if (ride.driver_lat && ride.driver_lng) {
|
||||||
|
setCurrentCoords({ lat: Number(ride.driver_lat), lng: Number(ride.driver_lng) });
|
||||||
|
} else {
|
||||||
|
setCurrentCoords({ lat: Number(ride.pickup_lat), lng: Number(ride.pickup_lng) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ride?.id, ride?.driver_lat, ride?.driver_lng, ride?.pickup_lat, ride?.pickup_lng]);
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: async (nextStatus: 'picked_up' | 'completed') => {
|
||||||
|
if (!ride?.id) throw new Error('No active ride');
|
||||||
|
const res = await api.post(`/api/rides/${ride.id}/status`, { status: nextStatus });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nearby-rides'] });
|
||||||
|
if (ride?.id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ride', ride.id] });
|
||||||
|
}
|
||||||
|
if (onRideUpdated) onRideUpdated();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!ride?.id) throw new Error('No active ride');
|
||||||
|
const res = await api.post(`/api/rides/${ride.id}/cancel`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nearby-rides'] });
|
||||||
|
if (ride?.id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ride', ride.id] });
|
||||||
|
}
|
||||||
|
if (onRideUpdated) onRideUpdated();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationMutation = useMutation({
|
||||||
|
mutationFn: async (coords: { lat: number; lng: number }) => {
|
||||||
|
if (!ride?.id) return;
|
||||||
|
const res = await api.post(`/api/rides/${ride.id}/location`, {
|
||||||
|
driver_lat: coords.lat,
|
||||||
|
driver_lng: coords.lng,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setLastPingTime(new Date().toLocaleTimeString());
|
||||||
|
setPingError(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setPingError(err?.message || 'GPS transmitter error');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ride || !transmitting) {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ride.status !== 'accepted' && ride.status !== 'picked_up') {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
if ('geolocation' in navigator) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const nextLat = pos.coords.latitude;
|
||||||
|
const nextLng = pos.coords.longitude;
|
||||||
|
setCurrentCoords({ lat: nextLat, lng: nextLng });
|
||||||
|
locationMutation.mutate({ lat: nextLat, lng: nextLng });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
simulateAndPing();
|
||||||
|
},
|
||||||
|
{ timeout: 3000, enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
simulateAndPing();
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [ride?.id, ride?.status, transmitting]);
|
||||||
|
|
||||||
|
const simulateAndPing = () => {
|
||||||
|
if (!ride) return;
|
||||||
|
setSimulatedProgress((prev) => {
|
||||||
|
const nextStep = prev >= 0.95 ? 0.05 : prev + 0.05;
|
||||||
|
const startLat = ride.status === 'accepted' ? (ride.driver_lat || ride.pickup_lat - 0.01) : ride.pickup_lat;
|
||||||
|
const startLng = ride.status === 'accepted' ? (ride.driver_lng || ride.pickup_lng - 0.01) : ride.pickup_lng;
|
||||||
|
const endLat = ride.status === 'accepted' ? ride.pickup_lat : ride.destination_lat;
|
||||||
|
const endLng = ride.status === 'accepted' ? ride.pickup_lng : ride.destination_lng;
|
||||||
|
|
||||||
|
const calcLat = startLat + (endLat - startLat) * nextStep;
|
||||||
|
const calcLng = startLng + (endLng - startLng) * nextStep;
|
||||||
|
|
||||||
|
setCurrentCoords({ lat: calcLat, lng: calcLng });
|
||||||
|
locationMutation.mutate({ lat: calcLat, lng: calcLng });
|
||||||
|
return nextStep;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ride) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-800 text-slate-400 p-6 rounded-xl text-center">
|
||||||
|
<Compass className="w-8 h-8 mx-auto mb-2 text-slate-500 animate-spin" />
|
||||||
|
<p className="text-sm">No active ride navigation session</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAccepted = ride.status === 'accepted';
|
||||||
|
const isPickedUp = ride.status === 'picked_up';
|
||||||
|
const isCompleted = ride.status === 'completed';
|
||||||
|
const isCancelled = ride.status === 'cancelled';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 text-slate-100 border border-slate-800 rounded-xl p-5 shadow-xl space-y-5">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 bg-sky-500/10 text-sky-400 rounded-lg border border-sky-500/20">
|
||||||
|
<Navigation className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-white">Active Trip Navigation</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{ride.passenger_name ? `Passenger: ${ride.passenger_name}` : `Passenger ID: ${ride.passenger_email}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{isAccepted && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||||
|
En Route to Pickup
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPickedUp && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||||
|
Driving to Destination
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCompleted && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCancelled && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 bg-slate-950/60 p-4 rounded-lg border border-slate-800/80">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<MapPin className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider font-semibold text-emerald-400">
|
||||||
|
Pickup Point
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-slate-200 truncate">{ride.pickup_location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-2.5 border-l-2 border-dashed border-slate-700 h-4 my-1" />
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<MapPin className="w-4 h-4 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider font-semibold text-sky-400">
|
||||||
|
Destination
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-slate-200 truncate">{ride.destination_location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isAccepted || isPickedUp) && (
|
||||||
|
<div className="bg-slate-950/80 rounded-lg p-3.5 border border-slate-800 flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`p-2 rounded-full ${transmitting ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-800 text-slate-500'}`}>
|
||||||
|
<Radio className={`w-4 h-4 ${transmitting ? 'animate-pulse' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs font-medium text-slate-200">
|
||||||
|
{transmitting ? 'GPS Transmitter Active' : 'GPS Transmitter Paused'}
|
||||||
|
</span>
|
||||||
|
{lastPingTime && (
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
Last ping: {lastPingTime}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
{currentCoords
|
||||||
|
? `Lat: ${currentCoords.lat.toFixed(5)}, Lng: ${currentCoords.lng.toFixed(5)}`
|
||||||
|
: 'Acquiring GPS fix...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTransmitting(!transmitting)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800 text-slate-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{transmitting ? 'Pause GPS' : 'Resume GPS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pingError && (
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-amber-400 bg-amber-500/10 p-2.5 rounded-md border border-amber-500/20">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{pingError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 space-y-3">
|
||||||
|
{isAccepted && (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
onClick={() => statusMutation.mutate('picked_up')}
|
||||||
|
className="flex-1 flex items-center justify-center space-x-2 bg-emerald-600 hover:bg-emerald-500 text-white font-medium py-3 px-4 rounded-lg transition-all shadow-lg shadow-emerald-950/40 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<UserCheck className="w-5 h-5" />
|
||||||
|
<span>
|
||||||
|
{statusMutation.isPending ? 'Updating...' : 'Arrived & Picked Up Passenger'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={cancelMutation.isPending}
|
||||||
|
onClick={() => cancelMutation.mutate()}
|
||||||
|
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-red-400 rounded-lg font-medium text-sm transition-colors border border-slate-700 flex items-center justify-center space-x-1"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-4 h-4" />
|
||||||
|
<span>Cancel Trip</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPickedUp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
onClick={() => statusMutation.mutate('completed')}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 bg-sky-600 hover:bg-sky-500 text-white font-medium py-3 px-4 rounded-lg transition-all shadow-lg shadow-sky-950/40 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span>
|
||||||
|
{statusMutation.isPending ? 'Completing...' : 'Arrived at Destination - Complete Ride'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-emerald-400 bg-emerald-500/10 p-3 rounded-lg border border-emerald-500/20 text-sm font-medium">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span>Trip successfully completed. Ready for next dispatch.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCancelled && (
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-red-400 bg-red-500/10 p-3 rounded-lg border border-red-500/20 text-sm font-medium">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>This trip has been cancelled.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
frontend/src/components/features/RideBookingForm.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { ArrowRightLeft, Car, CheckCircle2, Clock, Compass, DollarSign, MapPin, Navigation } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface PresetLocation {
|
||||||
|
label: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_LOCATIONS: PresetLocation[] = [
|
||||||
|
{ label: 'Downtown Central Station (100 Main St)', lat: 37.7749, lng: -122.4194 },
|
||||||
|
{ label: 'Tech Innovation Park (500 Oak Ave)', lat: 37.7885, lng: -122.4014 },
|
||||||
|
{ label: 'Metro Airport Terminal 2', lat: 37.6213, lng: -122.379 },
|
||||||
|
{ label: 'Financial District Plaza', lat: 37.7925, lng: -122.401 },
|
||||||
|
{ label: 'Westside Shopping Galleria', lat: 37.765, lng: -122.45 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface RideBookingFormProps {
|
||||||
|
onRideCreated?: (ride: any) => void;
|
||||||
|
onSetCoordinates?: (pickup: [number, number] | null, destination: [number, number] | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos((lat1 * Math.PI) / 180) *
|
||||||
|
Math.cos((lat2 * Math.PI) / 180) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return Math.round(R * c * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RideBookingForm({ onRideCreated, onSetCoordinates }: RideBookingFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [pickupText, setPickupText] = useState(PRESET_LOCATIONS[0].label);
|
||||||
|
const [pickupCoords, setPickupCoords] = useState<[number, number]>([
|
||||||
|
PRESET_LOCATIONS[0].lat,
|
||||||
|
PRESET_LOCATIONS[0].lng
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [destText, setDestText] = useState(PRESET_LOCATIONS[1].label);
|
||||||
|
const [destCoords, setDestCoords] = useState<[number, number]>([
|
||||||
|
PRESET_LOCATIONS[1].lat,
|
||||||
|
PRESET_LOCATIONS[1].lng
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSetCoordinates) {
|
||||||
|
onSetCoordinates(pickupCoords, destCoords);
|
||||||
|
}
|
||||||
|
}, [pickupCoords, destCoords, onSetCoordinates]);
|
||||||
|
|
||||||
|
const distance = calculateDistanceKm(
|
||||||
|
pickupCoords[0],
|
||||||
|
pickupCoords[1],
|
||||||
|
destCoords[0],
|
||||||
|
destCoords[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const estimatedFare = (3.5 + distance * 1.8).toFixed(2);
|
||||||
|
const estimatedTimeMins = Math.max(3, Math.round(distance * 2.5));
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const tempText = pickupText;
|
||||||
|
const tempCoords = pickupCoords;
|
||||||
|
setPickupText(destText);
|
||||||
|
setPickupCoords(destCoords);
|
||||||
|
setDestText(tempText);
|
||||||
|
setDestCoords(tempCoords);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRideMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
pickup_location: pickupText,
|
||||||
|
pickup_lat: pickupCoords[0],
|
||||||
|
pickup_lng: pickupCoords[1],
|
||||||
|
destination_location: destText,
|
||||||
|
destination_lat: destCoords[0],
|
||||||
|
destination_lng: destCoords[1]
|
||||||
|
};
|
||||||
|
const res = await api.post('/api/rides', payload);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success('Ride requested successfully!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||||
|
if (onRideCreated && data.ride) {
|
||||||
|
onRideCreated(data.ride);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.response?.data?.msg || err?.message || 'Failed to request ride');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!String((pickupText) ?? '').trim() || !String((destText) ?? '').trim()) {
|
||||||
|
toast.error('Please enter both pickup and destination locations.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pickupCoords[0] === destCoords[0] && pickupCoords[1] === destCoords[1]) {
|
||||||
|
toast.error('Pickup and destination points must be different.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createRideMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-300 shadow-sm p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Car className="w-5 h-5 text-slate-800" />
|
||||||
|
<h2 className="font-semibold text-slate-900 text-lg">Request a Ride</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs px-2.5 py-1 bg-slate-100 text-slate-700 font-medium rounded-full border border-slate-200">
|
||||||
|
Instant Dispatch
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-600 flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-emerald-600 inline-block"></span>
|
||||||
|
Pickup Location
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pickupText}
|
||||||
|
onChange={(e) => setPickupText(e.target.value)}
|
||||||
|
placeholder="Enter pickup location..."
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 text-sm bg-slate-50 border border-slate-300 rounded-lg text-slate-900 focus:bg-white focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||||
|
/>
|
||||||
|
<MapPin className="w-4 h-4 text-emerald-600 absolute left-3 top-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{PRESET_LOCATIONS.map((preset, idx) => (
|
||||||
|
<button
|
||||||
|
key={`p-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPickupText(preset.label);
|
||||||
|
setPickupCoords([preset.lat, preset.lng]);
|
||||||
|
}}
|
||||||
|
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||||
|
pickupText === preset.label
|
||||||
|
? 'bg-emerald-50 text-emerald-800 border-emerald-300 font-medium'
|
||||||
|
: 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{String((preset.label.split('(')[0]) ?? '').trim()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center -my-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSwap}
|
||||||
|
title="Swap pickup & destination"
|
||||||
|
className="p-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 border border-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-600 flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-sky-600 inline-block"></span>
|
||||||
|
Destination Location
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={destText}
|
||||||
|
onChange={(e) => setDestText(e.target.value)}
|
||||||
|
placeholder="Enter destination..."
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 text-sm bg-slate-50 border border-slate-300 rounded-lg text-slate-900 focus:bg-white focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||||
|
/>
|
||||||
|
<Navigation className="w-4 h-4 text-sky-600 absolute left-3 top-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{PRESET_LOCATIONS.map((preset, idx) => (
|
||||||
|
<button
|
||||||
|
key={`d-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDestText(preset.label);
|
||||||
|
setDestCoords([preset.lat, preset.lng]);
|
||||||
|
}}
|
||||||
|
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||||
|
destText === preset.label
|
||||||
|
? 'bg-sky-50 text-sky-800 border-sky-300 font-medium'
|
||||||
|
: 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{String((preset.label.split('(')[0]) ?? '').trim()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 flex items-center justify-center gap-1">
|
||||||
|
<Compass className="w-3.5 h-3.5" />
|
||||||
|
Distance
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-slate-900 text-sm mt-0.5">{distance} km</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-x border-slate-200">
|
||||||
|
<div className="text-xs text-slate-500 flex items-center justify-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
Est. Time
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-slate-900 text-sm mt-0.5">~{estimatedTimeMins} mins</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 flex items-center justify-center gap-1">
|
||||||
|
<DollarSign className="w-3.5 h-3.5" />
|
||||||
|
Est. Fare
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-slate-900 text-sm mt-0.5">${estimatedFare}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createRideMutation.isPending}
|
||||||
|
className="w-full bg-slate-900 hover:bg-slate-800 text-white font-medium py-3 rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createRideMutation.isPending ? (
|
||||||
|
<span>Processing Ride Request...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Confirm & Request Ride</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
frontend/src/components/features/RideTrackingSheet.tsx
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { AlertTriangle, Car, CheckCircle, MapPin, Navigation, Phone, RefreshCw, ShieldCheck, Star, XCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface RideTrackingSheetProps {
|
||||||
|
rideId: string | null;
|
||||||
|
onRideCancelled?: () => void;
|
||||||
|
onRideCompleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RideTrackingSheet({ rideId, onRideCancelled, onRideCompleted }: RideTrackingSheetProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [rating, setRating] = useState<number>(5);
|
||||||
|
const [hoverRating, setHoverRating] = useState<number>(0);
|
||||||
|
const [hasRated, setHasRated] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const { data: rideData, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['ride', rideId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!rideId) return null;
|
||||||
|
const res = await api.get(`/api/rides/${rideId}`);
|
||||||
|
return res.data?.ride ?? res.data;
|
||||||
|
},
|
||||||
|
enabled: !!rideId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const status = query.state.data?.status;
|
||||||
|
if (status === 'completed' || status === 'cancelled') return false;
|
||||||
|
return 3000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!rideId) return;
|
||||||
|
const res = await api.post(`/api/rides/${rideId}/cancel`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ride cancelled');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ride', rideId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||||
|
if (onRideCancelled) onRideCancelled();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.response?.data?.msg || err?.message || 'Failed to cancel ride');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rateMutation = useMutation({
|
||||||
|
mutationFn: async (selectedRating: number) => {
|
||||||
|
if (!rideId) return;
|
||||||
|
const res = await api.post(`/api/rides/${rideId}/rate`, { rating: selectedRating });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Thank you for rating your trip!');
|
||||||
|
setHasRated(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ride', rideId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||||
|
if (onRideCompleted) onRideCompleted();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.response?.data?.msg || err?.message || 'Failed to submit rating');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rideId) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-300 p-6 text-center space-y-3">
|
||||||
|
<Car className="w-10 h-10 text-slate-400 mx-auto" />
|
||||||
|
<h3 className="font-semibold text-slate-800 text-base">No Active Ride Selected</h3>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||||
|
Enter your pickup location and destination in the booking form above to start a live ride request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-300 p-6 text-center space-y-3">
|
||||||
|
<RefreshCw className="w-6 h-6 text-slate-600 animate-spin mx-auto" />
|
||||||
|
<p className="text-sm font-medium text-slate-700">Loading ride tracking details...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ride = rideData;
|
||||||
|
if (!ride) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-300 p-6 text-center space-y-3">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium text-slate-700">Ride details unavailable or not found.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-xs font-medium text-slate-900 underline hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Try Reloading
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStatus = ride.status || 'requested';
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ key: 'requested', label: 'Requested', desc: 'Searching for nearby drivers' },
|
||||||
|
{ key: 'accepted', label: 'Accepted', desc: 'Driver en route to pickup' },
|
||||||
|
{ key: 'picked_up', label: 'Picked Up', desc: 'Trip in progress' },
|
||||||
|
{ key: 'completed', label: 'Completed', desc: 'Arrived at destination' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentStageIndex = stages.findIndex((s) => s.key === currentStatus);
|
||||||
|
const isCancelled = currentStatus === 'cancelled';
|
||||||
|
const isCompleted = currentStatus === 'completed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-300 shadow-md p-5 space-y-5">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs uppercase tracking-wider text-slate-500 font-semibold">Ride Status</span>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{isCancelled ? (
|
||||||
|
<span className="text-sm font-bold text-red-600 flex items-center gap-1.5">
|
||||||
|
<XCircle className="w-4 h-4" /> Ride Cancelled
|
||||||
|
</span>
|
||||||
|
) : isCompleted ? (
|
||||||
|
<span className="text-sm font-bold text-emerald-700 flex items-center gap-1.5">
|
||||||
|
<CheckCircle className="w-4 h-4" /> Trip Completed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-bold text-slate-900 flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-sky-500 animate-pulse"></span>
|
||||||
|
{stages.find((s) => s.key === currentStatus)?.label || currentStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="p-1.5 rounded-lg border border-slate-200 hover:bg-slate-100 text-slate-600 transition-colors"
|
||||||
|
title="Refresh ride status"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCancelled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{stages.map((stage, idx) => {
|
||||||
|
const isPast = currentStageIndex > idx;
|
||||||
|
const isCurrent = currentStageIndex === idx;
|
||||||
|
return (
|
||||||
|
<div key={stage.key} className="space-y-1">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
isPast || isCurrent
|
||||||
|
? isCompleted
|
||||||
|
? 'bg-emerald-600'
|
||||||
|
: 'bg-slate-900'
|
||||||
|
: 'bg-slate-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-center font-medium text-slate-600 truncate">
|
||||||
|
{stage.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200 space-y-2 text-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 font-medium">Pickup</div>
|
||||||
|
<div className="font-semibold text-slate-900">{ride.pickup_location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 pt-2 flex items-start gap-2">
|
||||||
|
<Navigation className="w-4 h-4 text-sky-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 font-medium">Destination</div>
|
||||||
|
<div className="font-semibold text-slate-900">{ride.destination_location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ride.driver_email ? (
|
||||||
|
<div className="bg-slate-900 text-white rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center">
|
||||||
|
<Car className="w-5 h-5 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm">
|
||||||
|
{ride.driver_name || ride.driver_email.split('@')[0]}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 flex items-center gap-1">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
Verified Driver
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ride.driver_phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${ride.driver_phone}`}
|
||||||
|
className="p-2 rounded-full bg-slate-800 hover:bg-slate-700 text-slate-200 transition-colors"
|
||||||
|
title="Call driver"
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ride.driver_lat && ride.driver_lng && (
|
||||||
|
<div className="text-xs text-slate-300 border-t border-slate-800 pt-2 flex items-center justify-between">
|
||||||
|
<span>Live GPS Active</span>
|
||||||
|
<span className="font-mono text-slate-400">
|
||||||
|
{Number(ride.driver_lat).toFixed(4)}, {Number(ride.driver_lng).toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : !isCancelled && !isCompleted ? (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800 flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin shrink-0 text-amber-600" />
|
||||||
|
<span>Searching for an available driver nearby...</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(currentStatus === 'requested' || currentStatus === 'accepted') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cancelMutation.mutate()}
|
||||||
|
disabled={cancelMutation.isPending}
|
||||||
|
className="w-full bg-red-50 hover:bg-red-100 text-red-700 font-medium py-2.5 rounded-lg border border-red-200 text-xs flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
{cancelMutation.isPending ? 'Cancelling Ride...' : 'Cancel Ride Request'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-center space-y-3">
|
||||||
|
<h4 className="font-semibold text-slate-900 text-sm">
|
||||||
|
{hasRated || ride.rating ? 'Trip Rating Submitted' : 'How was your ride?'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{hasRated || ride.rating
|
||||||
|
? `You rated this trip ${ride.rating || rating} stars.`
|
||||||
|
: 'Please rate your driver experience below.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const active = (hoverRating || ride.rating || rating) >= star;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
disabled={hasRated || !!ride.rating || rateMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setRating(star);
|
||||||
|
rateMutation.mutate(star);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => !hasRated && !ride.rating && setHoverRating(star)}
|
||||||
|
onMouseLeave={() => !hasRated && !ride.rating && setHoverRating(0)}
|
||||||
|
className="p-1 focus:outline-none disabled:cursor-default"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-6 h-6 transition-colors ${
|
||||||
|
active ? 'fill-amber-400 text-amber-400' : 'text-slate-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
frontend/src/components/features/SafetyOnboardingCard.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { AlertTriangle, CheckCircle2, Clock, FileCheck, Loader2, Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SafetyOnboardingCardProps {
|
||||||
|
onCompleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SafetyOnboardingCard({ onCompleted }: SafetyOnboardingCardProps = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [personaLabel, setPersonaLabel] = useState('Standard Commuter');
|
||||||
|
const [safetyConsent, setSafetyConsent] = useState(true);
|
||||||
|
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const { data: profileData, isLoading } = useQuery({
|
||||||
|
queryKey: ['companion-safety-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/companion/safety-profile');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = profileData?.profile;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
if (profile.persona_label) setPersonaLabel(profile.persona_label);
|
||||||
|
setSafetyConsent(profile.safety_consent === 1);
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const updateProfileMutation = useMutation({
|
||||||
|
mutationFn: async (payload: { persona_label: string; safety_consent: number }) => {
|
||||||
|
const res = await api.put('/api/companion/safety-profile', payload);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companion-safety-profile'] });
|
||||||
|
setMsg({ type: 'success', text: 'Companion safety profile saved.' });
|
||||||
|
if (onCompleted) onCompleted();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setMsg({
|
||||||
|
type: 'error',
|
||||||
|
text: err?.response?.data?.msg || err?.message || 'Failed to update safety profile.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bgCheckMutation = useMutation({
|
||||||
|
mutationFn: async (consent: boolean) => {
|
||||||
|
const res = await api.post('/api/companion/safety-profile/background-check', { consent });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companion-safety-profile'] });
|
||||||
|
setMsg({ type: 'success', text: 'Background check verification requested.' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setMsg({
|
||||||
|
type: 'error',
|
||||||
|
text: err?.response?.data?.msg || err?.message || 'Failed to request background check.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveProfile = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
updateProfileMutation.mutate({
|
||||||
|
persona_label: String((personaLabel) ?? '').trim(),
|
||||||
|
safety_consent: safetyConsent ? 1 : 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestBgCheck = () => {
|
||||||
|
setMsg(null);
|
||||||
|
bgCheckMutation.mutate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 bg-slate-900 text-white rounded-xl border border-slate-800">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-sky-400" />
|
||||||
|
<span className="ml-2 text-sm text-slate-300">Loading companion safety profile...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkStatus = profile?.background_check_status || 'not_started';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 text-white rounded-xl border border-slate-800 p-6 shadow-xl">
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||||
|
<div className="w-full md:w-1/3 flex flex-col items-center text-center p-4 bg-slate-800/80 rounded-lg border border-slate-700">
|
||||||
|
<img
|
||||||
|
src="/assets/landing-04-safety-companion-framework.png"
|
||||||
|
alt="Safety Companion Verification Badge Graphic"
|
||||||
|
className="w-full h-32 object-cover rounded-md mb-3"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-white font-semibold text-base">
|
||||||
|
<Shield className="w-5 h-5 text-emerald-400" />
|
||||||
|
<span>Companion Safety</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Private persona settings & verified companion safety framework for trust on every trip.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full pt-3 border-t border-slate-700/60 flex flex-col items-center">
|
||||||
|
<span className="text-xs text-slate-400 mb-1 font-medium">Verification Status:</span>
|
||||||
|
{checkStatus === 'approved' && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-950/80 border border-emerald-500/40 text-emerald-300 text-xs font-medium rounded-full">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> Verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checkStatus === 'pending' && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-amber-950/80 border border-amber-500/40 text-amber-300 text-xs font-medium rounded-full">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-400 animate-pulse" /> Check Pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checkStatus === 'rejected' && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-red-950/80 border border-red-500/40 text-red-300 text-xs font-medium rounded-full">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-red-400" /> Needs Attention
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checkStatus === 'not_started' && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-slate-800 border border-slate-600 text-slate-300 text-xs font-medium rounded-full">
|
||||||
|
Not Started
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-2/3 flex flex-col gap-5">
|
||||||
|
{msg && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md text-sm flex items-center gap-2 border ${
|
||||||
|
msg.type === 'success'
|
||||||
|
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-200'
|
||||||
|
: 'bg-red-950/60 border-red-500/40 text-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{msg.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveProfile} className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||||||
|
Persona Label
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={personaLabel}
|
||||||
|
onChange={(e) => setPersonaLabel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-white text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
>
|
||||||
|
<option value="Standard Commuter">Standard Commuter</option>
|
||||||
|
<option value="Night Travel Safety">Night Travel Safety Focus</option>
|
||||||
|
<option value="Quiet Ride Companion">Quiet Ride Preferred</option>
|
||||||
|
<option value="Frequent Traveler">Frequent City Traveler</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-slate-800/60 border border-slate-700/80 rounded-md">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="safetyConsent"
|
||||||
|
checked={safetyConsent}
|
||||||
|
onChange={(e) => setSafetyConsent(e.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-600 bg-slate-700 text-sky-500 focus:ring-sky-500 focus:ring-offset-slate-900"
|
||||||
|
/>
|
||||||
|
<label htmlFor="safetyConsent" className="text-xs text-slate-300 leading-relaxed cursor-pointer">
|
||||||
|
I consent to safety telemetry logging during active rides and agreeing to RideFlow companion safety guidelines.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateProfileMutation.isPending}
|
||||||
|
className="px-4 py-2 bg-sky-600 hover:bg-sky-500 text-white font-medium text-sm rounded-md transition-colors disabled:opacity-50 min-h-[44px] inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{updateProfileMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
<span>Save Safety Profile</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-slate-800 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-slate-200 flex items-center gap-1.5">
|
||||||
|
<FileCheck className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Background Check Verification</span>
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Submit for official companion safety verification check.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkStatus === 'not_started' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRequestBgCheck}
|
||||||
|
disabled={bgCheckMutation.isPending}
|
||||||
|
className="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-medium rounded-md transition-colors disabled:opacity-50 min-h-[44px] whitespace-nowrap inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{bgCheckMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>Request Verification</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checkStatus === 'pending' && (
|
||||||
|
<span className="text-xs text-amber-300 font-medium bg-amber-950/60 px-3 py-1.5 rounded border border-amber-800/50">
|
||||||
|
Verification Review in Progress
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checkStatus === 'approved' && (
|
||||||
|
<span className="text-xs text-emerald-300 font-medium bg-emerald-950/60 px-3 py-1.5 rounded border border-emerald-800/50">
|
||||||
|
Identity & Background Verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
frontend/src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export { Dialog as AlertDialog, DialogContent as AlertDialogContent, DialogTitle as AlertDialogTitle, DialogDescription as AlertDialogDescription, DialogClose as AlertDialogCancel } from './dialog';
|
||||||
7
frontend/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Alert = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='alert' className={cx('rounded-lg border p-4 text-sm', className)} {...props} />;
|
||||||
|
export const AlertTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h5 className='mb-1 font-medium' {...props} />;
|
||||||
|
export const AlertDescription = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='text-muted-foreground' {...props} />;
|
||||||
|
export default Alert;
|
||||||
5
frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Badge = ({ className, variant, size, ...props }: React.HTMLAttributes<HTMLSpanElement> & { variant?: string; size?: string }) => <span className={cx('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', className)} {...props} />;
|
||||||
|
export default Badge;
|
||||||
6
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }>(({ className, variant, size, ...props }, ref) => <button ref={ref} className={cx('inline-flex min-h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', className)} {...props} />);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
export default Button;
|
||||||
10
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />;
|
||||||
|
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-1.5 p-6', className)} {...props} />;
|
||||||
|
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => <h3 className={cx('text-lg font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||||
|
export const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={cx('text-sm text-muted-foreground', className)} {...props} />;
|
||||||
|
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('p-6 pt-0', className)} {...props} />;
|
||||||
|
export const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex items-center p-6 pt-0', className)} {...props} />;
|
||||||
|
export default Card;
|
||||||
5
frontend/src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' className='size-4 rounded border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
|
||||||
|
Checkbox.displayName = 'Checkbox';
|
||||||
|
export default Checkbox;
|
||||||
9
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Dialog = ({ open = true, children }: { open?: boolean; children?: React.ReactNode }) => open ? <div role='dialog' aria-modal='true' className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4'>{children}</div> : null;
|
||||||
|
export const DialogContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg', className)} {...props} />;
|
||||||
|
export const DialogTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h2 className='text-lg font-semibold' {...props} />;
|
||||||
|
export const DialogDescription = (props: React.HTMLAttributes<HTMLParagraphElement>) => <p className='text-sm text-muted-foreground' {...props} />;
|
||||||
|
export const DialogClose = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' aria-label='Close' {...props} />;
|
||||||
|
export default Dialog;
|
||||||
6
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const DropdownMenu = ({ children }: { children?: React.ReactNode }) => <div className='relative'>{children}</div>;
|
||||||
|
export const DropdownMenuTrigger = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' {...props} />;
|
||||||
|
export const DropdownMenuContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='absolute right-0 z-20 mt-2 min-w-40 rounded-md border bg-popover p-1 shadow-md' {...props} />;
|
||||||
|
export const DropdownMenuItem = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' className='flex w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent' {...props} />;
|
||||||
4
frontend/src/components/ui/empty.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Empty = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex min-h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-6 text-center', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export default Empty;
|
||||||
4
frontend/src/components/ui/field-group.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const FieldGroup = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex flex-col gap-4', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export default FieldGroup;
|
||||||
7
frontend/src/components/ui/field.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Field = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex flex-col gap-2', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export const FieldLabel = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export const FieldDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={['text-sm text-muted-foreground', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export const FieldError = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p role='alert' className={['text-sm text-destructive', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export default Field;
|
||||||
5
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
export default Input;
|
||||||
4
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Label = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium leading-none', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export default Label;
|
||||||
7
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, ...props }, ref) => <select ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm', className].filter(Boolean).join(' ')} {...props} />);
|
||||||
|
Select.displayName = 'Select';
|
||||||
|
export const SelectItem = (props: React.OptionHTMLAttributes<HTMLOptionElement>) => <option {...props} />;
|
||||||
|
export const SelectGroup = (props: React.OptgroupHTMLAttributes<HTMLOptGroupElement>) => <optgroup {...props} />;
|
||||||
|
export default Select;
|
||||||
4
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Separator = ({ orientation = 'horizontal', className, ...props }: React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) => <div role='separator' aria-orientation={orientation} className={[orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full', 'shrink-0 bg-border', className].filter(Boolean).join(' ')} {...props} />;
|
||||||
|
export default Separator;
|
||||||
3
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export { Dialog as Sheet, DialogContent as SheetContent, DialogTitle as SheetTitle, DialogDescription as SheetDescription, DialogClose as SheetClose } from './dialog';
|
||||||
5
frontend/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Skeleton = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||||
|
export default Skeleton;
|
||||||
5
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Switch = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' role='switch' className='size-5 rounded-full border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
|
||||||
|
Switch.displayName = 'Switch';
|
||||||
|
export default Switch;
|
||||||
10
frontend/src/components/ui/table.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Table = ({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <div className='w-full overflow-auto'><table className={cx('w-full caption-bottom text-sm', className)} {...props} /></div>;
|
||||||
|
export const TableHeader = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead {...props} />;
|
||||||
|
export const TableBody = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />;
|
||||||
|
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr className={cx('border-b', className)} {...props} />;
|
||||||
|
export const TableHead = (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th className='h-10 px-2 text-left align-middle font-medium' {...props} />;
|
||||||
|
export const TableCell = (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td className='p-2 align-middle' {...props} />;
|
||||||
|
export default Table;
|
||||||
8
frontend/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||||
|
export const Tabs = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-3', className)} {...props} />;
|
||||||
|
export const TabsList = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tablist' className={cx('inline-flex h-10 items-center gap-1 rounded-md bg-muted p-1', className)} {...props} />;
|
||||||
|
export const TabsTrigger = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button role='tab' className={cx('rounded px-3 py-1.5 text-sm', className)} {...props} />;
|
||||||
|
export const TabsContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tabpanel' className={cx('mt-2', className)} {...props} />;
|
||||||
|
export default Tabs;
|
||||||
5
frontend/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(({ className, ...props }, ref) => <textarea ref={ref} className={['flex min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
export default Textarea;
|
||||||
5
frontend/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Tooltip = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
|
||||||
|
export const TooltipTrigger = (props: React.HTMLAttributes<HTMLElement>) => <span {...props} />;
|
||||||
|
export const TooltipContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div role='tooltip' className='rounded bg-foreground px-2 py-1 text-xs text-background' {...props} />;
|
||||||
154
frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { authLink } from '../lib/mAuth';
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
username: string;
|
||||||
|
fullName?: string;
|
||||||
|
full_name?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
citizen_id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: UserData | null;
|
||||||
|
token: string | null;
|
||||||
|
login: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<UserData | null>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (userData: UserData) => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const updateUser = (newUserData: UserData) => {
|
||||||
|
setUser(newUserData);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(newUserData));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the backend check-in endpoint to sync the mAuth user
|
||||||
|
* with the local database and get the full user profile.
|
||||||
|
*/
|
||||||
|
const handleCheckIn = async (authToken: string) => {
|
||||||
|
try {
|
||||||
|
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||||
|
const res = await api.post('/user/check-in', {
|
||||||
|
mauth_token: authToken,
|
||||||
|
});
|
||||||
|
if (res.data?.success && res.data.user) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(res.data.user));
|
||||||
|
return res.data.user;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Check-in failed:", err);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public compatibility method for generated onboarding and role guards.
|
||||||
|
// It always reuses the scaffolded check-in path instead of inventing a
|
||||||
|
// second token-validation flow in individual pages.
|
||||||
|
const checkAuth = async (): Promise<UserData | null> => {
|
||||||
|
const authToken = token || localStorage.getItem('user_auth_token') || localStorage.getItem('auth_token');
|
||||||
|
if (!authToken) return null;
|
||||||
|
setToken(authToken);
|
||||||
|
return handleCheckIn(authToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mAuth Native Initializer:
|
||||||
|
* 1. Check for user_auth_token in localStorage (set by /auth route).
|
||||||
|
* 2. If present, call backend check-in to validate & sync user data.
|
||||||
|
* The backend handles mAuth token verification — we must NOT call
|
||||||
|
* fetchUserData here too, as that would consume/invalidate the token
|
||||||
|
* before the backend can use it.
|
||||||
|
* 3. If absent or invalid, clear auth state so the login button is shown.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const initializer = async () => {
|
||||||
|
const origin = window.location.origin.toString();
|
||||||
|
if (!localStorage.getItem('mworld_origin')) {
|
||||||
|
localStorage.setItem('mworld_origin', origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uat = localStorage.getItem('user_auth_token');
|
||||||
|
if (uat) {
|
||||||
|
setToken(uat);
|
||||||
|
|
||||||
|
// Restore cached user data immediately for fast UI
|
||||||
|
const savedUser = localStorage.getItem('auth_user');
|
||||||
|
if (savedUser && savedUser !== 'undefined') {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token & sync user via backend check-in
|
||||||
|
// (backend calls mAuth internally to verify the token)
|
||||||
|
const freshUser = await handleCheckIn(uat);
|
||||||
|
if (!freshUser) {
|
||||||
|
// Token is invalid or check-in failed — clean up
|
||||||
|
localStorage.removeItem('user_auth_token');
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no token present, user stays logged out (login button shown)
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initializer();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the mAuth native login flow:
|
||||||
|
* Generates an auth link and redirects the user to mAuth in the same window.
|
||||||
|
*/
|
||||||
|
const login = async (): Promise<void> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||||
|
const link = await authLink(currentOrigin);
|
||||||
|
if (!link) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Native flow: redirect in same window (not window.open)
|
||||||
|
window.location.href = link;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
localStorage.removeItem('user_auth_token');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, login, checkAuth, logout, updateUser, isAuthenticated: !!user, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return context;
|
||||||
|
};
|
||||||
46
frontend/src/hooks/useNotifications.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
recipient_email: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'mention' | 'system';
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
link?: string;
|
||||||
|
actor_email?: string;
|
||||||
|
is_read: number | boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Poll unread count every 15 seconds
|
||||||
|
const countQuery = useQuery({
|
||||||
|
queryKey: ['notification-count'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/notifications/count');
|
||||||
|
return typeof res.data?.count === 'number' ? res.data.count : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchInterval: 15000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateNotifications = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
unreadCount: countQuery.data ?? 0,
|
||||||
|
isLoadingCount: countQuery.isLoading,
|
||||||
|
refetchCount: countQuery.refetch,
|
||||||
|
invalidateNotifications,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useNotifications;
|
||||||
91
frontend/src/index.css
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 210 40% 98%;
|
||||||
|
--foreground: 0 0% 0%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 0%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 0%;
|
||||||
|
--primary: 222 47% 11%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 0 0% 0%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215 16% 35%;
|
||||||
|
--accent: 199 89% 39%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--success: 142 72% 29%;
|
||||||
|
--success-foreground: 0 0% 100%;
|
||||||
|
--warning: 32 95% 37%;
|
||||||
|
--warning-foreground: 0 0% 100%;
|
||||||
|
--info: 199 96% 32%;
|
||||||
|
--info-foreground: 0 0% 100%;
|
||||||
|
--border: 214 32% 81%;
|
||||||
|
--input: 214 32% 81%;
|
||||||
|
--ring: 222 47% 11%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-pulse-emerald {
|
||||||
|
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0.4);
|
||||||
|
animation: pulse-emerald 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-emerald {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0.5);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(21, 128, 61, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-pulse-blue {
|
||||||
|
box-shadow: 0 0 0 0 rgba(2, 132, 199, 0.4);
|
||||||
|
animation: pulse-blue 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-blue {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(2, 132, 199, 0.5);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(2, 132, 199, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(2, 132, 199, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/src/lib/mAuth.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const mAuthAPIURL = "https://api.mauth.mworld.cloud";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new mAuth authentication link.
|
||||||
|
* Requires the current origin URL.
|
||||||
|
*/
|
||||||
|
export const authLink = async (currentOrigin: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const r = await axios.get(`${mAuthAPIURL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}&type=native`);
|
||||||
|
console.log(r.data);
|
||||||
|
const link = r.data?.mAuth?.auth_link;
|
||||||
|
return link || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the data of the user who authenticated.
|
||||||
|
* Requires origin URL and the user auth token.
|
||||||
|
*
|
||||||
|
* WARNING: This CONSUMES the mAuth token. The backend `/user/check-in`
|
||||||
|
* endpoint already verifies the token server-side, so calling this during
|
||||||
|
* app bootstrap would invalidate the token before the backend can use it.
|
||||||
|
* Do NOT call this from AuthContext — it exists only for one-off flows that
|
||||||
|
* need raw mAuth profile fields without a backend round-trip.
|
||||||
|
*/
|
||||||
|
export const fetchUserData = async (origin: string, token: string) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
origin: origin,
|
||||||
|
required_user_data: ["fullName", "email", "phone"],
|
||||||
|
};
|
||||||
|
const r = await axios.get(
|
||||||
|
`${mAuthAPIURL}/mAuthUser/${token}?payload=${JSON.stringify(payload)}`
|
||||||
|
);
|
||||||
|
console.log(r.data);
|
||||||
|
return r.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
36
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(price: any): string {
|
||||||
|
const num = Number(price);
|
||||||
|
if (isNaN(num)) return '';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMileage(mileage: any): string {
|
||||||
|
const num = Number(mileage);
|
||||||
|
if (isNaN(num)) return '';
|
||||||
|
return new Intl.NumberFormat('en-US').format(num) + ' miles';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: any): string {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/main.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React, { Suspense } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</Suspense>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
229
frontend/src/pages/Admin.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Activity, FileText, LayoutDashboard, Radio, RefreshCw, Shield, Users } from 'lucide-react';
|
||||||
|
import { adminApi } from '../api';
|
||||||
|
import AdminStatsCards from '../components/features/AdminStatsCards';
|
||||||
|
import AdminUserTable from '../components/features/AdminUserTable';
|
||||||
|
import AdminRideLogsTable from '../components/features/AdminRideLogsTable';
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [lastRefreshed, setLastRefreshed] = useState<string>(new Date().toLocaleTimeString());
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
|
const [, setTokenVerified] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Determine active tab from URL path
|
||||||
|
const getActiveTabFromPath = (path: string) => {
|
||||||
|
if (path.includes('/admin/users')) return 'users';
|
||||||
|
if (path.includes('/admin/rides')) return 'rides';
|
||||||
|
return 'overview';
|
||||||
|
};
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'rides'>(
|
||||||
|
getActiveTabFromPath(location.pathname)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab(getActiveTabFromPath(location.pathname));
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// Initial verification check using correct full admin endpoint path
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.get('/api/admin/verify-token')
|
||||||
|
.then((res) => {
|
||||||
|
if (res.data?.success) {
|
||||||
|
setTokenVerified(true);
|
||||||
|
} else {
|
||||||
|
setTokenVerified(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setTokenVerified(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTabChange = (tab: 'overview' | 'users' | 'rides') => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (tab === 'overview') navigate('/admin');
|
||||||
|
else if (tab === 'users') navigate('/admin/users');
|
||||||
|
else if (tab === 'rides') navigate('/admin/rides');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshAll = () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setLastRefreshed(new Date().toLocaleTimeString());
|
||||||
|
setTimeout(() => setIsRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 font-sans pb-16">
|
||||||
|
|
||||||
|
<header className="border-b border-slate-800 bg-slate-900/90 backdrop-blur sticky top-0 z-30 px-4 lg:px-8 py-4">
|
||||||
|
<div className="max-w-7xl mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-sky-500/10 border border-sky-500/30 flex items-center justify-center text-sky-400 font-bold">
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-white font-display">RideFlow Operator Panel</h1>
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
Live Telemetry
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Real-time dispatch oversight & platform audit logs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-slate-400 block font-mono">Last Synced</span>
|
||||||
|
<span className="text-xs text-slate-200 font-mono">{lastRefreshed}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-xs font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin text-sky-400' : ''}`} />
|
||||||
|
Refresh Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Admin Workspace */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 lg:px-8 pt-6 space-y-6">
|
||||||
|
|
||||||
|
<div className="flex border-b border-slate-800 gap-2 overflow-x-auto pb-px">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('overview')}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === 'overview'
|
||||||
|
? 'border-sky-500 text-sky-400 bg-sky-500/5'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
|
Overview Telemetry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('users')}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === 'users'
|
||||||
|
? 'border-sky-500 text-sky-400 bg-sky-500/5'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
User Directory
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('rides')}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === 'rides'
|
||||||
|
? 'border-sky-500 text-sky-400 bg-sky-500/5'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
System Ride Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content Panes */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-6 animate-fadeIn">
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 overflow-hidden relative group">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 items-center">
|
||||||
|
<div className="p-6 md:col-span-2 space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||||
|
<Radio className="w-3.5 h-3.5 animate-pulse" /> Platform Operations
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white font-display">System Status & Real-time Metrics</h2>
|
||||||
|
<p className="text-sm text-slate-400 max-w-xl">
|
||||||
|
Monitor active passenger requests, driver availability status, total system volume, and completed rides across the urban transit network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex justify-end md:border-l border-slate-800/80">
|
||||||
|
<img
|
||||||
|
src="/assets/admin-01-admin-kpi-summary.png"
|
||||||
|
alt="Display top-level system stats including total users, online drivers, system for RideFlow"
|
||||||
|
className="w-full max-h-36 object-cover rounded-lg border border-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<AdminStatsCards />
|
||||||
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-sky-400" /> Recent User Registrations
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('users')}
|
||||||
|
className="text-xs text-sky-400 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
View All Users →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<AdminUserTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4 text-amber-400" /> Active & Recent Rides
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('rides')}
|
||||||
|
className="text-xs text-sky-400 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
View All Ride Logs →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<AdminRideLogsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<div className="space-y-4 animate-fadeIn bg-slate-900 border border-slate-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white font-display">User Management Directory</h2>
|
||||||
|
<p className="text-xs text-slate-400">All registered passengers, active drivers, and system administrators</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AdminUserTable />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'rides' && (
|
||||||
|
<div className="space-y-4 animate-fadeIn bg-slate-900 border border-slate-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white font-display">System Ride Logs & Audit Trail</h2>
|
||||||
|
<p className="text-xs text-slate-400">Comprehensive log of requested, accepted, picked up, completed, and cancelled rides</p> //
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AdminRideLogsTable />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
frontend/src/pages/Auth.tsx
Normal file
33
frontend/src/pages/Circles.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import CircleActivityPlanner from '../components/features/CircleActivityPlanner';
|
||||||
|
import { ShieldCheck, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function CirclesPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] bg-slate-50 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 bg-sky-100 text-sky-700 rounded-lg">
|
||||||
|
<Users className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Companion Circles & Activity Hub</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1 max-w-2xl leading-relaxed">
|
||||||
|
Coordinate trusted companion circles, manage activity invitations, and plan recurring safety meetups with seamless state tracking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs bg-slate-100 text-slate-700 px-3 py-2 rounded-xl border border-slate-200 self-stretch md:self-auto justify-center">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||||
|
<span>Verified Community Network</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Planner Workspace */}
|
||||||
|
<CircleActivityPlanner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
frontend/src/pages/DriverWorkstation.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import MapView, { MapMarker } from '../components/MapView';
|
||||||
|
import DriverAvailabilityToggle from '../components/features/DriverAvailabilityToggle';
|
||||||
|
import DriverRequestCard from '../components/features/DriverRequestCard';
|
||||||
|
import DriverTripNavigation from '../components/features/DriverTripNavigation';
|
||||||
|
import { Ride } from '../types/ride';
|
||||||
|
import { Car, Navigation, Radio, RefreshCw, ShieldAlert } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function DriverWorkstation() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch driver profile to check setup & availability
|
||||||
|
const { data: profileRes, isLoading: isProfileLoading, refetch: refetchProfile } = useQuery({
|
||||||
|
queryKey: ['driver-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/driver/profile');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const driverProfile = profileRes?.profile;
|
||||||
|
const isAvailable = Boolean(driverProfile?.is_available);
|
||||||
|
|
||||||
|
// Fetch driver's current rides to locate an active trip
|
||||||
|
const { data: myRidesRes, refetch: refetchMyRides } = useQuery({
|
||||||
|
queryKey: ['rides-my'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/rides/my');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const myRides: Ride[] = Array.isArray(myRidesRes?.rides) ? myRidesRes.rides : [];
|
||||||
|
const activeRide = myRides.find(
|
||||||
|
(r) => r.status === 'accepted' || r.status === 'picked_up'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch nearby ride requests when available & not in an active trip
|
||||||
|
const { refetch: refetchNearby } = useQuery({
|
||||||
|
queryKey: ['rides-nearby'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/rides/nearby');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: isAvailable && !activeRide,
|
||||||
|
refetchInterval: isAvailable && !activeRide ? 4000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetchProfile();
|
||||||
|
refetchMyRides();
|
||||||
|
if (isAvailable && !activeRide) refetchNearby();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcceptRide = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides-my'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides-nearby'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRideUpdated = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides-my'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides-nearby'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine map markers strictly matching MapMarker contract
|
||||||
|
const mapMarkers: MapMarker[] = [];
|
||||||
|
if (activeRide) {
|
||||||
|
if (typeof activeRide.pickup_lat === 'number' && typeof activeRide.pickup_lng === 'number') {
|
||||||
|
mapMarkers.push({
|
||||||
|
id: 'pickup',
|
||||||
|
lat: activeRide.pickup_lat,
|
||||||
|
lng: activeRide.pickup_lng,
|
||||||
|
label: `Pickup: ${activeRide.pickup_location}`,
|
||||||
|
type: 'pickup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof activeRide.destination_lat === 'number' && typeof activeRide.destination_lng === 'number') {
|
||||||
|
mapMarkers.push({
|
||||||
|
id: 'destination',
|
||||||
|
lat: activeRide.destination_lat,
|
||||||
|
lng: activeRide.destination_lng,
|
||||||
|
label: `Destination: ${activeRide.destination_location}`,
|
||||||
|
type: 'destination',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof activeRide.driver_lat === 'number' && typeof activeRide.driver_lng === 'number') {
|
||||||
|
mapMarkers.push({
|
||||||
|
id: 'driver',
|
||||||
|
lat: activeRide.driver_lat,
|
||||||
|
lng: activeRide.driver_lng,
|
||||||
|
label: 'Your Vehicle Position',
|
||||||
|
type: 'driver',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate polyline for route
|
||||||
|
const polyline: [number, number][] | undefined =
|
||||||
|
activeRide &&
|
||||||
|
typeof activeRide.pickup_lat === 'number' &&
|
||||||
|
typeof activeRide.pickup_lng === 'number' &&
|
||||||
|
typeof activeRide.destination_lat === 'number' &&
|
||||||
|
typeof activeRide.destination_lng === 'number'
|
||||||
|
? [
|
||||||
|
[activeRide.pickup_lat, activeRide.pickup_lng],
|
||||||
|
[activeRide.destination_lat, activeRide.destination_lng],
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Determine map center
|
||||||
|
const mapCenter: [number, number] =
|
||||||
|
activeRide && typeof activeRide.driver_lat === 'number' && typeof activeRide.driver_lng === 'number'
|
||||||
|
? [activeRide.driver_lat, activeRide.driver_lng]
|
||||||
|
: activeRide && typeof activeRide.pickup_lat === 'number' && typeof activeRide.pickup_lng === 'number'
|
||||||
|
? [activeRide.pickup_lat, activeRide.pickup_lng]
|
||||||
|
: [37.7749, -122.4194];
|
||||||
|
|
||||||
|
if (!driverProfile && !isProfileLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl py-12 px-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-8 text-center space-y-4 shadow-xs">
|
||||||
|
<div className="inline-flex p-3 rounded-full bg-amber-500/10 text-amber-600">
|
||||||
|
<Car className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold">Driver Profile Required</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto text-sm">
|
||||||
|
To receive ride dispatches and go online as a driver, please register your vehicle details first.
|
||||||
|
</p>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Link
|
||||||
|
to="/app/onboarding"
|
||||||
|
className="inline-flex items-center gap-2 bg-primary text-primary-foreground text-sm font-semibold px-6 py-2.5 rounded-lg hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Complete Driver Setup
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-7xl py-6 px-4 space-y-6">
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-card border border-border p-4 rounded-xl shadow-xs">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-slate-900 text-white dark:bg-slate-800">
|
||||||
|
<Navigation className="w-6 h-6 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">Driver Workstation</h1>
|
||||||
|
{driverProfile && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 font-mono dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
{driverProfile.vehicle_make} {driverProfile.vehicle_model} • {driverProfile.license_plate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{activeRide
|
||||||
|
? 'Active navigation in progress'
|
||||||
|
: isAvailable
|
||||||
|
? 'Online — Scanning for nearby dispatch requests'
|
||||||
|
: 'Offline — Switch online to receive requests'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 self-end md:self-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="p-2 rounded-lg border border-border hover:bg-muted text-muted-foreground transition"
|
||||||
|
title="Refresh dispatch feeds"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DriverAvailabilityToggle
|
||||||
|
onStatusChange={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['driver-profile'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rides-nearby'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Workstation View Split */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||||
|
|
||||||
|
<div className="lg:col-span-5 space-y-6">
|
||||||
|
{activeRide ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-sky-600 bg-sky-50 dark:bg-sky-950/40 dark:text-sky-400 px-3 py-1.5 rounded-lg border border-sky-200 dark:border-sky-800">
|
||||||
|
<Radio className="w-4 h-4 animate-pulse text-sky-500" />
|
||||||
|
<span>Active Trip Engagement</span>
|
||||||
|
</div>
|
||||||
|
<DriverTripNavigation ride={activeRide} onRideUpdated={handleRideUpdated} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-4 shadow-xs">
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Radio className={`w-4 h-4 ${isAvailable ? 'text-emerald-500 animate-pulse' : 'text-slate-400'}`} />
|
||||||
|
Nearby Dispatch Feed
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{isAvailable ? 'Live' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAvailable ? (
|
||||||
|
<div className="py-8 text-center space-y-3">
|
||||||
|
<div className="inline-flex p-3 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
|
||||||
|
<ShieldAlert className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold">You are currently Offline</h3>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-xs mx-auto">
|
||||||
|
Toggle your availability to "Online" in the header bar above to receive nearby passenger ride dispatches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DriverRequestCard onAcceptRide={handleAcceptRide} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="lg:col-span-7 bg-card border border-border rounded-xl p-3 shadow-xs space-y-3">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<Navigation className="w-3.5 h-3.5 text-sky-600" />
|
||||||
|
Tactical Navigation Stage
|
||||||
|
</span>
|
||||||
|
{activeRide && (
|
||||||
|
<span className="text-xs bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300 px-2 py-0.5 rounded font-mono font-medium">
|
||||||
|
Live Route Tracking
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapView
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={13}
|
||||||
|
markers={mapMarkers}
|
||||||
|
polyline={polyline}
|
||||||
|
className="h-[520px] w-full rounded-lg border border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
396
frontend/src/pages/History.tsx
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { StarRating } from '../components/StarRating';
|
||||||
|
import { Ride, CompanionCircle, CompanionActivity } from '../types/ride';
|
||||||
|
import { Calendar, Car, CheckCircle2, ChevronDown, ChevronUp, Clock, Filter, MapPin, Navigation, Users, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
type TabType = 'all' | 'rides' | 'activities';
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch rides
|
||||||
|
const { data: ridesResponse, isLoading: isRidesLoading } = useQuery({
|
||||||
|
queryKey: ['my-rides-history'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/rides/my');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch companion circles to retrieve activities
|
||||||
|
const { data: circlesResponse, isLoading: isCirclesLoading } = useQuery({
|
||||||
|
queryKey: ['my-circles-history'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/companion/circles/my');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawRides: Ride[] = Array.isArray(ridesResponse?.rides) ? ridesResponse.rides : [];
|
||||||
|
const circles: CompanionCircle[] = Array.isArray(circlesResponse?.circles) ? circlesResponse.circles : [];
|
||||||
|
|
||||||
|
// Process activities from circles
|
||||||
|
const activities: (CompanionActivity & { circle_name?: string })[] = [];
|
||||||
|
circles.forEach((circle) => {
|
||||||
|
if (Array.isArray((circle as any).activities)) {
|
||||||
|
(circle as any).activities.forEach((act: CompanionActivity) => {
|
||||||
|
activities.push({
|
||||||
|
...act,
|
||||||
|
circle_name: circle.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter rides
|
||||||
|
const filteredRides = rawRides.filter((ride) => {
|
||||||
|
if (statusFilter !== 'all' && ride.status !== statusFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter activities
|
||||||
|
const filteredActivities = activities.filter((act) => {
|
||||||
|
if (statusFilter !== 'all' && act.status !== statusFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = isRidesLoading || isCirclesLoading;
|
||||||
|
|
||||||
|
const toggleExpand = (id: string) => {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" /> Completed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'picked_up':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 border border-sky-300">
|
||||||
|
<Navigation className="w-3.5 h-3.5 text-sky-600" /> In Transit
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'accepted':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-300">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-600" /> Accepted
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'requested':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-800 border border-slate-300">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-slate-600" /> Requested
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'cancelled':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-100 text-rose-800 border border-rose-300">
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-rose-600" /> Cancelled
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-800 border border-slate-300">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] bg-slate-50 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 font-display">
|
||||||
|
Activity and Trip History
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
|
Review past ride bookings, status progressions, driver assignments, and companion circle activities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between border-b border-slate-200 pb-4">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-200/80 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Activity
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('rides')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
|
activeTab === 'rides'
|
||||||
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Car className="w-3.5 h-3.5" /> Rides ({filteredRides.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('activities')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
|
activeTab === 'activities'
|
||||||
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" /> Circle Events ({filteredActivities.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<Filter className="w-4 h-4 text-slate-500" />
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="text-xs bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-slate-700 focus:ring-1 focus:ring-sky-500 font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="picked_up">In Transit</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="requested">Requested</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-24 bg-white border border-slate-200 rounded-xl animate-pulse p-4 flex flex-col justify-between">
|
||||||
|
<div className="h-4 bg-slate-200 rounded w-1/3"></div>
|
||||||
|
<div className="h-3 bg-slate-100 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Rides List */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'rides') && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activeTab === 'all' && filteredRides.length > 0 && (
|
||||||
|
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-1">
|
||||||
|
Rides ({filteredRides.length})
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{filteredRides.length === 0 && activeTab === 'rides' && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||||
|
<Car className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-slate-800">No ride history found</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
When you request or complete trips, they will be listed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredRides.map((ride) => {
|
||||||
|
const isExpanded = expandedId === ride.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ride.id}
|
||||||
|
className="bg-white border border-slate-200 rounded-xl overflow-hidden hover:border-slate-300 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleExpand(ride.id)}
|
||||||
|
className="p-4 flex items-start justify-between gap-4 cursor-pointer hover:bg-slate-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||||
|
<div className="p-2.5 rounded-lg bg-slate-100 text-slate-700 shrink-0 mt-0.5">
|
||||||
|
<Car className="w-5 h-5 text-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-medium text-slate-500">
|
||||||
|
{formatDate(ride.created_at)}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(ride.status)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-900 truncate flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||||
|
<span className="truncate">{ride.pickup_location}</span>
|
||||||
|
<span className="text-slate-400 font-normal px-0.5">to</span>
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-600 shrink-0" />
|
||||||
|
<span className="truncate">{ride.destination_location}</span>
|
||||||
|
</div>
|
||||||
|
{ride.rating && ride.rating > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 pt-0.5">
|
||||||
|
<StarRating value={ride.rating} readonly size={14} />
|
||||||
|
<span className="text-xs text-slate-500 font-medium">
|
||||||
|
({ride.rating}/5)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-slate-400 hover:text-slate-600 p-1 shrink-0"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 pt-2 border-t border-slate-100 bg-slate-50/50 text-xs text-slate-600 space-y-2.5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-slate-200 space-y-1">
|
||||||
|
<p className="font-medium text-slate-500 text-[11px] uppercase tracking-wider">
|
||||||
|
Passenger Detail
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-900 font-semibold">
|
||||||
|
{ride.passenger_name || ride.passenger_email || 'Passenger'}
|
||||||
|
</p>
|
||||||
|
{ride.passenger_phone && (
|
||||||
|
<p className="text-slate-600">Phone: {ride.passenger_phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-slate-200 space-y-1">
|
||||||
|
<p className="font-medium text-slate-500 text-[11px] uppercase tracking-wider">
|
||||||
|
Driver Detail
|
||||||
|
</p>
|
||||||
|
{ride.driver_email ? (
|
||||||
|
<>
|
||||||
|
<p className="text-slate-900 font-semibold">
|
||||||
|
{ride.driver_name || ride.driver_email}
|
||||||
|
</p>
|
||||||
|
{ride.driver_phone && (
|
||||||
|
<p className="text-slate-600">Phone: {ride.driver_phone}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500 italic">Driver not assigned yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-slate-500 text-[11px] pt-1">
|
||||||
|
<span>Ride ID: {ride.id}</span>
|
||||||
|
<span>Updated: {formatDate(ride.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Circle Activities List */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'activities') && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
{activeTab === 'all' && filteredActivities.length > 0 && (
|
||||||
|
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-1">
|
||||||
|
Circle Events ({filteredActivities.length})
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{filteredActivities.length === 0 && activeTab === 'activities' && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||||
|
<Users className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-slate-800">No circle events recorded</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Join or create a companion circle to plan group transit activities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredActivities.map((act) => (
|
||||||
|
<div
|
||||||
|
key={act.id}
|
||||||
|
className="bg-white border border-slate-200 rounded-xl p-4 space-y-2.5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="p-2 rounded-lg bg-sky-50 text-sky-700 shrink-0">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{act.title}</h3>
|
||||||
|
{act.circle_name && (
|
||||||
|
<p className="text-xs font-medium text-sky-700">{act.circle_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(act.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{act.description && (
|
||||||
|
<p className="text-xs text-slate-600 line-clamp-2 pl-10">
|
||||||
|
{act.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-500 pl-10 flex-wrap pt-1 border-t border-slate-100">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400" /> {act.location_label}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-slate-400" /> {formatDate(act.starts_at)}
|
||||||
|
</span>
|
||||||
|
{act.max_attendees && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3.5 h-3.5 text-slate-400" /> Max {act.max_attendees}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'all' &&
|
||||||
|
filteredRides.length === 0 &&
|
||||||
|
filteredActivities.length === 0 && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-10 text-center">
|
||||||
|
<Car className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">No activity found</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-1 max-w-sm mx-auto">
|
||||||
|
There are no rides or companion circle activities matching your filter criteria.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
frontend/src/pages/Landing.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Activity, ArrowRight, Car, Check, CheckCircle2, Clock, MapPin, Navigation, ShieldCheck, Smartphone, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
const [pickupPreview, setPickupPreview] = useState('100 Main St, San Francisco');
|
||||||
|
const [dropoffPreview, setDropoffPreview] = useState('500 Oak Ave, San Francisco');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f8fafc] text-[#000000] font-sans flex flex-col">
|
||||||
|
|
||||||
|
<header className="sticky top-0 z-50 h-16 bg-white/90 backdrop-blur-md border-b border-[#cbd5e1] px-4 md:px-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[#0f172a] text-white flex items-center justify-center font-bold text-xl shadow-sm">
|
||||||
|
<Navigation className="w-5 h-5 text-[#0284c7]" />
|
||||||
|
</div>
|
||||||
|
<span className="font-extrabold text-xl tracking-tight text-[#0f172a]">RideFlow</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-sm font-semibold text-[#0f172a]">
|
||||||
|
<a href="#hero-interactive-preview" className="hover:text-[#0284c7] transition-colors">
|
||||||
|
Book Ride
|
||||||
|
</a>
|
||||||
|
<a href="#dual-role-workstation-overview" className="hover:text-[#0284c7] transition-colors">
|
||||||
|
Driver Workstation
|
||||||
|
</a>
|
||||||
|
<a href="#live-telemetry-capabilities" className="hover:text-[#0284c7] transition-colors">
|
||||||
|
Telemetry
|
||||||
|
</a>
|
||||||
|
<a href="#safety-companion-framework" className="hover:text-[#0284c7] transition-colors">
|
||||||
|
Safety Circles
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="px-5 py-2.5 rounded-md bg-[#0f172a] hover:bg-[#1e293b] text-white font-semibold text-sm transition-all shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>Sign In</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-[#0284c7]" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<main className="flex-1">
|
||||||
|
|
||||||
|
<section id="hero-interactive-preview" className="py-12 md:py-20 px-4 md:px-8 max-w-7xl mx-auto">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||||
|
|
||||||
|
<div className="lg:col-span-7 space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#e2e8f0] text-[#0369a1] text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-[#0284c7] text-[#0284c7]" />
|
||||||
|
Urban Signal Precision Engine
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-[#0f172a] leading-[1.1] tracking-tight">
|
||||||
|
Real-Time Urban Mobility, Zero Friction
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-[#64748b] max-w-2xl font-normal leading-relaxed">
|
||||||
|
Book rides instantly, track driver telemetry live on the map, or go online as a driver with a single tap. Designed for instant dispatch and crystal-clear trip status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white p-4 rounded-xl border border-[#cbd5e1] shadow-sm space-y-3 max-w-xl">
|
||||||
|
<div className="flex items-center gap-3 bg-[#f1f5f9] px-3.5 py-2.5 rounded-lg border border-[#e2e8f0]">
|
||||||
|
<MapPin className="w-5 h-5 text-[#15803d] shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pickupPreview}
|
||||||
|
onChange={(e) => setPickupPreview(e.target.value)}
|
||||||
|
placeholder="Enter pickup point..."
|
||||||
|
className="bg-transparent border-none outline-none w-full text-sm font-medium text-[#0f172a] placeholder-[#64748b]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 bg-[#f1f5f9] px-3.5 py-2.5 rounded-lg border border-[#e2e8f0]">
|
||||||
|
<Navigation className="w-5 h-5 text-[#0284c7] shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dropoffPreview}
|
||||||
|
onChange={(e) => setDropoffPreview(e.target.value)}
|
||||||
|
placeholder="Enter destination..."
|
||||||
|
className="bg-transparent border-none outline-none w-full text-sm font-medium text-[#0f172a] placeholder-[#64748b]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="px-8 py-3.5 rounded-lg bg-[#0f172a] text-white font-bold text-base hover:bg-[#1e293b] transition-all shadow-md flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span>Request Ride Now</span>
|
||||||
|
<ArrowRight className="w-5 h-5 text-[#0284c7]" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="px-6 py-3.5 rounded-lg bg-white border border-[#cbd5e1] text-[#0f172a] font-bold text-base hover:bg-[#f1f5f9] transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Car className="w-5 h-5 text-[#0f172a]" />
|
||||||
|
<span>Drive with RideFlow</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="lg:col-span-5 relative">
|
||||||
|
<div className="relative rounded-2xl overflow-hidden border border-[#cbd5e1] shadow-lg bg-white p-2">
|
||||||
|
<img
|
||||||
|
src="/assets/landing-01-hero-interactive-preview.png"
|
||||||
|
alt="Interactive Vector Route Canvas for RideFlow"
|
||||||
|
className="w-full h-auto object-cover rounded-xl"
|
||||||
|
style={{ objectPosition: 'center' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 bg-[#0f172a]/90 backdrop-blur-md text-white p-3 rounded-lg border border-slate-700 flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-[#15803d] animate-pulse"></span>
|
||||||
|
<span className="font-semibold text-slate-200">Live Driver Telemetry Active</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[#0284c7]">4 Min Pickup</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="dual-role-workstation-overview" className="py-16 bg-[#f1f5f9] border-y border-[#e2e8f0] px-4 md:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-12">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-[#0369a1]">
|
||||||
|
DUAL WORKSTATION SYSTEM
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold text-[#0f172a] tracking-tight">
|
||||||
|
Engineered for Both Sides of the Journey
|
||||||
|
</h2>
|
||||||
|
<p className="text-base text-[#64748b] max-w-2xl mx-auto">
|
||||||
|
Seamless passenger booking paired with a tactical high-density workstation for professional drivers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-stretch">
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl p-6 md:p-8 border border-[#cbd5e1] shadow-sm flex flex-col justify-between space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0284c7] font-bold">
|
||||||
|
<Smartphone className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-[#0f172a]">Passenger Experience</h3>
|
||||||
|
<p className="text-sm text-[#64748b] leading-relaxed">
|
||||||
|
Select pickup and dropoff points with automatic fare calculations, monitor your driver's real-time vehicle movement on interactive maps, and rate your trip seamlessly.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2.5 pt-2 text-sm text-[#0f172a]">
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||||
|
<span>Upfront route preview and estimated fare transparency</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||||
|
<span>Live driver GPS location polling with map markers</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||||
|
<span>Sequential trip status updates: Accepted to Picked Up</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="w-full py-3 rounded-lg bg-[#0f172a] hover:bg-[#1e293b] text-white font-semibold text-sm transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span>Launch Passenger Workstation</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-[#0284c7]" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl p-6 md:p-8 border border-[#cbd5e1] shadow-sm flex flex-col justify-between space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#15803d] font-bold">
|
||||||
|
<Car className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-[#0f172a]">Driver Control Station</h3>
|
||||||
|
<p className="text-sm text-[#64748b] leading-relaxed">
|
||||||
|
Toggle online availability, review incoming dispatch requests with distance indicators, navigate sequential trip stages, and transmit live GPS telemetry.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2.5 pt-2 text-sm text-[#0f172a]">
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||||
|
<span>One-tap online/offline driver availability switch</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||||
|
<span>Instant incoming request cards with pickup/destination</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||||
|
<span>Automated location transmitter and status progression</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="w-full py-3 rounded-lg bg-[#0f172a] hover:bg-[#1e293b] text-white font-semibold text-sm transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span>Launch Driver Workstation</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-[#0284c7]" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="rounded-xl overflow-hidden border border-[#cbd5e1] bg-white p-2 shadow-sm">
|
||||||
|
<img
|
||||||
|
src="/assets/landing-02-dual-role-workstation-overview.png"
|
||||||
|
alt="Passenger Booking Interface Vector Diagram for RideFlow"
|
||||||
|
className="w-full h-auto object-cover rounded-lg"
|
||||||
|
style={{ objectPosition: 'center' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SECTION 3: Live Telemetry Capabilities */}
|
||||||
|
<section id="live-telemetry-capabilities" className="py-16 px-4 md:px-8 max-w-7xl mx-auto space-y-12">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-[#0369a1]">
|
||||||
|
PLATFORM RELIABILITY
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold text-[#0f172a] tracking-tight">
|
||||||
|
Powered by High-Precision Telemetry
|
||||||
|
</h2>
|
||||||
|
<p className="text-base text-[#64748b] max-w-2xl mx-auto">
|
||||||
|
Every trip state step is tracked deterministically with high-frequency GPS pinging and strict state order.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||||
|
<Activity className="w-5 h-5 text-[#0284c7]" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-lg text-[#0f172a]">Sequential Status Engine</h4>
|
||||||
|
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||||
|
Strict status ordering: Requested → Accepted → Picked Up → Completed, preventing invalid state transitions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||||
|
<Navigation className="w-5 h-5 text-[#15803d]" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-lg text-[#0f172a]">GPS Location Polling</h4>
|
||||||
|
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||||
|
Active drivers transmit live latitude and longitude updates every few seconds directly to the tracking map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||||
|
<Clock className="w-5 h-5 text-[#b45309]" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-lg text-[#0f172a]">Cancellation Controls</h4>
|
||||||
|
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||||
|
Passengers can cancel ride requests at any time prior to pick up with instant status synchronization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||||
|
<Check className="w-5 h-5 text-[#0369a1]" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-lg text-[#0f172a]">Trip Rating System</h4>
|
||||||
|
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||||
|
Prompt rating modal appears upon completion for passenger feedback and transparent ride record keeping.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl overflow-hidden border border-[#cbd5e1] bg-[#f1f5f9] p-2">
|
||||||
|
<img
|
||||||
|
src="/assets/landing-03-live-telemetry-capabilities.png"
|
||||||
|
alt="Highlight technical platform reliability including GPS location sharing, for RideFlow"
|
||||||
|
className="w-full h-auto object-cover rounded-lg"
|
||||||
|
style={{ objectPosition: 'center' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SECTION 4: Safety & Companion Framework */}
|
||||||
|
<section id="safety-companion-framework" className="py-16 bg-[#0f172a] text-white px-4 md:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||||
|
<div className="lg:col-span-7 space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-slate-800 text-[#0284c7] text-xs font-semibold uppercase tracking-wider border border-slate-700">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-[#15803d]" />
|
||||||
|
Private Companion Safety System
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold text-white tracking-tight leading-tight">
|
||||||
|
Travel with Confidence & Trusted Safety Circles
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-base text-slate-300 leading-relaxed max-w-xl">
|
||||||
|
Configure your safety persona profile, grant safety consent, and initiate background check verification. Form companion circles for group activity planning and coordinated rides.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
||||||
|
<div className="p-4 rounded-lg bg-slate-800/80 border border-slate-700 space-y-1.5">
|
||||||
|
<span className="font-bold text-white text-sm block">Safety Persona Label</span>
|
||||||
|
<p className="text-xs text-slate-400">Custom persona preferences and safety check-ins during night trips.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-slate-800/80 border border-slate-700 space-y-1.5">
|
||||||
|
<span className="font-bold text-white text-sm block">Background Check Verification</span>
|
||||||
|
<p className="text-xs text-slate-400">Initiate optional safety background check with pending status tracking.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Link
|
||||||
|
to="/auth?returnTo=%2Fapp"
|
||||||
|
className="inline-flex items-center gap-3 px-8 py-3.5 rounded-lg bg-[#0284c7] hover:bg-[#0369a1] text-white font-bold text-sm transition-all shadow-md"
|
||||||
|
>
|
||||||
|
<span>Get Started with Safety Setup</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-white" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<div className="rounded-xl overflow-hidden border border-slate-700 p-2 bg-slate-800/50">
|
||||||
|
<img
|
||||||
|
src="/assets/landing-04-safety-companion-framework.png"
|
||||||
|
alt="Companion Safety Verification Badge Graphic for RideFlow"
|
||||||
|
className="w-full h-auto object-cover rounded-lg"
|
||||||
|
style={{ objectPosition: 'center' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white border-t border-[#cbd5e1] py-8 px-4 md:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-[#64748b]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded bg-[#0f172a] text-white flex items-center justify-center font-bold text-xs">
|
||||||
|
RF
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-[#0f172a]">RideFlow</span>
|
||||||
|
<span>© {new Date().getFullYear()} RideFlow Platform. All rights reserved.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 font-medium text-[#0f172a]">
|
||||||
|
<Link to="/auth?returnTo=%2Fapp" className="hover:text-[#0284c7]">
|
||||||
|
Passenger Portal
|
||||||
|
</Link>
|
||||||
|
<Link to="/auth?returnTo=%2Fapp" className="hover:text-[#0284c7]">
|
||||||
|
Driver Workstation
|
||||||
|
</Link>
|
||||||
|
<Link to="/auth?returnTo=%2Fapp" className="hover:text-[#0284c7]">
|
||||||
|
Safety Setup
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
frontend/src/pages/Onboarding.tsx
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { api } from '../api';
|
||||||
|
import DriverProfileForm from '../components/features/DriverProfileForm';
|
||||||
|
import SafetyOnboardingCard from '../components/features/SafetyOnboardingCard';
|
||||||
|
import AdminStatsCards from '../components/features/AdminStatsCards';
|
||||||
|
import AdminUserTable from '../components/features/AdminUserTable';
|
||||||
|
import AdminRideLogsTable from '../components/features/AdminRideLogsTable';
|
||||||
|
import { ArrowRight, Car, CheckCircle2, Compass, ShieldAlert, ShieldCheck, Sparkles, User } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [selectedRole, setSelectedRole] = useState<'passenger' | 'driver' | 'admin'>(
|
||||||
|
user?.role === 'admin' ? 'admin' : user?.role === 'driver' ? 'driver' : 'passenger'
|
||||||
|
);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<'role' | 'vehicle' | 'safety' | 'admin'>('role');
|
||||||
|
const [driverProfileComplete, setDriverProfileComplete] = useState(false);
|
||||||
|
const [safetyProfileComplete, setSafetyProfileComplete] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkProfiles = async () => {
|
||||||
|
try {
|
||||||
|
const [driverRes, safetyRes] = await Promise.all([
|
||||||
|
api.get('/driver/profile').catch(() => ({ data: { profile: null } })),
|
||||||
|
api.get('/companion/safety-profile').catch(() => ({ data: { profile: null } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (driverRes.data?.profile) {
|
||||||
|
setDriverProfileComplete(true);
|
||||||
|
}
|
||||||
|
if (safetyRes.data?.profile) {
|
||||||
|
setSafetyProfileComplete(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching profiles', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkProfiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRoleSelect = (role: 'passenger' | 'driver' | 'admin') => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
if (user && user.role !== role) {
|
||||||
|
updateUser({ ...user, role });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinueFromRole = () => {
|
||||||
|
if (selectedRole === 'driver') {
|
||||||
|
setStep('vehicle');
|
||||||
|
} else if (selectedRole === 'admin') {
|
||||||
|
setStep('admin');
|
||||||
|
} else {
|
||||||
|
setStep('safety');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDriverFormSuccess = () => {
|
||||||
|
setDriverProfileComplete(true);
|
||||||
|
setStep('safety');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSafetyCompleted = () => {
|
||||||
|
setSafetyProfileComplete(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishOnboarding = () => {
|
||||||
|
if (selectedRole === 'driver') {
|
||||||
|
navigate('/app/driver');
|
||||||
|
} else {
|
||||||
|
navigate('/app/ride');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] bg-[#f8fafc] text-[#0f172a] px-4 py-8 md:py-12">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-semibold bg-[#0284c7]/10 text-[#0284c7] border border-[#0284c7]/20">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
<span>Welcome to RideFlow Setup</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight text-[#0f172a] font-['Plus_Jakarta_Sans']">
|
||||||
|
Configure Your Ride Experience
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm md:text-base text-slate-600 max-w-xl mx-auto">
|
||||||
|
Choose your primary role, register vehicle details if driving, and customize your safety companion profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 p-1.5 bg-slate-200/70 rounded-xl max-w-2xl mx-auto text-xs font-medium text-slate-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('role')}
|
||||||
|
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
step === 'role'
|
||||||
|
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||||
|
: 'hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User size={14} />
|
||||||
|
<span>1. Account Role</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selectedRole !== 'driver'}
|
||||||
|
onClick={() => selectedRole === 'driver' && setStep('vehicle')}
|
||||||
|
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
selectedRole !== 'driver'
|
||||||
|
? 'opacity-40 cursor-not-allowed'
|
||||||
|
: step === 'vehicle'
|
||||||
|
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||||
|
: 'hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Car size={14} />
|
||||||
|
<span>2. Vehicle</span>
|
||||||
|
{driverProfileComplete && <CheckCircle2 size={12} className="text-emerald-600" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('safety')}
|
||||||
|
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
step === 'safety'
|
||||||
|
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||||
|
: 'hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
<span>3. Safety</span>
|
||||||
|
{safetyProfileComplete && <CheckCircle2 size={12} className="text-emerald-600" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('admin')}
|
||||||
|
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
step === 'admin'
|
||||||
|
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||||
|
: 'hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={14} />
|
||||||
|
<span>4. Admin View</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'role' && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||||
|
<div className="space-y-1 text-center md:text-left">
|
||||||
|
<h2 className="text-xl font-bold text-[#0f172a]">Select Your Primary Role</h2>
|
||||||
|
<p className="text-xs md:text-sm text-slate-500">
|
||||||
|
You can switch between booking rides, driving, or platform administration at any time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => handleRoleSelect('passenger')}
|
||||||
|
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all flex flex-col justify-between ${
|
||||||
|
selectedRole === 'passenger'
|
||||||
|
? 'border-[#0284c7] bg-sky-50/40 ring-1 ring-[#0284c7]'
|
||||||
|
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="p-2.5 rounded-lg bg-sky-100 text-[#0284c7]">
|
||||||
|
<User size={24} />
|
||||||
|
</div>
|
||||||
|
{selectedRole === 'passenger' && (
|
||||||
|
<CheckCircle2 size={20} className="text-[#0284c7]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-[#0f172a]">Passenger</h3>
|
||||||
|
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||||
|
Request rides on interactive maps, view upfront route estimates, track assigned drivers live, and join companion circles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-200/60 flex items-center justify-between text-xs font-semibold text-[#0284c7]">
|
||||||
|
<span>Instant Booking Access</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => handleRoleSelect('driver')}
|
||||||
|
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all flex flex-col justify-between ${
|
||||||
|
selectedRole === 'driver'
|
||||||
|
? 'border-[#0f172a] bg-slate-100/60 ring-1 ring-[#0f172a]'
|
||||||
|
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="p-2.5 rounded-lg bg-slate-900 text-white">
|
||||||
|
<Car size={24} />
|
||||||
|
</div>
|
||||||
|
{selectedRole === 'driver' && (
|
||||||
|
<CheckCircle2 size={20} className="text-[#0f172a]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-[#0f172a]">Driver</h3>
|
||||||
|
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||||
|
Go online to receive nearby ride requests, update sequential trip progress, transmit live GPS position, and earn fare.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-200/60 flex items-center justify-between text-xs font-semibold text-[#0f172a]">
|
||||||
|
<span>Requires Vehicle Info</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => handleRoleSelect('admin')}
|
||||||
|
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all flex flex-col justify-between ${
|
||||||
|
selectedRole === 'admin'
|
||||||
|
? 'border-purple-600 bg-purple-50/40 ring-1 ring-purple-600'
|
||||||
|
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="p-2.5 rounded-lg bg-purple-100 text-purple-700">
|
||||||
|
<ShieldAlert size={24} />
|
||||||
|
</div>
|
||||||
|
{selectedRole === 'admin' && (
|
||||||
|
<CheckCircle2 size={20} className="text-purple-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-[#0f172a]">Administrator</h3>
|
||||||
|
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||||
|
Access system analytics, monitor platform users, review real-time ride logs, and manage safety controls.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-200/60 flex items-center justify-between text-xs font-semibold text-purple-700">
|
||||||
|
<span>System Management</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||||
|
<img
|
||||||
|
src="/assets/application-01-role-selection.png"
|
||||||
|
alt="Vehicle silhouette visual orientation"
|
||||||
|
className="w-20 h-20 object-contain rounded-lg bg-white border border-slate-200 p-1 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-slate-600 space-y-1">
|
||||||
|
<p className="font-semibold text-slate-900">Seamless Dual-Role Platform</p>
|
||||||
|
<p>
|
||||||
|
RideFlow provides high-contrast workstation tools for both passengers and drivers with instant telemetry switching.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleContinueFromRole}
|
||||||
|
className="px-6 py-2.5 bg-[#0f172a] text-white rounded-lg text-xs font-semibold hover:bg-slate-800 transition-colors flex items-center gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<span>Continue Setup</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'vehicle' && selectedRole === 'driver' && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-[#0f172a]">Driver Vehicle Registration</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Provide your vehicle make, model, license plate, and color for passenger verification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src="/assets/application-02-driver-vehicle-form.png"
|
||||||
|
alt="Driver Vehicle Registration"
|
||||||
|
className="w-14 h-14 object-contain rounded-lg border border-slate-200 bg-slate-50 p-1 hidden sm:block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DriverProfileForm onSuccess={handleDriverFormSuccess} />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('role')}
|
||||||
|
className="px-4 py-2 border border-slate-300 rounded-lg text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Back to Role
|
||||||
|
</button>
|
||||||
|
{driverProfileComplete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('safety')}
|
||||||
|
className="px-5 py-2 bg-[#0284c7] text-white rounded-lg text-xs font-semibold hover:bg-sky-700 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span>Proceed to Safety Profile</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'safety' && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||||
|
<div className="border-b border-slate-200 pb-4">
|
||||||
|
<h2 className="text-xl font-bold text-[#0f172a]">Companion Safety Configuration</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Set up your private safety persona and optional background verification consent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SafetyOnboardingCard onCompleted={handleSafetyCompleted} />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (selectedRole === 'driver' ? setStep('vehicle') : setStep('role'))}
|
||||||
|
className="px-4 py-2 border border-slate-300 rounded-lg text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFinishOnboarding}
|
||||||
|
className="px-6 py-2.5 bg-[#15803d] text-white rounded-lg text-xs font-semibold hover:bg-emerald-700 transition-colors flex items-center gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<span>Finish & Open Workstation</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'admin' && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||||
|
<div className="border-b border-slate-200 pb-4">
|
||||||
|
<h2 className="text-xl font-bold text-[#0f172a]">System Administration Overview</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Review platform telemetry, active user registries, and real-time ride logs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminStatsCards />
|
||||||
|
|
||||||
|
<div className="space-y-6 pt-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] mb-3">Platform User Directory</h3>
|
||||||
|
<AdminUserTable />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-[#0f172a] mb-3">Platform Ride Logs</h3>
|
||||||
|
<AdminRideLogsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('role')}
|
||||||
|
className="px-4 py-2 border border-slate-300 rounded-lg text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Back to Role
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFinishOnboarding}
|
||||||
|
className="px-6 py-2.5 bg-[#15803d] text-white rounded-lg text-xs font-semibold hover:bg-emerald-700 transition-colors flex items-center gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<span>Finish Setup</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-slate-100 rounded-xl p-4 border border-slate-200 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Compass size={16} className="text-[#0284c7]" />
|
||||||
|
<span>Already configured your profile? Jump directly to your destination workstation:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/app/ride')}
|
||||||
|
className="px-3 py-1.5 bg-white border border-slate-300 text-[#0f172a] rounded-lg hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Passenger Map
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/app/driver')}
|
||||||
|
className="px-3 py-1.5 bg-[#0f172a] text-white rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Driver Station
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
frontend/src/pages/PassengerRide.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import MapView, { MapMarker } from '../components/MapView';
|
||||||
|
import RideBookingForm from '../components/features/RideBookingForm';
|
||||||
|
import RideTrackingSheet from '../components/features/RideTrackingSheet';
|
||||||
|
import { Ride } from '../types/ride';
|
||||||
|
import { Car, Compass, MapPin, Navigation, RefreshCw, Shield } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function PassengerRide() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [previewPickup, setPreviewPickup] = useState<[number, number] | null>(null);
|
||||||
|
const [previewDestination, setPreviewDestination] = useState<[number, number] | null>(null);
|
||||||
|
const [selectedRideId, setSelectedRideId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch active / recent rides for the current passenger
|
||||||
|
const { data: ridesResponse, refetch, isRefetching } = useQuery({
|
||||||
|
queryKey: ['my-rides'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/api/rides/my');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const myRides: Ride[] = useMemo(() => {
|
||||||
|
if (ridesResponse && Array.isArray(ridesResponse.rides)) {
|
||||||
|
return ridesResponse.rides;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [ridesResponse]);
|
||||||
|
|
||||||
|
// Identify current active ride
|
||||||
|
const activeRide = useMemo(() => {
|
||||||
|
return myRides.find((r) => ['requested', 'accepted', 'picked_up'].includes(r.status)) || null;
|
||||||
|
}, [myRides]);
|
||||||
|
|
||||||
|
const currentTrackingId = selectedRideId || (activeRide ? activeRide.id : null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeRide && !selectedRideId) {
|
||||||
|
setSelectedRideId(activeRide.id);
|
||||||
|
}
|
||||||
|
}, [activeRide, selectedRideId]);
|
||||||
|
|
||||||
|
// Compute center position for map
|
||||||
|
const mapCenter: [number, number] = useMemo(() => {
|
||||||
|
if (activeRide) {
|
||||||
|
if (activeRide.driver_lat && activeRide.driver_lng) {
|
||||||
|
return [Number(activeRide.driver_lat), Number(activeRide.driver_lng)];
|
||||||
|
}
|
||||||
|
return [Number(activeRide.pickup_lat), Number(activeRide.pickup_lng)];
|
||||||
|
}
|
||||||
|
if (previewPickup) {
|
||||||
|
return previewPickup;
|
||||||
|
}
|
||||||
|
return [37.7749, -122.4194]; // Default SF coordinates
|
||||||
|
}, [activeRide, previewPickup]);
|
||||||
|
|
||||||
|
// Compute map markers
|
||||||
|
const mapMarkers: MapMarker[] = useMemo(() => {
|
||||||
|
const markers: MapMarker[] = [];
|
||||||
|
|
||||||
|
if (activeRide) {
|
||||||
|
markers.push({
|
||||||
|
id: 'pickup',
|
||||||
|
lat: Number(activeRide.pickup_lat),
|
||||||
|
lng: Number(activeRide.pickup_lng),
|
||||||
|
label: `Pickup: ${activeRide.pickup_location}`,
|
||||||
|
type: 'pickup',
|
||||||
|
});
|
||||||
|
|
||||||
|
markers.push({
|
||||||
|
id: 'dest',
|
||||||
|
lat: Number(activeRide.destination_lat),
|
||||||
|
lng: Number(activeRide.destination_lng),
|
||||||
|
label: `Destination: ${activeRide.destination_location}`,
|
||||||
|
type: 'destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeRide.driver_lat !== undefined &&
|
||||||
|
activeRide.driver_lat !== null &&
|
||||||
|
activeRide.driver_lng !== undefined &&
|
||||||
|
activeRide.driver_lng !== null
|
||||||
|
) {
|
||||||
|
markers.push({
|
||||||
|
id: 'driver',
|
||||||
|
lat: Number(activeRide.driver_lat),
|
||||||
|
lng: Number(activeRide.driver_lng),
|
||||||
|
label: activeRide.driver_name ? `Driver: ${activeRide.driver_name}` : 'Assigned Driver',
|
||||||
|
type: 'driver',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (previewPickup) {
|
||||||
|
markers.push({
|
||||||
|
id: 'preview-pickup',
|
||||||
|
lat: previewPickup[0],
|
||||||
|
lng: previewPickup[1],
|
||||||
|
label: 'Pickup Location',
|
||||||
|
type: 'pickup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (previewDestination) {
|
||||||
|
markers.push({
|
||||||
|
id: 'preview-dest',
|
||||||
|
lat: previewDestination[0],
|
||||||
|
lng: previewDestination[1],
|
||||||
|
label: 'Destination Location',
|
||||||
|
type: 'destination',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}, [activeRide, previewPickup, previewDestination]);
|
||||||
|
|
||||||
|
// Compute polyline route
|
||||||
|
const polylineRoute: [number, number][] | undefined = useMemo(() => {
|
||||||
|
if (activeRide) {
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
if (activeRide.driver_lat && activeRide.driver_lng && activeRide.status === 'accepted') {
|
||||||
|
points.push([Number(activeRide.driver_lat), Number(activeRide.driver_lng)]);
|
||||||
|
}
|
||||||
|
points.push([Number(activeRide.pickup_lat), Number(activeRide.pickup_lng)]);
|
||||||
|
points.push([Number(activeRide.destination_lat), Number(activeRide.destination_lng)]);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewPickup && previewDestination) {
|
||||||
|
return [previewPickup, previewDestination];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [activeRide, previewPickup, previewDestination]);
|
||||||
|
|
||||||
|
const handleCoordinatesChange = (
|
||||||
|
pickup: [number, number] | null,
|
||||||
|
destination: [number, number] | null
|
||||||
|
) => {
|
||||||
|
setPreviewPickup(pickup);
|
||||||
|
setPreviewDestination(destination);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRideCreated = (ride: Ride) => {
|
||||||
|
if (ride && ride.id) {
|
||||||
|
setSelectedRideId(ride.id);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRideCancelled = () => {
|
||||||
|
setSelectedRideId(null);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRideCompleted = () => {
|
||||||
|
setSelectedRideId(null);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-[calc(100vh-64px)] overflow-hidden bg-slate-950 flex flex-col md:flex-row">
|
||||||
|
|
||||||
|
<div className="absolute top-4 left-4 right-4 z-20 pointer-events-none flex items-center justify-between gap-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 bg-slate-900/90 backdrop-blur-md border border-slate-700/60 px-3 py-1.5 rounded-full shadow-lg text-xs font-medium text-slate-200">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline text-slate-400">RideFlow Telemetry:</span>
|
||||||
|
<span className="font-mono font-semibold text-emerald-400">GPS ACTIVE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
className="p-2 rounded-full bg-slate-900/90 hover:bg-slate-800 border border-slate-700/60 text-slate-300 transition-colors shadow-lg"
|
||||||
|
title="Refresh Dispatch"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isRefetching ? 'animate-spin text-sky-400' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/app/history"
|
||||||
|
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-900/90 hover:bg-slate-800 border border-slate-700/60 text-xs font-medium text-slate-300 transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
<Compass className="w-3.5 h-3.5 text-sky-400" />
|
||||||
|
<span>Ride History</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative w-full h-full flex-1 z-0">
|
||||||
|
<MapView
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={14}
|
||||||
|
markers={mapMarkers}
|
||||||
|
polyline={polylineRoute}
|
||||||
|
className="w-full h-full min-h-[300px] md:min-h-full bg-slate-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="z-10 w-full md:w-[420px] max-h-[60vh] md:max-h-full overflow-y-auto bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800 p-4 md:p-6 shadow-2xl flex flex-col justify-between">
|
||||||
|
{currentTrackingId ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Car className="w-5 h-5 text-sky-400" />
|
||||||
|
<h2 className="text-base font-semibold text-slate-100">Active Ride Status</h2>
|
||||||
|
</div>
|
||||||
|
{activeRide && (
|
||||||
|
<span className="text-xs font-mono uppercase px-2 py-0.5 rounded bg-sky-950 text-sky-300 border border-sky-800/60">
|
||||||
|
{activeRide.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RideTrackingSheet
|
||||||
|
rideId={currentTrackingId}
|
||||||
|
onRideCancelled={handleRideCancelled}
|
||||||
|
onRideCompleted={handleRideCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-100 tracking-tight">Request a Ride</h2>
|
||||||
|
<p className="text-xs text-slate-400">Select pickup and destination on map or preset points</p>
|
||||||
|
</div>
|
||||||
|
<Shield className="w-5 h-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<RideBookingForm
|
||||||
|
onRideCreated={handleRideCreated}
|
||||||
|
onSetCoordinates={handleCoordinatesChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<div className="mt-6 pt-3 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span className="truncate max-w-[180px]">{user?.email || 'Passenger Workspace'}</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/app/onboarding"
|
||||||
|
className="text-sky-400 hover:underline font-medium text-xs"
|
||||||
|
>
|
||||||
|
Safety Profile
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/pages/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { default as LandingPage } from './Landing';
|
||||||
|
export { default as OnboardingPage } from './Onboarding';
|
||||||
|
export { default as PassengerRidePage } from './PassengerRide';
|
||||||
|
export { default as DriverWorkstationPage } from './DriverWorkstation';
|
||||||
|
export { default as CirclesPage } from './Circles';
|
||||||
|
export { default as HistoryPage } from './History';
|
||||||
|
export { default as OnboardingPage } from './Onboarding';
|
||||||
|
export { default as AdminPage } from './Admin';
|
||||||
139
frontend/src/types/ride.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
export type UserRole = 'passenger' | 'driver' | 'admin' | 'member';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
mauth_token?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
citizen_id?: string | null;
|
||||||
|
is_banned?: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackgroundCheckStatus = 'not_started' | 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
export interface CompanionSafetyProfile {
|
||||||
|
user_email: string;
|
||||||
|
persona_label: string;
|
||||||
|
safety_consent: number;
|
||||||
|
background_check_status: BackgroundCheckStatus;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircleCadence = 'weekly' | 'biweekly' | 'monthly';
|
||||||
|
export type CircleStatus = 'forming' | 'active' | 'paused';
|
||||||
|
export type MemberStatus = 'invited' | 'accepted' | 'declined' | 'left';
|
||||||
|
|
||||||
|
export interface CompanionCircleMember {
|
||||||
|
circle_id: string;
|
||||||
|
user_email: string;
|
||||||
|
status: MemberStatus;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanionCircle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cadence: CircleCadence;
|
||||||
|
status: CircleStatus;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
members?: CompanionCircleMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityStatus = 'planned' | 'cancelled' | 'completed';
|
||||||
|
|
||||||
|
export interface CompanionActivity {
|
||||||
|
id: string;
|
||||||
|
circle_id: string;
|
||||||
|
created_by_email: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
location_label: string;
|
||||||
|
starts_at: string;
|
||||||
|
max_attendees?: number | null;
|
||||||
|
status: ActivityStatus;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RsvpStatus = 'going' | 'maybe' | 'not_going';
|
||||||
|
|
||||||
|
export interface CompanionActivityRsvp {
|
||||||
|
activity_id: string;
|
||||||
|
user_email: string;
|
||||||
|
status: RsvpStatus;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverProfile {
|
||||||
|
id: string;
|
||||||
|
user_email: string;
|
||||||
|
vehicle_make: string;
|
||||||
|
vehicle_model: string;
|
||||||
|
license_plate: string;
|
||||||
|
vehicle_color: string;
|
||||||
|
is_available: number;
|
||||||
|
current_lat?: number | null;
|
||||||
|
current_lng?: number | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RideStatus = 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export interface Ride {
|
||||||
|
id: string;
|
||||||
|
passenger_email: string;
|
||||||
|
driver_email?: string | null;
|
||||||
|
pickup_location: string;
|
||||||
|
pickup_lat: number;
|
||||||
|
pickup_lng: number;
|
||||||
|
destination_location: string;
|
||||||
|
destination_lat: number;
|
||||||
|
destination_lng: number;
|
||||||
|
status: RideStatus;
|
||||||
|
driver_lat?: number | null;
|
||||||
|
driver_lng?: number | null;
|
||||||
|
rating?: number | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
passenger_name?: string | null;
|
||||||
|
passenger_phone?: string | null;
|
||||||
|
driver_name?: string | null;
|
||||||
|
driver_phone?: string | null;
|
||||||
|
lat?: number | null;
|
||||||
|
lng?: number | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
total_users: number;
|
||||||
|
active_drivers: number;
|
||||||
|
total_rides: number;
|
||||||
|
active_rides: number;
|
||||||
|
completed_rides: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationType = 'info' | 'success' | 'warning' | 'mention' | 'system';
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
recipient_email: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
link?: string | null;
|
||||||
|
actor_email?: string | null;
|
||||||
|
is_read: number;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
80
frontend/tailwind.config.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: 'hsl(var(--warning))',
|
||||||
|
foreground: 'hsl(var(--warning-foreground))',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: 'hsl(var(--info))',
|
||||||
|
foreground: 'hsl(var(--info-foreground))',
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
transit: {
|
||||||
|
slate: '#0f172a',
|
||||||
|
surface: '#ffffff',
|
||||||
|
canvas: '#f8fafc',
|
||||||
|
border: '#cbd5e1',
|
||||||
|
telemetry: '#0284c7',
|
||||||
|
emerald: '#15803d',
|
||||||
|
amber: '#b45309',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 6px)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"Plus Jakarta Sans"', 'sans-serif'],
|
||||||
|
body: ['"DM Sans"', 'sans-serif'],
|
||||||
|
sans: ['"DM Sans"', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
transit: '0 10px 30px -5px rgba(15, 23, 42, 0.08)',
|
||||||
|
floating: '0 20px 40px -15px rgba(15, 23, 42, 0.15)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"react-query": "@tanstack/react-query",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warn(warning);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||