feat: add frontend in /frontend

This commit is contained in:
MWorld Deployer 2026-09-29 20:17:53 +05:30
parent 59fbe66793
commit 6e64fa16ea
76 changed files with 14625 additions and 0 deletions

File diff suppressed because it is too large Load Diff

4
frontend/.gitignore vendored Normal file
View File

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

26
frontend/components.json Normal file
View File

@ -0,0 +1,26 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"catalogVersion": "shadcn-radix-v1",
"style": "radix",
"rsc": false,
"tsx": true,
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
"lucide-react": "^0.378.0",
"class-variance-authority": "^0.7.0",
"tailwind-merge": "^2.0.0"
},
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

73
frontend/design/DESIGN.md Normal file
View 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

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

File diff suppressed because one or more lines are too long

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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

File diff suppressed because it is too large Load Diff

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

55
frontend/src/App.tsx Normal file
View 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
View File

@ -0,0 +1,97 @@
import axios from 'axios';
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? "http://127.0.0.1:8787"
: "";
export function resolveMediaUrl(value?: string | null): string {
if (!value) return '';
return value.startsWith('/api/media/') ? API_URL + value : value;
}
function resolveMediaUrls(value: unknown): unknown {
if (typeof value === 'string') return resolveMediaUrl(value);
if (Array.isArray(value)) return value.map(resolveMediaUrls);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [key, resolveMediaUrls(nested)])
);
}
return value;
}
export const api = axios.create({
baseURL: API_URL
});
export const adminApi = axios.create({
baseURL: API_URL
});
api.interceptors.request.use((config) => {
// Force the /api prefix on all internal API calls that forgot it
if (config.url && !config.url.startsWith('/api') && !config.url.startsWith('http')) {
config.url = `/api${config.url.startsWith('/') ? '' : '/'}${config.url}`;
}
const token = localStorage.getItem('user_auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
config.headers['Content-Type'] = undefined;
}
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
config.headers['x-mworld-origin'] = currentOrigin;
return config;
});
api.interceptors.response.use(
(response) => {
response.data = resolveMediaUrls(response.data);
return response;
},
(error) => {
if (error.response?.status === 401) {
// Prevent infinite page reloads on check-in failures
const isCheckIn = error.config?.url?.includes('/user/check-in');
if (!isCheckIn) {
const hadToken = localStorage.getItem('user_auth_token');
localStorage.removeItem('user_auth_token');
localStorage.removeItem('auth_user');
if (hadToken) window.location.reload();
}
}
return Promise.reject(error);
}
);
adminApi.interceptors.request.use((config) => {
if (config.url && !config.url.startsWith('http')) {
if (config.url.startsWith('/api/admin')) {
// Already namespaced.
} else if (config.url.startsWith('/api/')) {
config.url = `/api/admin${config.url.slice('/api'.length)}`;
} else {
config.url = `/api/admin${config.url.startsWith('/') ? '' : '/'}${config.url}`;
}
}
const token = sessionStorage.getItem('admin_auth_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
config.headers['Content-Type'] = undefined;
}
config.headers['x-mworld-origin'] = window.location.origin;
return config;
});
adminApi.interceptors.response.use(
(response) => {
response.data = resolveMediaUrls(response.data);
return response;
},
(error) => Promise.reject(error)
);
export default api;

View File

@ -0,0 +1,117 @@
import React, { FormEvent, useEffect, useState } from 'react';
import { KeyRound, Loader2, LockKeyhole, LogOut } from 'lucide-react';
import { adminApi } from '../api';
export default function AdminTokenGate({ children }: { children: React.ReactNode }) {
const siteTitle = "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>;
}

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

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

View 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 &rarr;
</Link>
</div>
</div>
)}
</div>
);
}

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

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

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

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

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

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

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

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

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

View 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 &amp; Request Ride</span>
</>
)}
</button>
</form>
</div>
);
}

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

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

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

View File

@ -0,0 +1,7 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Alert = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='alert' className={cx('rounded-lg border p-4 text-sm', className)} {...props} />;
export const AlertTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h5 className='mb-1 font-medium' {...props} />;
export const AlertDescription = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='text-muted-foreground' {...props} />;
export default Alert;

View File

@ -0,0 +1,5 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Badge = ({ className, variant, size, ...props }: React.HTMLAttributes<HTMLSpanElement> & { variant?: string; size?: string }) => <span className={cx('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', className)} {...props} />;
export default Badge;

View File

@ -0,0 +1,6 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Button = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }>(({ className, variant, size, ...props }, ref) => <button ref={ref} className={cx('inline-flex min-h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', className)} {...props} />);
Button.displayName = 'Button';
export default Button;

View File

@ -0,0 +1,10 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />;
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-1.5 p-6', className)} {...props} />;
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => <h3 className={cx('text-lg font-semibold leading-none tracking-tight', className)} {...props} />;
export const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={cx('text-sm text-muted-foreground', className)} {...props} />;
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('p-6 pt-0', className)} {...props} />;
export const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex items-center p-6 pt-0', className)} {...props} />;
export default Card;

View File

@ -0,0 +1,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;

View File

@ -0,0 +1,9 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Dialog = ({ open = true, children }: { open?: boolean; children?: React.ReactNode }) => open ? <div role='dialog' aria-modal='true' className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4'>{children}</div> : null;
export const DialogContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg', className)} {...props} />;
export const DialogTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h2 className='text-lg font-semibold' {...props} />;
export const DialogDescription = (props: React.HTMLAttributes<HTMLParagraphElement>) => <p className='text-sm text-muted-foreground' {...props} />;
export const DialogClose = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' aria-label='Close' {...props} />;
export default Dialog;

View File

@ -0,0 +1,6 @@
import React from 'react';
export const DropdownMenu = ({ children }: { children?: React.ReactNode }) => <div className='relative'>{children}</div>;
export const DropdownMenuTrigger = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' {...props} />;
export const DropdownMenuContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='absolute right-0 z-20 mt-2 min-w-40 rounded-md border bg-popover p-1 shadow-md' {...props} />;
export const DropdownMenuItem = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' className='flex w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent' {...props} />;

View File

@ -0,0 +1,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;

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

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

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
Input.displayName = 'Input';
export default Input;

View File

@ -0,0 +1,4 @@
import React from 'react';
export const Label = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium leading-none', className].filter(Boolean).join(' ')} {...props} />;
export default Label;

View File

@ -0,0 +1,7 @@
import React from 'react';
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, ...props }, ref) => <select ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm', className].filter(Boolean).join(' ')} {...props} />);
Select.displayName = 'Select';
export const SelectItem = (props: React.OptionHTMLAttributes<HTMLOptionElement>) => <option {...props} />;
export const SelectGroup = (props: React.OptgroupHTMLAttributes<HTMLOptGroupElement>) => <optgroup {...props} />;
export default Select;

View File

@ -0,0 +1,4 @@
import React from 'react';
export const Separator = ({ orientation = 'horizontal', className, ...props }: React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) => <div role='separator' aria-orientation={orientation} className={[orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full', 'shrink-0 bg-border', className].filter(Boolean).join(' ')} {...props} />;
export default Separator;

View File

@ -0,0 +1,3 @@
import React from 'react';
export { Dialog as Sheet, DialogContent as SheetContent, DialogTitle as SheetTitle, DialogDescription as SheetDescription, DialogClose as SheetClose } from './dialog';

View File

@ -0,0 +1,5 @@
import React from 'react';
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;

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Switch = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' role='switch' className='size-5 rounded-full border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
Switch.displayName = 'Switch';
export default Switch;

View File

@ -0,0 +1,10 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Table = ({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <div className='w-full overflow-auto'><table className={cx('w-full caption-bottom text-sm', className)} {...props} /></div>;
export const TableHeader = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead {...props} />;
export const TableBody = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />;
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr className={cx('border-b', className)} {...props} />;
export const TableHead = (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th className='h-10 px-2 text-left align-middle font-medium' {...props} />;
export const TableCell = (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td className='p-2 align-middle' {...props} />;
export default Table;

View File

@ -0,0 +1,8 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Tabs = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-3', className)} {...props} />;
export const TabsList = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tablist' className={cx('inline-flex h-10 items-center gap-1 rounded-md bg-muted p-1', className)} {...props} />;
export const TabsTrigger = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button role='tab' className={cx('rounded px-3 py-1.5 text-sm', className)} {...props} />;
export const TabsContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tabpanel' className={cx('mt-2', className)} {...props} />;
export default Tabs;

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(({ className, ...props }, ref) => <textarea ref={ref} className={['flex min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
Textarea.displayName = 'Textarea';
export default Textarea;

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Tooltip = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
export const TooltipTrigger = (props: React.HTMLAttributes<HTMLElement>) => <span {...props} />;
export const TooltipContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div role='tooltip' className='rounded bg-foreground px-2 py-1 text-xs text-background' {...props} />;

View File

@ -0,0 +1,154 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../api';
import { authLink } from '../lib/mAuth';
export interface UserData {
username: string;
fullName?: string;
full_name?: string;
email: string;
phone: string;
citizen_id: string;
[key: string]: any;
}
interface AuthContextType {
user: UserData | null;
token: string | null;
login: () => Promise<void>;
checkAuth: () => Promise<UserData | null>;
logout: () => void;
updateUser: (userData: UserData) => void;
isAuthenticated: boolean;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<UserData | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const updateUser = (newUserData: UserData) => {
setUser(newUserData);
localStorage.setItem('auth_user', JSON.stringify(newUserData));
};
/**
* Calls the backend check-in endpoint to sync the mAuth user
* with the local database and get the full user profile.
*/
const handleCheckIn = async (authToken: string) => {
try {
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
const res = await api.post('/user/check-in', {
mauth_token: authToken,
});
if (res.data?.success && res.data.user) {
setUser(res.data.user);
localStorage.setItem('auth_user', JSON.stringify(res.data.user));
return res.data.user;
}
} catch (err) {
console.error("Check-in failed:", err);
logout();
}
return null;
};
// Public compatibility method for generated onboarding and role guards.
// It always reuses the scaffolded check-in path instead of inventing a
// second token-validation flow in individual pages.
const checkAuth = async (): Promise<UserData | null> => {
const authToken = token || localStorage.getItem('user_auth_token') || localStorage.getItem('auth_token');
if (!authToken) return null;
setToken(authToken);
return handleCheckIn(authToken);
};
/**
* mAuth Native Initializer:
* 1. Check for user_auth_token in localStorage (set by /auth route).
* 2. If present, call backend check-in to validate & sync user data.
* The backend handles mAuth token verification — we must NOT call
* fetchUserData here too, as that would consume/invalidate the token
* before the backend can use it.
* 3. If absent or invalid, clear auth state so the login button is shown.
*/
useEffect(() => {
const initializer = async () => {
const origin = window.location.origin.toString();
if (!localStorage.getItem('mworld_origin')) {
localStorage.setItem('mworld_origin', origin);
}
const uat = localStorage.getItem('user_auth_token');
if (uat) {
setToken(uat);
// Restore cached user data immediately for fast UI
const savedUser = localStorage.getItem('auth_user');
if (savedUser && savedUser !== 'undefined') {
try {
setUser(JSON.parse(savedUser));
} catch { /* ignore parse errors */ }
}
// Validate token & sync user via backend check-in
// (backend calls mAuth internally to verify the token)
const freshUser = await handleCheckIn(uat);
if (!freshUser) {
// Token is invalid or check-in failed — clean up
localStorage.removeItem('user_auth_token');
logout();
}
}
// If no token present, user stays logged out (login button shown)
setIsLoading(false);
};
initializer();
}, []);
/**
* Triggers the mAuth native login flow:
* Generates an auth link and redirects the user to mAuth in the same window.
*/
const login = async (): Promise<void> => {
setIsLoading(true);
try {
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
const link = await authLink(currentOrigin);
if (!link) {
setIsLoading(false);
return;
}
// Native flow: redirect in same window (not window.open)
window.location.href = link;
} catch (error) {
console.error("Login failed:", error);
setIsLoading(false);
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_user');
localStorage.removeItem('user_auth_token');
};
return (
<AuthContext.Provider value={{ user, token, login, checkAuth, logout, updateUser, isAuthenticated: !!user, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};

View File

@ -0,0 +1,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
View 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
View File

@ -0,0 +1,47 @@
import axios from 'axios';
const mAuthAPIURL = "https://api.mauth.mworld.cloud";
/**
* Creates a new mAuth authentication link.
* Requires the current origin URL.
*/
export const authLink = async (currentOrigin: string): Promise<string | null> => {
try {
const r = await axios.get(`${mAuthAPIURL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}&type=native`);
console.log(r.data);
const link = r.data?.mAuth?.auth_link;
return link || null;
} catch (err) {
console.log(err);
return null;
}
};
/**
* Fetches the data of the user who authenticated.
* Requires origin URL and the user auth token.
*
* WARNING: This CONSUMES the mAuth token. The backend `/user/check-in`
* endpoint already verifies the token server-side, so calling this during
* app bootstrap would invalidate the token before the backend can use it.
* Do NOT call this from AuthContext — it exists only for one-off flows that
* need raw mAuth profile fields without a backend round-trip.
*/
export const fetchUserData = async (origin: string, token: string) => {
try {
const payload = {
origin: origin,
required_user_data: ["fullName", "email", "phone"],
};
const r = await axios.get(
`${mAuthAPIURL}/mAuthUser/${token}?payload=${JSON.stringify(payload)}`
);
console.log(r.data);
return r.data;
} catch (err) {
console.log(err);
return null;
}
};

36
frontend/src/lib/utils.ts Normal file
View File

@ -0,0 +1,36 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(price: any): string {
const num = Number(price);
if (isNaN(num)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(num);
}
export function formatMileage(mileage: any): string {
const num = Number(mileage);
if (isNaN(num)) return '';
return new Intl.NumberFormat('en-US').format(num) + ' miles';
}
export function formatDate(dateString: any): string {
if (!dateString) return '';
try {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
} catch (e) {
return '';
}
}

31
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,31 @@
import React, { Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './context/AuthContext'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<AuthProvider>
<App />
</AuthProvider>
</Suspense>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,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 &amp; 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 &amp; 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 &rarr;
</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 &amp; Recent Rides
</h3>
<button
onClick={() => handleTabChange('rides')}
className="text-xs text-sky-400 hover:underline font-medium"
>
View All Ride Logs &rarr;
</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 &amp; 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

File diff suppressed because one or more lines are too long

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

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

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

View 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&apos;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 &rarr; Accepted &rarr; Picked Up &rarr; 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 &amp; 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>&copy; {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>
);
}

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

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

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

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

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

27
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"react-query": "@tanstack/react-query",
},
},
build: {
rollupOptions: {
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
return;
}
warn(warning);
},
},
},
})