feat: add frontend in /frontend
2854
dist-worker/unified-worker.js
Normal file
4
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
.env
|
||||
26
frontend/components.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"catalogVersion": "shadcn-radix-v1",
|
||||
"style": "radix",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"lucide-react": "^0.378.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
73
frontend/design/DESIGN.md
Normal file
@ -0,0 +1,73 @@
|
||||
# RideFlow design preview
|
||||
|
||||
**Personality:** Urban Signal Precision
|
||||
|
||||
Tactical urban transit utility rendered with geometric clarity, signal-level status accenting, and instant state feedback.
|
||||
|
||||
**Design read:** Reading RideFlow as a high-velocity, real-time urban transit platform demanding spatial clarity, instant state feedback, and driver-centric legibility under active movement. The visual language marries clean map-first interfaces with high-contrast signal status indicators for seamless transition between passenger booking and driver navigation.
|
||||
|
||||
## Taste dials
|
||||
- Design variance: 6/10
|
||||
- Motion intensity: 5/10
|
||||
- Visual density: 4/10
|
||||
|
||||
## System
|
||||
- Direction: High-Contrast Transit Navigation
|
||||
- Default mode: light
|
||||
- Display font: Plus Jakarta Sans
|
||||
- Body font: DM Sans
|
||||
- Landing composition: Asymmetric hero pairing a dynamic interactive route preview stage on the right with a bold, left-aligned booking callout. Features transition into structured split-screen workstation previews.
|
||||
|
||||
## Surfaces
|
||||
|
||||
### landing
|
||||
Navigation: Top-tier sticky header navigation bar with brand logotype, quick links for Passenger and Driver role features, and a prominent M-Auth sign-in button.
|
||||
- Public Landing Shell (/*): Introduce RideFlow transit capabilities, showcase live map routing and driver tracking, and route incoming passengers and drivers into M-Auth login.
|
||||
- hero-interactive-preview: Asymmetric Interactive Hero Stage - High-contrast light slate canvas (#f8fafc) with crisp border enclosure around vector route preview. Telemetry blue accents draw eyes to pickup actions.
|
||||
- dual-role-workstation-overview: Split Navigation Workstation Showcase - Side-by-side elevated slate cards with 1px border outlines (#cbd5e1), signal badges in emerald green and telemetry blue.
|
||||
- live-telemetry-capabilities: Linear Dual-Workflow Process Strip - Full-bleed muted slate strip (#f1f5f9) delimited by sharp top and bottom hairline borders (#e2e8f0).
|
||||
- safety-companion-framework: Full-Width Signal Capability Deck - Deep dark inverse surface (#0f172a) with crisp white typography and emerald verification accents for high visual contrast.
|
||||
- authentication-access-cta: Centered High-Contrast Signal Banner - Crisp slate background with dark primary CTA button (#0f172a) and telemetry blue focus rings.
|
||||
|
||||
### application
|
||||
Navigation: Top bar shell with prominent active trip banner, role-aware navigation switches (Passenger / Driver / Circles / History), and drawer access on small screens.
|
||||
- Onboarding & Safety Setup (/app/onboarding): Guide new users through role selection, driver vehicle registration, and private companion safety consent setup.
|
||||
- role-selection: Linear Single-Column Setup Form - Crisp white bordered surface with signal indigo selected state
|
||||
- driver-vehicle-form: Linear Single-Column Setup Form - Muted slate inset form field group with distinct border boundaries
|
||||
- companion-safety-profile: Linear Single-Column Setup Form - Light slate card with emerald verified border accenting
|
||||
- Passenger Workstation (/app/ride): Interactive map interface for booking rides, estimating route fare, tracking driver movement live, and rating completed trips.
|
||||
- booking-panel: Floating Bottom Sheet over Interactive Canvas - Crisp surface with high-contrast primary button and signal accents
|
||||
- live-tracking-panel: Floating Bottom Sheet over Interactive Canvas - Slate inverse card background with vibrant telemetry status badges
|
||||
- trip-rating-modal: Linear Single-Column Setup Form - Clean dialog with crisp divider and prominent primary star selectors
|
||||
- Driver Workstation (/app/driver): Control panel for drivers to toggle online availability, review incoming ride requests, navigate active trips, and report live GPS location.
|
||||
- availability-header: Split View Navigation Workstation - Dark slate bar with bright emerald status glow when online
|
||||
- incoming-requests: Split View Navigation Workstation - High-contrast white cards with thick slate left border
|
||||
- active-navigation-controls: Floating Bottom Sheet over Interactive Canvas - Tactical dark bottom bar with large high-contrast primary action button
|
||||
- Companion Circles & Activity Hub (/app/circles): View companion circles, handle invitations, manage recurring activity plans, and register RSVPs.
|
||||
- circles-overview: Split View Navigation Workstation - Bordered card stack with crisp badge indicators
|
||||
- activity-planning: Linear Single-Column Setup Form - Clean dialog surface with clear form row grouping
|
||||
- activity-rsvp-list: Split View Navigation Workstation - Crisp white card with left emerald accent line for 'Going' status
|
||||
- Ride & Activity History (/app/history): Comprehensive log of past ride bookings and companion activities with statuses, drivers, timestamps, and ratings.
|
||||
- history-filters: Dense Tactical Data Table - Light slate filter toolbar with clear active tab indicator
|
||||
- history-table-list: Dense Tactical Data Table - Dense table with subtle hover row highlights and crisp status badges
|
||||
|
||||
### admin
|
||||
Navigation: Sidebar Navigation with Top System Header and Tabbed Context Panels
|
||||
- System Overview (/admin): Provide system operators with real-time operational telemetry, active ride counts, user volume stats, and system health status.
|
||||
- admin-kpi-summary: Dense Tactical Data Table - Slate container with crisp subtle borders, emerald telemetry badge indicators, and high-contrast bold typography.
|
||||
- admin-quick-navigation: Split View Navigation Workstation - Clean slate background with strong dark borders and distinct action buttons.
|
||||
- User Directory (/admin/users): Enable system operators to inspect registered passengers, drivers, and admin accounts in a structured table.
|
||||
- user-table-controls: Linear Single-Column Setup Form - Framed surface bar with muted slate border and explicit input labels.
|
||||
- user-directory-table: Dense Tactical Data Table - Clean white table surface with subtle slate row dividers and high-contrast text contrast.
|
||||
- System Ride Logs (/admin/rides): Allow system admins to audit all historical and active ride requests, driver assignments, locations, status progressions, and passenger ratings.
|
||||
- ride-log-filters: Split View Navigation Workstation - Slate tabs with active slate-900 high-contrast underline visual indicator.
|
||||
- ride-log-table: Dense Tactical Data Table - Tabular grid with alternating slight surface contrast for easy line scanning.
|
||||
|
||||
## Generated image placements
|
||||
- landing-01-hero-interactive-preview: landing /* / hero-interactive-preview
|
||||
- landing-02-dual-role-workstation-overview: landing /* / dual-role-workstation-overview
|
||||
- landing-03-live-telemetry-capabilities: landing /* / live-telemetry-capabilities
|
||||
- landing-04-safety-companion-framework: landing /* / safety-companion-framework
|
||||
- application-01-role-selection: application /app/onboarding / role-selection
|
||||
- application-02-driver-vehicle-form: application /app/onboarding / driver-vehicle-form
|
||||
- admin-01-admin-kpi-summary: admin /admin / admin-kpi-summary
|
||||
93
frontend/design/asset-manifest.json
Normal file
@ -0,0 +1,93 @@
|
||||
[
|
||||
{
|
||||
"id": "landing-01-hero-interactive-preview",
|
||||
"src": "/assets/landing-01-hero-interactive-preview.png",
|
||||
"alt": "Interactive Vector Route Canvas for RideFlow",
|
||||
"role": "Interactive Vector Route Canvas",
|
||||
"width": 1600,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/*",
|
||||
"sectionId": "hero-interactive-preview",
|
||||
"usage": "High-contrast light slate canvas (#f8fafc) with crisp border enclosure around vector route preview. Telemetry blue accents draw eyes to pickup actions.",
|
||||
"cropFocus": "Keep the primary subject in the central-right safe area so concise hero copy can sit to the left"
|
||||
},
|
||||
{
|
||||
"id": "landing-02-dual-role-workstation-overview",
|
||||
"src": "/assets/landing-02-dual-role-workstation-overview.png",
|
||||
"alt": "Passenger Booking Interface Vector Diagram for RideFlow",
|
||||
"role": "Passenger Booking Interface Vector Diagram",
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/*",
|
||||
"sectionId": "dual-role-workstation-overview",
|
||||
"usage": "Side-by-side elevated slate cards with 1px border outlines (#cbd5e1), signal badges in emerald green and telemetry blue.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "landing-03-live-telemetry-capabilities",
|
||||
"src": "/assets/landing-03-live-telemetry-capabilities.png",
|
||||
"alt": "Highlight technical platform reliability including GPS location sharing, for RideFlow",
|
||||
"role": "Highlight technical platform reliability including GPS location sharing,",
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/*",
|
||||
"sectionId": "live-telemetry-capabilities",
|
||||
"usage": "Full-bleed muted slate strip (#f1f5f9) delimited by sharp top and bottom hairline borders (#e2e8f0).",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "landing-04-safety-companion-framework",
|
||||
"src": "/assets/landing-04-safety-companion-framework.png",
|
||||
"alt": "Companion Safety Verification Badge Graphic for RideFlow",
|
||||
"role": "Companion Safety Verification Badge Graphic",
|
||||
"width": 1200,
|
||||
"height": 900,
|
||||
"surfaceId": "landing",
|
||||
"screenRoute": "/*",
|
||||
"sectionId": "safety-companion-framework",
|
||||
"usage": "Deep dark inverse surface (#0f172a) with crisp white typography and emerald verification accents for high visual contrast.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "application-01-role-selection",
|
||||
"src": "/assets/application-01-role-selection.png",
|
||||
"alt": "Vehicle silhouette visual orientation for RideFlow",
|
||||
"role": "Vehicle silhouette visual orientation",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"surfaceId": "application",
|
||||
"screenRoute": "/app/onboarding",
|
||||
"sectionId": "role-selection",
|
||||
"usage": "Crisp white bordered surface with signal indigo selected state",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "application-02-driver-vehicle-form",
|
||||
"src": "/assets/application-02-driver-vehicle-form.png",
|
||||
"alt": "Collect driver vehicle make, model, license plate, and color visual for RideFlow",
|
||||
"role": "Collect driver vehicle make, model, license plate, and color visual",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"surfaceId": "application",
|
||||
"screenRoute": "/app/onboarding",
|
||||
"sectionId": "driver-vehicle-form",
|
||||
"usage": "Muted slate inset form field group with distinct border boundaries",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
},
|
||||
{
|
||||
"id": "admin-01-admin-kpi-summary",
|
||||
"src": "/assets/admin-01-admin-kpi-summary.png",
|
||||
"alt": "Display top-level system stats including total users, online drivers, system for RideFlow",
|
||||
"role": "Display top-level system stats including total users, online drivers, system",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"surfaceId": "admin",
|
||||
"screenRoute": "/admin",
|
||||
"sectionId": "admin-kpi-summary",
|
||||
"usage": "Slate container with crisp subtle borders, emerald telemetry badge indicators, and high-contrast bold typography.",
|
||||
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
|
||||
}
|
||||
]
|
||||
1530
frontend/design/design-contract.json
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RideFlow</title>
|
||||
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="s01qyw"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3280
frontend/package-lock.json
generated
Normal file
48
frontend/package.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "rideflow-s01qyw",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"zod": "^3.22.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"sonner": "latest",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"axios": "^1.6.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"framer-motion": "^10.16.4",
|
||||
"recharts": "^2.13.0",
|
||||
"react-is": "^18.3.1",
|
||||
"lucide-react": "^0.378.0",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"vite": "^4.5.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"typescript": "^5.2.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"socket.io-client": "4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/node": "^20.11.0"
|
||||
}
|
||||
}
|
||||
1
frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
BIN
frontend/public/assets/admin-01-admin-kpi-summary.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/application-01-role-selection.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/application-02-driver-vehicle-form.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/landing-01-hero-interactive-preview.png
Normal file
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 940 KiB |
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/assets/landing-04-safety-companion-framework.png
Normal file
|
After Width: | Height: | Size: 793 KiB |
55
frontend/src/App.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { Toaster } from 'sonner';
|
||||
import Auth from './pages/Auth';
|
||||
import AdminTokenGate from './components/AdminTokenGate';
|
||||
import Navbar from './components/Navbar';
|
||||
import LandingPage from './pages/Landing';
|
||||
import OnboardingPage from './pages/Onboarding';
|
||||
import PassengerRidePage from './pages/PassengerRide';
|
||||
import DriverWorkstationPage from './pages/DriverWorkstation';
|
||||
import CirclesPage from './pages/Circles';
|
||||
import HistoryPage from './pages/History';
|
||||
import OnboardingPage from './pages/Onboarding';
|
||||
import AdminPage from './pages/Admin';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading session...</div>;
|
||||
if (!user) {
|
||||
const returnTo = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={'/auth?returnTo=' + returnTo} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const isPublicShellRoute = location.pathname === '/' || location.pathname === '/auth' || location.pathname.startsWith('/admin');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-right" />
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{!isPublicShellRoute && <Navbar />}
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/app" element={<ProtectedRoute><OnboardingPage /></ProtectedRoute>} />
|
||||
<Route path="/app/ride" element={<ProtectedRoute><PassengerRidePage /></ProtectedRoute>} />
|
||||
<Route path="/app/driver" element={<ProtectedRoute><DriverWorkstationPage /></ProtectedRoute>} />
|
||||
<Route path="/app/circles" element={<ProtectedRoute><CirclesPage /></ProtectedRoute>} />
|
||||
<Route path="/app/history" element={<ProtectedRoute><HistoryPage /></ProtectedRoute>} />
|
||||
<Route path="/app/onboarding" element={<ProtectedRoute><OnboardingPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<AdminTokenGate><AdminPage /></AdminTokenGate>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
frontend/src/api.ts
Normal file
@ -0,0 +1,97 @@
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||
? "http://127.0.0.1:8787"
|
||||
: "";
|
||||
|
||||
export function resolveMediaUrl(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
return value.startsWith('/api/media/') ? API_URL + value : value;
|
||||
}
|
||||
|
||||
function resolveMediaUrls(value: unknown): unknown {
|
||||
if (typeof value === 'string') return resolveMediaUrl(value);
|
||||
if (Array.isArray(value)) return value.map(resolveMediaUrls);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [key, resolveMediaUrls(nested)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL
|
||||
});
|
||||
|
||||
export const adminApi = axios.create({
|
||||
baseURL: API_URL
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
// Force the /api prefix on all internal API calls that forgot it
|
||||
if (config.url && !config.url.startsWith('/api') && !config.url.startsWith('http')) {
|
||||
config.url = `/api${config.url.startsWith('/') ? '' : '/'}${config.url}`;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('user_auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = undefined;
|
||||
}
|
||||
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||
config.headers['x-mworld-origin'] = currentOrigin;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
response.data = resolveMediaUrls(response.data);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Prevent infinite page reloads on check-in failures
|
||||
const isCheckIn = error.config?.url?.includes('/user/check-in');
|
||||
if (!isCheckIn) {
|
||||
const hadToken = localStorage.getItem('user_auth_token');
|
||||
localStorage.removeItem('user_auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
if (hadToken) window.location.reload();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
if (config.url && !config.url.startsWith('http')) {
|
||||
if (config.url.startsWith('/api/admin')) {
|
||||
// Already namespaced.
|
||||
} else if (config.url.startsWith('/api/')) {
|
||||
config.url = `/api/admin${config.url.slice('/api'.length)}`;
|
||||
} else {
|
||||
config.url = `/api/admin${config.url.startsWith('/') ? '' : '/'}${config.url}`;
|
||||
}
|
||||
}
|
||||
const token = sessionStorage.getItem('admin_auth_token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = undefined;
|
||||
}
|
||||
config.headers['x-mworld-origin'] = window.location.origin;
|
||||
return config;
|
||||
});
|
||||
|
||||
adminApi.interceptors.response.use(
|
||||
(response) => {
|
||||
response.data = resolveMediaUrls(response.data);
|
||||
return response;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
export default api;
|
||||
117
frontend/src/components/AdminTokenGate.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
|
||||
import React, { FormEvent, useEffect, useState } from 'react';
|
||||
import { KeyRound, Loader2, LockKeyhole, LogOut } from 'lucide-react';
|
||||
import { adminApi } from '../api';
|
||||
|
||||
export default function AdminTokenGate({ children }: { children: React.ReactNode }) {
|
||||
const siteTitle = "RideFlow";
|
||||
const [token, setToken] = useState('');
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const tokenParamNames = ['agentic_token', 'mAuthAgenticToken', 'mAgenticAuthToken', 'mAgenticAuth'];
|
||||
|
||||
const normalizeAgenticToken = (value: string | null): string => {
|
||||
let normalized = String(value || '').trim().replace(/^["']|["']$/g, '').trim();
|
||||
normalized = normalized.replace(/^Bearer[ ]+/i, '').trim();
|
||||
if (normalized.toLowerCase().startsWith('http://') || normalized.toLowerCase().startsWith('https://')) {
|
||||
try {
|
||||
const pastedUrl = new URL(normalized);
|
||||
const hashParams = new URLSearchParams(pastedUrl.hash.startsWith('#') ? pastedUrl.hash.slice(1) : '');
|
||||
normalized = tokenParamNames.map(name => pastedUrl.searchParams.get(name) || hashParams.get(name)).find(Boolean) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (/%[0-9a-f]{2}/i.test(normalized)) {
|
||||
try { normalized = decodeURIComponent(normalized); } catch { /* keep the original token */ }
|
||||
}
|
||||
return normalized.trim();
|
||||
};
|
||||
|
||||
const readAgenticHandoff = () => {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : '');
|
||||
const handoff = tokenParamNames.map(name => url.searchParams.get(name) || hashParams.get(name)).find(Boolean) || null;
|
||||
if (handoff) {
|
||||
tokenParamNames.forEach(name => {
|
||||
url.searchParams.delete(name);
|
||||
hashParams.delete(name);
|
||||
});
|
||||
url.hash = hashParams.toString() ? '#' + hashParams.toString() : '';
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
return normalizeAgenticToken(handoff);
|
||||
};
|
||||
|
||||
const verify = async (candidate: string) => {
|
||||
const normalized = normalizeAgenticToken(candidate);
|
||||
setToken(normalized);
|
||||
if (!normalized) {
|
||||
setError('A valid M-Auth agentic token is required.');
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
setChecking(true);
|
||||
setError('');
|
||||
sessionStorage.setItem('admin_auth_token', normalized);
|
||||
try {
|
||||
await adminApi.get('/api/admin/verify-token');
|
||||
setVerified(true);
|
||||
} catch (requestError: any) {
|
||||
const status = Number(requestError?.response?.status || 0);
|
||||
setVerified(false);
|
||||
if (status === 401 || status === 403) {
|
||||
sessionStorage.removeItem('admin_auth_token');
|
||||
setError('That M-Auth agentic token is invalid or expired. Open the admin panel from Fullstack Builder again.');
|
||||
} else {
|
||||
setError('M-Auth could not be reached right now. Your token has been kept; retry in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const saved = readAgenticHandoff() || normalizeAgenticToken(sessionStorage.getItem('admin_auth_token'));
|
||||
if (saved) verify(saved);
|
||||
else setChecking(false);
|
||||
}, []);
|
||||
|
||||
if (verified) {
|
||||
return <div className="min-h-screen bg-background text-foreground">
|
||||
<div className="fixed right-4 top-4 z-50">
|
||||
<button
|
||||
type="button"
|
||||
title="Lock admin panel"
|
||||
onClick={() => { sessionStorage.removeItem('admin_auth_token'); setVerified(false); setToken(''); }}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <main className="flex min-h-screen items-center justify-center bg-background px-5 text-foreground">
|
||||
<section className="w-full max-w-md border border-border bg-background p-7 shadow-xl">
|
||||
<div className="mb-7 flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground"><LockKeyhole className="h-5 w-5" /></div>
|
||||
<div><h1 className="text-xl font-semibold">{siteTitle} Admin</h1><p className="mt-1 text-sm text-muted-foreground">Restricted operator access</p></div>
|
||||
</div>
|
||||
<form onSubmit={(event: FormEvent) => { event.preventDefault(); verify(token); }} className="space-y-4">
|
||||
<label className="block text-sm font-medium" htmlFor="admin-token">M-Auth agentic token</label>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<input id="admin-token" type="password" autoComplete="off" value={token} onChange={event => setToken(event.target.value)} className="h-10 w-full border border-input bg-background pl-10 pr-3 text-sm outline-none focus:border-primary" />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<button disabled={checking || !token.trim()} className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-50">
|
||||
{checking && <Loader2 className="h-4 w-4 animate-spin" />} Unlock admin panel
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
163
frontend/src/components/MapView.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
export interface MapMarker {
|
||||
id?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label?: string;
|
||||
type?: 'pickup' | 'destination' | 'driver' | 'default';
|
||||
}
|
||||
|
||||
export interface MapViewProps {
|
||||
center: [number, number];
|
||||
zoom?: number;
|
||||
markers?: MapMarker[];
|
||||
polyline?: [number, number][];
|
||||
className?: string;
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
const createCustomIcon = (type: MapMarker['type'] = 'default') => {
|
||||
let bgColor = '#0f172a';
|
||||
let ringColor = 'rgba(15, 23, 42, 0.2)';
|
||||
let symbol = '•';
|
||||
|
||||
if (type === 'pickup') {
|
||||
bgColor = '#15803d';
|
||||
ringColor = 'rgba(21, 128, 61, 0.3)';
|
||||
symbol = 'A';
|
||||
} else if (type === 'destination') {
|
||||
bgColor = '#dc2626';
|
||||
ringColor = 'rgba(220, 38, 38, 0.3)';
|
||||
symbol = 'B';
|
||||
} else if (type === 'driver') {
|
||||
bgColor = '#0284c7';
|
||||
ringColor = 'rgba(2, 132, 199, 0.35)';
|
||||
symbol = '🚕';
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div style="
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: ${bgColor};
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 0 4px ${ringColor}, 0 4px 10px rgba(0,0,0,0.2);
|
||||
">
|
||||
${symbol}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return L.divIcon({
|
||||
html,
|
||||
className: 'custom-leaflet-marker-icon',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
};
|
||||
|
||||
export default function MapView({
|
||||
center,
|
||||
zoom = 13,
|
||||
markers = [],
|
||||
polyline = [],
|
||||
className,
|
||||
onMapClick,
|
||||
}: MapViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markerLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const polylineLayerRef = useRef<L.Polyline | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
}).setView(center, zoom);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(map);
|
||||
|
||||
markerLayerRef.current = L.layerGroup().addTo(map);
|
||||
mapRef.current = map;
|
||||
|
||||
if (onMapClick) {
|
||||
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
});
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.setView(center, zoom);
|
||||
}
|
||||
}, [center[0], center[1], zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
const layer = markerLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
|
||||
markers.forEach((m) => {
|
||||
const icon = createCustomIcon(m.type);
|
||||
const marker = L.marker([m.lat, m.lng], { icon });
|
||||
if (m.label) {
|
||||
marker.bindPopup(
|
||||
`<div style="font-family: sans-serif; font-weight: 600; font-size: 13px; color: #0f172a;">${m.label}</div>`
|
||||
);
|
||||
}
|
||||
layer.addLayer(marker);
|
||||
});
|
||||
}, [markers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
|
||||
if (polylineLayerRef.current) {
|
||||
polylineLayerRef.current.remove();
|
||||
polylineLayerRef.current = null;
|
||||
}
|
||||
|
||||
if (polyline && polyline.length > 0) {
|
||||
polylineLayerRef.current = L.polyline(polyline, {
|
||||
color: '#0284c7',
|
||||
weight: 5,
|
||||
opacity: 0.85,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}).addTo(mapRef.current);
|
||||
}
|
||||
}, [polyline]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className || 'h-[450px] w-full rounded-xl border border-slate-300 shadow-sm bg-slate-100 overflow-hidden'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api } from '../api';
|
||||
import { Bell, Car, History, LogIn, LogOut, Menu, Navigation, ShieldCheck, Users, X, Zap } from 'lucide-react';
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const { data: notifData } = useQuery({
|
||||
queryKey: ['notification-count'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/notifications/count');
|
||||
return res.data;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const unreadCount = typeof notifData?.count === 'number' ? notifData.count : 0;
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
const navLinks = isAuthenticated
|
||||
? [
|
||||
{ path: '/app/ride', label: 'Passenger', icon: Navigation },
|
||||
{ path: '/app/driver', label: 'Driver Workstation', icon: Car },
|
||||
{ path: '/app/circles', label: 'Circles', icon: Users },
|
||||
{ path: '/app/history', label: 'History', icon: History },
|
||||
{ path: '/app/onboarding', label: 'Safety Setup', icon: ShieldCheck },
|
||||
]
|
||||
: [
|
||||
{ path: '/', label: 'Overview', icon: Zap },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 h-16 w-full border-b border-border bg-slate-900 text-slate-100 shadow-sm backdrop-blur">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/" className="flex items-center gap-2.5 transition-opacity hover:opacity-90">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-sky-600 font-bold text-white shadow-md shadow-sky-600/30">
|
||||
<Navigation className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold tracking-tight text-white">RideFlow</span>
|
||||
<span className="-mt-1 text-[10px] font-medium tracking-wider text-sky-400 uppercase">Urban Transit</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex md:items-center md:gap-1">
|
||||
{navLinks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-slate-800 text-sky-400 font-semibold shadow-inner'
|
||||
: 'text-slate-300 hover:bg-slate-800/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex md:items-center md:gap-4">
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className="relative rounded-full p-2 text-slate-300 hover:bg-slate-800 hover:text-white transition-colors"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-[10px] font-bold text-white shadow">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 border-l border-slate-800 pl-3">
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-xs font-semibold text-slate-200">
|
||||
{user?.full_name || user?.email?.split('@')[0] || 'User'}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
{user?.role || 'passenger'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-300 hover:bg-slate-700 hover:text-white transition-all shadow-sm"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span>Exit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="flex items-center gap-2 rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-500 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
<span>Sign In</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="rounded-md p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<div className="border-b border-slate-800 bg-slate-900 px-4 pt-2 pb-4 shadow-xl md:hidden">
|
||||
<div className="space-y-1">
|
||||
{navLinks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-base font-medium ${
|
||||
active
|
||||
? 'bg-slate-800 text-sky-400 font-semibold'
|
||||
: 'text-slate-300 hover:bg-slate-800/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 border-t border-slate-800 pt-4">
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-200">
|
||||
{user?.full_name || user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 capitalize">Role: {user?.role || 'passenger'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-2 text-xs font-medium text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-sky-600 py-2.5 text-center text-sm font-semibold text-white shadow hover:bg-sky-500"
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
<span>Sign In to RideFlow</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/NotificationBell.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Bell, Car, Check, CheckCheck, MapPin, X } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
type RideItem = {
|
||||
id: string;
|
||||
status: string;
|
||||
destination_location?: string;
|
||||
pickup_location?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [readIds, setReadIds] = useState<string[]>([]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const { data: countData } = useQuery({
|
||||
queryKey: ['notification-count'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/notifications/count');
|
||||
return res.data;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 15000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const { data: ridesData } = useQuery({
|
||||
queryKey: ['notification-rides'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/rides/my');
|
||||
return res.data;
|
||||
},
|
||||
enabled: isAuthenticated && isOpen,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
const rawCount = typeof countData?.count === 'number' ? countData.count : 0;
|
||||
const rides: RideItem[] = Array.isArray(ridesData?.rides) ? ridesData.rides : [];
|
||||
|
||||
const notifications = rides.slice(0, 5).map((ride) => {
|
||||
const isCompleted = ride.status === 'completed';
|
||||
const isAccepted = ride.status === 'accepted';
|
||||
const isPickedUp = ride.status === 'picked_up';
|
||||
const isCancelled = ride.status === 'cancelled';
|
||||
|
||||
let title = 'Ride Status Update';
|
||||
let body = `Trip to ${ride.destination_location || 'destination'} is ${ride.status}`;
|
||||
let type = 'info';
|
||||
|
||||
if (isCompleted) {
|
||||
title = 'Trip Completed';
|
||||
body = `Your ride to ${ride.destination_location || 'destination'} is completed.`;
|
||||
type = 'success';
|
||||
} else if (isPickedUp) {
|
||||
title = 'Passenger Picked Up';
|
||||
body = `En route to ${ride.destination_location || 'destination'}.`;
|
||||
type = 'info';
|
||||
} else if (isAccepted) {
|
||||
title = 'Driver Assigned';
|
||||
body = `Driver assigned for pickup at ${ride.pickup_location || 'origin'}.`;
|
||||
type = 'warning';
|
||||
} else if (isCancelled) {
|
||||
title = 'Trip Cancelled';
|
||||
body = `Ride request to ${ride.destination_location || 'destination'} was cancelled.`;
|
||||
type = 'destructive';
|
||||
}
|
||||
|
||||
return {
|
||||
id: ride.id,
|
||||
title,
|
||||
body,
|
||||
type,
|
||||
status: ride.status,
|
||||
link: '/app/ride',
|
||||
time: ride.created_at
|
||||
? new Date(ride.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: 'Recently',
|
||||
};
|
||||
});
|
||||
|
||||
const unreadItems = notifications.filter((n) => !readIds.includes(n.id));
|
||||
const displayCount = Math.max(rawCount, unreadItems.length);
|
||||
|
||||
const markAllRead = () => {
|
||||
setReadIds(notifications.map((n) => n.id));
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
|
||||
};
|
||||
|
||||
const handleItemClick = (link: string, id: string) => {
|
||||
if (!readIds.includes(id)) {
|
||||
setReadIds((prev) => [...prev, id]);
|
||||
}
|
||||
setIsOpen(false);
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={panelRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative flex items-center justify-center w-10 h-10 rounded-full border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 hover:text-slate-900 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
aria-label="View notifications"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{displayCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-sky-600 px-1 text-[11px] font-bold text-white shadow-sm ring-2 ring-white">
|
||||
{displayCount > 99 ? '99+' : displayCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 sm:w-96 rounded-xl border border-slate-200 bg-white shadow-2xl z-50 overflow-hidden text-slate-900 animate-in fade-in zoom-in-95 duration-150">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="font-semibold text-sm text-slate-900">Notifications</h3>
|
||||
{displayCount > 0 && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 font-medium">
|
||||
{displayCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadItems.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
className="text-xs font-medium text-sky-600 hover:text-sky-800 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
Read all
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-100">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<Bell className="w-8 h-8 mx-auto text-slate-300 mb-2" />
|
||||
<p className="text-sm font-medium text-slate-700">No new notifications</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Updates on your active rides and activities will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((item) => {
|
||||
const isRead = readIds.includes(item.id);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item.link, item.id)}
|
||||
className={`w-full text-left p-3.5 flex items-start gap-3 hover:bg-slate-50 transition-colors ${
|
||||
isRead ? 'opacity-70' : 'bg-sky-50/30'
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5 p-2 rounded-lg bg-slate-100 text-slate-700 shrink-0">
|
||||
{item.type === 'success' ? (
|
||||
<Check className="w-4 h-4 text-emerald-600" />
|
||||
) : item.type === 'warning' ? (
|
||||
<Car className="w-4 h-4 text-amber-600" />
|
||||
) : (
|
||||
<MapPin className="w-4 h-4 text-sky-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-slate-900 truncate">{item.title}</p>
|
||||
<span className="text-[10px] text-slate-400 shrink-0">{item.time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 line-clamp-2 mt-0.5">{item.body}</p>
|
||||
</div>
|
||||
{!isRead && <span className="w-2 h-2 rounded-full bg-sky-600 shrink-0 mt-1.5" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-slate-100 bg-slate-50 text-center">
|
||||
<Link
|
||||
to="/app/history"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-xs font-medium text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
View all ride history →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/StarRating.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number;
|
||||
onChange?: (rating: number) => void;
|
||||
size?: number;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
size = 20,
|
||||
readonly = false,
|
||||
className = '',
|
||||
}: StarRatingProps) {
|
||||
const [hover, setHover] = useState(0);
|
||||
const interactive = !readonly && !!onChange;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = (hover || value) >= star;
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={!interactive}
|
||||
onClick={() => interactive && onChange && onChange(star)}
|
||||
onMouseEnter={() => interactive && setHover(star)}
|
||||
onMouseLeave={() => interactive && setHover(0)}
|
||||
className={`${interactive ? 'cursor-pointer transition-transform hover:scale-110' : 'cursor-default'} rounded p-0.5 focus:outline-none focus-visible:ring-1 focus-visible:ring-sky-500`}
|
||||
aria-label={`${star} star${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
size={size}
|
||||
className={filled ? 'fill-amber-400 text-amber-500' : 'fill-none text-slate-300 dark:text-slate-600'}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StarRating;
|
||||
410
frontend/src/components/features/AdminRideLogsTable.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight, Car, CheckCircle2, Clock, MapPin, Navigation, Phone, RefreshCw, Search, ShieldAlert, Star, User, XCircle } from 'lucide-react';
|
||||
import { adminApi } from '../../api';
|
||||
|
||||
interface RideRecord {
|
||||
id: string;
|
||||
passenger_email: string;
|
||||
passenger_name?: string | null;
|
||||
passenger_phone?: string | null;
|
||||
driver_email?: string | null;
|
||||
driver_name?: string | null;
|
||||
driver_phone?: string | null;
|
||||
pickup_location: string;
|
||||
pickup_lat: number;
|
||||
pickup_lng: number;
|
||||
destination_location: string;
|
||||
destination_lat: number;
|
||||
destination_lng: number;
|
||||
status: 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||
driver_lat?: number | null;
|
||||
driver_lng?: number | null;
|
||||
rating?: number | null;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||
|
||||
export default function AdminRideLogsTable() {
|
||||
const [selectedStatus, setSelectedStatus] = useState<StatusFilter>('all');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['admin-rides'],
|
||||
queryFn: async () => {
|
||||
const res = await adminApi.get('/api/admin/rides');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const rawRides: RideRecord[] = Array.isArray(data?.rides) ? data.rides : [];
|
||||
|
||||
const filteredRides = useMemo(() => {
|
||||
return rawRides.filter((ride) => {
|
||||
const matchesStatus = selectedStatus === 'all' || ride.status === selectedStatus;
|
||||
|
||||
const term = String((String(searchTerm ?? '').toLowerCase()) ?? '').trim();
|
||||
if (!term) return matchesStatus;
|
||||
|
||||
const pName = String(ride.passenger_name ?? '').toLowerCase();
|
||||
const pEmail = String(ride.passenger_email ?? '').toLowerCase();
|
||||
const dName = String(ride.driver_name ?? '').toLowerCase();
|
||||
const dEmail = String(ride.driver_email ?? '').toLowerCase();
|
||||
const pickup = String(ride.pickup_location ?? '').toLowerCase();
|
||||
const dest = String(ride.destination_location ?? '').toLowerCase();
|
||||
const rideId = String(ride.id ?? '').toLowerCase();
|
||||
|
||||
const matchesSearch =
|
||||
pName.includes(term) ||
|
||||
pEmail.includes(term) ||
|
||||
dName.includes(term) ||
|
||||
dEmail.includes(term) ||
|
||||
pickup.includes(term) ||
|
||||
dest.includes(term) ||
|
||||
rideId.includes(term);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
}, [rawRides, selectedStatus, searchTerm]);
|
||||
|
||||
const totalPages = Math.ceil(filteredRides.length / pageSize) || 1;
|
||||
const paginatedRides = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredRides.slice(start, start + pageSize);
|
||||
}, [filteredRides, currentPage, pageSize]);
|
||||
|
||||
const getStatusBadge = (status: RideRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'requested':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 border border-sky-300">
|
||||
<Clock className="w-3.5 h-3.5 text-sky-600" />
|
||||
Requested
|
||||
</span>
|
||||
);
|
||||
case 'accepted':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-300">
|
||||
<Car className="w-3.5 h-3.5 text-amber-600" />
|
||||
Accepted
|
||||
</span>
|
||||
);
|
||||
case 'picked_up':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-teal-100 text-teal-800 border border-teal-300">
|
||||
<Navigation className="w-3.5 h-3.5 text-teal-600" />
|
||||
In Transit
|
||||
</span>
|
||||
);
|
||||
case 'completed':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||
Completed
|
||||
</span>
|
||||
);
|
||||
case 'cancelled':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-rose-100 text-rose-800 border border-rose-300">
|
||||
<XCircle className="w-3.5 h-3.5 text-rose-600" />
|
||||
Cancelled
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 border border-slate-300">
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<Car className="w-5 h-5 text-sky-600" />
|
||||
System Ride Logs
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Real-time audit log of all system requests, assignments, and completed trips
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-semibold text-slate-700 bg-slate-100 hover:bg-slate-200 border border-slate-300 rounded-lg transition-colors cursor-pointer disabled:opacity-50 self-start md:self-auto"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||
{isRefetching ? 'Refreshing...' : 'Refresh Logs'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-100">
|
||||
{(
|
||||
[
|
||||
{ id: 'all', label: 'All Rides', count: rawRides.length },
|
||||
{ id: 'requested', label: 'Requested', count: rawRides.filter((r) => r.status === 'requested').length },
|
||||
{ id: 'accepted', label: 'Accepted', count: rawRides.filter((r) => r.status === 'accepted').length },
|
||||
{ id: 'picked_up', label: 'Picked Up', count: rawRides.filter((r) => r.status === 'picked_up').length },
|
||||
{ id: 'completed', label: 'Completed', count: rawRides.filter((r) => r.status === 'completed').length },
|
||||
{ id: 'cancelled', label: 'Cancelled', count: rawRides.filter((r) => r.status === 'cancelled').length },
|
||||
] as const
|
||||
).map((tab) => {
|
||||
const active = selectedStatus === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setSelectedStatus(tab.id);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
active
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded-full text-[10px] ${
|
||||
active ? 'bg-slate-800 text-slate-200' : 'bg-slate-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Search by passenger, driver, locations, or ride ID..."
|
||||
className="w-full pl-9 pr-4 py-2 text-xs bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent text-slate-900 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center text-slate-500 space-y-3">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-slate-400" />
|
||||
<p className="text-xs font-medium">Loading system ride logs...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="p-8 text-center text-rose-600 space-y-3 bg-rose-50/50">
|
||||
<ShieldAlert className="w-8 h-8 mx-auto text-rose-500" />
|
||||
<p className="text-xs font-semibold">
|
||||
{String((error as Error)?.message || 'Failed to retrieve system ride logs.')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="px-4 py-1.5 text-xs font-medium text-rose-700 bg-rose-100 hover:bg-rose-200 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : paginatedRides.length === 0 ? (
|
||||
<div className="p-12 text-center text-slate-500 space-y-2">
|
||||
<Car className="w-8 h-8 mx-auto text-slate-300" />
|
||||
<p className="text-sm font-semibold text-slate-700">No ride records found</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{searchTerm || selectedStatus !== 'all'
|
||||
? 'Try adjusting your search query or status filter.'
|
||||
: 'No rides have been requested in the system yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-slate-700 border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-100/80 border-b border-slate-200 text-slate-600 font-semibold uppercase tracking-wider text-[11px]">
|
||||
<th className="py-3 px-4">Ride ID</th>
|
||||
<th className="py-3 px-4">Passenger</th>
|
||||
<th className="py-3 px-4">Driver</th>
|
||||
<th className="py-3 px-4">Route (Pickup → Destination)</th>
|
||||
<th className="py-3 px-4">Status</th>
|
||||
<th className="py-3 px-4">Rating</th>
|
||||
<th className="py-3 px-4">Requested Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{paginatedRides.map((ride) => {
|
||||
const passengerDisplay =
|
||||
ride.passenger_name ?? ride.passenger_email.split('@')[0];
|
||||
const driverDisplay = ride.driver_name
|
||||
? ride.driver_name
|
||||
: ride.driver_email
|
||||
? ride.driver_email.split('@')[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr key={ride.id} className="hover:bg-slate-50/80 transition-colors">
|
||||
|
||||
<td className="py-3 px-4 font-mono font-medium text-slate-900 whitespace-nowrap">
|
||||
<span className="bg-slate-100 text-slate-800 px-2 py-0.5 rounded text-[11px]">
|
||||
#{ride.id.slice(0, 8)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-slate-900 flex items-center gap-1">
|
||||
<User className="w-3 h-3 text-slate-400 inline" />
|
||||
{passengerDisplay}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500">{ride.passenger_email}</span>
|
||||
{ride.passenger_phone && (
|
||||
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<Phone className="w-2.5 h-2.5" />
|
||||
{ride.passenger_phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
<td className="py-3 px-4">
|
||||
{driverDisplay ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-slate-900 flex items-center gap-1">
|
||||
<Car className="w-3 h-3 text-emerald-600 inline" />
|
||||
{driverDisplay}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500">{ride.driver_email}</span>
|
||||
{ride.driver_phone && (
|
||||
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<Phone className="w-2.5 h-2.5" />
|
||||
{ride.driver_phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="italic text-slate-400 text-[11px]">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
|
||||
<td className="py-3 px-4 max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-start gap-1.5 text-slate-800">
|
||||
<MapPin className="w-3.5 h-3.5 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<span className="truncate font-medium" title={ride.pickup_location}>
|
||||
{ride.pickup_location}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-slate-400 pl-1">
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5 text-slate-800">
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-600 shrink-0 mt-0.5" />
|
||||
<span className="truncate font-medium" title={ride.destination_location}>
|
||||
{ride.destination_location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
<td className="py-3 px-4 whitespace-nowrap">
|
||||
{getStatusBadge(ride.status)}
|
||||
</td>
|
||||
|
||||
|
||||
<td className="py-3 px-4 whitespace-nowrap">
|
||||
{ride.rating ? (
|
||||
<div className="flex items-center gap-1 font-semibold text-amber-700 bg-amber-50 px-2 py-0.5 rounded border border-amber-200 inline-flex">
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
<span>{ride.rating} / 5</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 text-[11px]">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
|
||||
<td className="py-3 px-4 whitespace-nowrap text-slate-500 font-medium">
|
||||
{formatDate(ride.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!isLoading && !isError && filteredRides.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 border-t border-slate-200 text-xs text-slate-600">
|
||||
<div>
|
||||
Showing <span className="font-semibold text-slate-900">{(currentPage - 1) * pageSize + 1}</span> to{' '}
|
||||
<span className="font-semibold text-slate-900">
|
||||
{Math.min(currentPage * pageSize, filteredRides.length)}
|
||||
</span>{' '}
|
||||
of <span className="font-semibold text-slate-900">{filteredRides.length}</span> records
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2.5 py-1 font-medium bg-white border border-slate-300 rounded hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-white cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-2 font-medium text-slate-700">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2.5 py-1 font-medium bg-white border border-slate-300 rounded hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-white cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/features/AdminStatsCards.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi, api } from '../../api';
|
||||
import { Activity, AlertTriangle, Car, CheckCircle2, RefreshCw, Route, Users } from 'lucide-react';
|
||||
|
||||
export default function AdminStatsCards() {
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-stats'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await adminApi.get('/api/admin/stats');
|
||||
return res.data;
|
||||
} catch {
|
||||
const res = await api.get('/api/admin/stats');
|
||||
return res.data;
|
||||
}
|
||||
},
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-28 rounded-xl bg-slate-100 dark:bg-slate-800 animate-pulse border border-slate-200 dark:border-slate-800 p-4" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" />
|
||||
<span className="text-sm font-medium">Failed to load platform telemetry stats.</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-red-100 hover:bg-red-200 text-red-900 dark:bg-red-900 dark:hover:bg-red-800 dark:text-red-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = data?.stats || {
|
||||
total_users: 0,
|
||||
active_drivers: 0,
|
||||
total_rides: 0,
|
||||
active_rides: 0,
|
||||
completed_rides: 0,
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: stats.total_users ?? 0,
|
||||
icon: Users,
|
||||
color: 'text-slate-900 dark:text-slate-100',
|
||||
bg: 'bg-slate-100 dark:bg-slate-800',
|
||||
description: 'Registered passengers & drivers',
|
||||
},
|
||||
{
|
||||
title: 'Online Drivers',
|
||||
value: stats.active_drivers ?? 0,
|
||||
icon: Car,
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
description: 'Available for instant dispatch',
|
||||
},
|
||||
{
|
||||
title: 'Total System Rides',
|
||||
value: stats.total_rides ?? 0,
|
||||
icon: Route,
|
||||
color: 'text-sky-600 dark:text-sky-400',
|
||||
bg: 'bg-sky-50 dark:bg-sky-950/40',
|
||||
description: 'All-time ride requests',
|
||||
},
|
||||
{
|
||||
title: 'Active Ongoing',
|
||||
value: stats.active_rides ?? 0,
|
||||
icon: Activity,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
description: 'Requested, accepted, or picked up',
|
||||
},
|
||||
{
|
||||
title: 'Completed Trips',
|
||||
value: stats.completed_rides ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: 'text-teal-600 dark:text-teal-400',
|
||||
bg: 'bg-teal-50 dark:bg-teal-950/40',
|
||||
description: 'Successfully finished rides',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.title}
|
||||
className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4 shadow-sm flex flex-col justify-between transition-all hover:border-slate-300 dark:hover:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
{card.title}
|
||||
</span>
|
||||
<div className={`p-2 rounded-lg ${card.bg}`}>
|
||||
<Icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||
{(card.value ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 line-clamp-1">
|
||||
{card.description}
|
||||
</p> // Fixed text formatting
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/features/AdminUserTable.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi, api } from '../../api';
|
||||
import { AlertCircle, Car, Filter, RefreshCw, Search, Shield, User, Users } from 'lucide-react';
|
||||
|
||||
export default function AdminUserTable() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await adminApi.get('/api/admin/users');
|
||||
return res.data;
|
||||
} catch {
|
||||
const res = await api.get('/api/admin/users');
|
||||
return res.data;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const rawUsers = Array.isArray(data?.users) ? data.users : [];
|
||||
|
||||
const filteredUsers = rawUsers.filter((u: any) => {
|
||||
const name = String(u.full_name ?? u.username ?? '').toLowerCase();
|
||||
const email = String(u.email ?? '').toLowerCase();
|
||||
const phone = String(u.phone ?? '').toLowerCase();
|
||||
const citizenId = String(u.citizen_id ?? '').toLowerCase();
|
||||
const query = String((searchQuery.toLowerCase()) ?? '').trim();
|
||||
const matchesSearch =
|
||||
!query ||
|
||||
name.includes(query) ||
|
||||
email.includes(query) ||
|
||||
phone.includes(query) ||
|
||||
citizenId.includes(query);
|
||||
|
||||
const role = String(u.role ?? '').toLowerCase();
|
||||
const matchesRole = roleFilter === 'all' || role === roleFilter;
|
||||
|
||||
return matchesSearch && matchesRole;
|
||||
});
|
||||
|
||||
const getRoleBadge = (roleStr: string) => {
|
||||
const role = String(roleStr ?? '').toLowerCase();
|
||||
if (role === 'admin') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300 border border-teal-200 dark:border-teal-800">
|
||||
<Shield className="w-3 h-3" />
|
||||
Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (role === 'driver') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
||||
<Car className="w-3 h-3" />
|
||||
Driver
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
|
||||
<User className="w-3 h-3" />
|
||||
Passenger
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return isNaN(date.getTime()) ? dateStr : date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email, phone, or citizen ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 dark:focus:ring-slate-100 text-slate-900 dark:text-white placeholder-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 px-1 font-medium">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
<span>Role:</span>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 dark:focus:ring-slate-100 text-slate-900 dark:text-white font-medium cursor-pointer"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="passenger">Passengers</option>
|
||||
<option value="driver">Drivers</option>
|
||||
<option value="admin">Admins</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh Directory"
|
||||
className="p-2 text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center space-y-3">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto text-slate-400" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Loading registered user directory...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="p-8 text-center space-y-3">
|
||||
<AlertCircle className="h-8 w-8 text-red-500 mx-auto" />
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">Unable to retrieve user records.</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold rounded-lg bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="p-12 text-center space-y-3">
|
||||
<Users className="h-10 w-10 text-slate-300 dark:text-slate-600 mx-auto" />
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-white">No users found</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm mx-auto">
|
||||
{searchQuery || roleFilter !== 'all'
|
||||
? 'No registered users match your search criteria. Try adjusting filters.'
|
||||
: 'There are currently no registered users in the database.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/50 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
<th className="py-3.5 px-4">User</th>
|
||||
<th className="py-3.5 px-4">Role</th>
|
||||
<th className="py-3.5 px-4">Contact Info</th>
|
||||
<th className="py-3.5 px-4">Citizen ID</th>
|
||||
<th className="py-3.5 px-4">Joined Date</th>
|
||||
<th className="py-3.5 px-4 text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800 text-sm">
|
||||
{filteredUsers.map((u: any) => {
|
||||
const name = u.full_name || u.username || u.email?.split('@')[0] || 'User';
|
||||
const email = u.email || 'N/A';
|
||||
const phone = u.phone || 'N/A';
|
||||
const citizenId = u.citizen_id || 'N/A';
|
||||
|
||||
return (
|
||||
<tr key={u.id || u.email} className="hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-colors">
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-center text-slate-700 dark:text-slate-300 font-bold text-xs uppercase shrink-0">
|
||||
{name.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-slate-900 dark:text-white">{name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">{email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
{getRoleBadge(u.role)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<div className="text-slate-900 dark:text-slate-100 font-mono text-xs">{phone}</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<div className="text-slate-600 dark:text-slate-400 font-mono text-xs">{citizenId}</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap text-slate-600 dark:text-slate-400 text-xs">
|
||||
{formatDate(u.created_at)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap text-right">
|
||||
{u.is_banned ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300">
|
||||
Banned
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
<span>Showing {filteredUsers.length} of {rawUsers.length} total registered users</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
459
frontend/src/components/features/CircleActivityPlanner.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { CompanionCircle, CompanionActivity, RsvpStatus } from '../../types/ride';
|
||||
import { AlertCircle, Calendar, CheckCircle, Clock, Loader2, MapPin, Plus, Sparkles, UserCheck, Users, XCircle } from 'lucide-react';
|
||||
|
||||
export default function CircleActivityPlanner() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCircleId, setSelectedCircleId] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
|
||||
// Form State
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
const [startsAt, setStartsAt] = useState('');
|
||||
const [maxAttendees, setMaxAttendees] = useState('');
|
||||
|
||||
// Fetch My Circles
|
||||
const { data: circlesData, isLoading: isLoadingCircles, isError: isCirclesError } = useQuery({
|
||||
queryKey: ['companion-circles'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/companion/circles/my');
|
||||
return res.data?.circles as CompanionCircle[] || [];
|
||||
},
|
||||
});
|
||||
|
||||
const circles = Array.isArray(circlesData) ? circlesData : [];
|
||||
const activeCircle = circles.find((c) => c.id === selectedCircleId) || circles[0] || null;
|
||||
const activeCircleId = activeCircle?.id;
|
||||
|
||||
// Find user's member status in active circle
|
||||
const userMember = activeCircle?.members?.find((m) => m.user_email === user?.email);
|
||||
const userStatus = userMember?.status || 'invited';
|
||||
|
||||
// Fetch Activities for Active Circle
|
||||
const { data: activitiesData, isLoading: isLoadingActivities } = useQuery({
|
||||
queryKey: ['companion-activities', activeCircleId],
|
||||
queryFn: async () => {
|
||||
if (!activeCircleId) return [];
|
||||
const res = await api.get(`/api/companion/circles/${activeCircleId}/activities`);
|
||||
return res.data?.activities as CompanionActivity[] || [];
|
||||
},
|
||||
enabled: !!activeCircleId && userStatus === 'accepted',
|
||||
});
|
||||
|
||||
const activities = Array.isArray(activitiesData) ? activitiesData : [];
|
||||
|
||||
// Mutation: Respond to Invitation
|
||||
const respondMutation = useMutation({
|
||||
mutationFn: async ({ circleId, decision }: { circleId: string; decision: 'accepted' | 'declined' }) => {
|
||||
const res = await api.post(`/api/companion/circles/${circleId}/respond`, { decision });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionSuccess('Circle invite response recorded.');
|
||||
queryClient.invalidateQueries({ queryKey: ['companion-circles'] });
|
||||
setTimeout(() => setActionSuccess(null), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setFormError(err?.response?.data?.msg || 'Failed to submit response.');
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: Create Activity
|
||||
const createActivityMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!activeCircleId) throw new Error('No active circle selected');
|
||||
const payload = {
|
||||
title: String(title).trim(),
|
||||
description: String(description).trim(),
|
||||
location_label: String(locationLabel).trim(),
|
||||
starts_at: startsAt,
|
||||
max_attendees: maxAttendees ? Number(maxAttendees) : undefined,
|
||||
};
|
||||
const res = await api.post(`/api/companion/circles/${activeCircleId}/activities`, payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionSuccess('Activity scheduled successfully!');
|
||||
setShowCreateModal(false);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setLocationLabel('');
|
||||
setStartsAt('');
|
||||
setMaxAttendees('');
|
||||
queryClient.invalidateQueries({ queryKey: ['companion-activities', activeCircleId] });
|
||||
setTimeout(() => setActionSuccess(null), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setFormError(err?.response?.data?.msg || 'Failed to create activity.');
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: RSVP to Activity
|
||||
const rsvpMutation = useMutation({
|
||||
mutationFn: async ({ activityId, status }: { activityId: string; status: RsvpStatus }) => {
|
||||
const res = await api.post(`/api/companion/activities/${activityId}/rsvp`, { status });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionSuccess('RSVP updated successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['companion-activities', activeCircleId] });
|
||||
setTimeout(() => setActionSuccess(null), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setFormError(err?.response?.data?.msg || 'Failed to update RSVP.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!title || !locationLabel || !startsAt) {
|
||||
setFormError('Please fill in title, location, and start time.');
|
||||
return;
|
||||
}
|
||||
createActivityMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{actionSuccess && (
|
||||
<div className="p-3 text-sm bg-emerald-500/10 border border-emerald-500/30 text-emerald-600 rounded-lg flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{actionSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="p-3 text-sm bg-destructive/10 border border-destructive/30 text-destructive rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<div className="space-y-4 md:col-span-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-sky-600" />
|
||||
<span>My Companion Circles</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoadingCircles ? (
|
||||
<div className="p-8 text-center bg-white rounded-xl border border-slate-200">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-sky-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-slate-500">Loading circles...</p>
|
||||
</div>
|
||||
) : isCirclesError ? (
|
||||
<div className="p-4 text-center bg-destructive/5 text-destructive rounded-xl border border-destructive/20 text-sm">
|
||||
Failed to load circles.
|
||||
</div>
|
||||
) : circles.length === 0 ? (
|
||||
<div className="p-6 text-center bg-white rounded-xl border border-slate-200">
|
||||
<Users className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-slate-700">No Circles Found</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
You are not currently part of any companion safety circle.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{circles.map((circle) => {
|
||||
const myMember = circle.members?.find((m) => m.user_email === user?.email);
|
||||
const isSelected = (selectedCircleId || activeCircleId) === circle.id;
|
||||
const status = myMember?.status || 'invited';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={circle.id}
|
||||
onClick={() => setSelectedCircleId(circle.id)}
|
||||
className={`p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-slate-900 text-white border-slate-900 shadow-sm'
|
||||
: 'bg-white text-slate-900 border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-semibold text-base leading-tight">{circle.name}</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full font-medium capitalize ${
|
||||
isSelected
|
||||
? 'bg-sky-500/20 text-sky-300 border border-sky-400/30'
|
||||
: 'bg-slate-100 text-slate-700 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
{circle.cadence}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs flex items-center gap-2 opacity-80 mb-3">
|
||||
<span>Members: {circle.members?.length || 0}</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">Status: {circle.status}</span>
|
||||
</div>
|
||||
|
||||
{/* Invitation Response Buttons if pending */}
|
||||
{status === 'invited' && (
|
||||
<div className="pt-2 border-t border-slate-200/20 flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => respondMutation.mutate({ circleId: circle.id, decision: 'accepted' })}
|
||||
disabled={respondMutation.isPending}
|
||||
className="flex-1 py-1.5 px-3 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold rounded-md flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
<span>Accept</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respondMutation.mutate({ circleId: circle.id, decision: 'declined' })}
|
||||
disabled={respondMutation.isPending}
|
||||
className="flex-1 py-1.5 px-3 bg-slate-700 hover:bg-slate-800 text-white text-xs font-semibold rounded-md flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>Decline</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4 md:col-span-2">
|
||||
{!activeCircle ? (
|
||||
<div className="p-12 text-center bg-white rounded-xl border border-slate-200 text-slate-500">
|
||||
Select a circle from the directory to view or plan activities.
|
||||
</div>
|
||||
) : userStatus !== 'accepted' ? (
|
||||
<div className="p-8 text-center bg-amber-500/10 border border-amber-500/20 text-amber-900 rounded-xl space-y-3">
|
||||
<AlertCircle className="w-8 h-8 text-amber-600 mx-auto" />
|
||||
<h3 className="font-semibold text-lg">Invitation Pending or Declined</h3>
|
||||
<p className="text-sm text-amber-800 max-w-md mx-auto">
|
||||
You must accept the invitation to <strong>{activeCircle.name}</strong> to view and participate in recurring safety activities.
|
||||
</p>
|
||||
{userStatus === 'invited' && (
|
||||
<button
|
||||
onClick={() => respondMutation.mutate({ circleId: activeCircle.id, decision: 'accepted' })}
|
||||
disabled={respondMutation.isPending}
|
||||
className="mt-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg inline-flex items-center gap-2"
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
<span>Accept Invitation Now</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-slate-900">{activeCircle.name}</h2>
|
||||
<span className="px-2.5 py-0.5 text-xs font-semibold bg-emerald-100 text-emerald-800 rounded-full">
|
||||
Joined
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Recurring cadence: {activeCircle.cadence} • Members: {activeCircle.members?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-white text-sm font-semibold rounded-lg flex items-center gap-2 shadow-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Activity</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{isLoadingActivities ? (
|
||||
<div className="p-8 text-center bg-white rounded-xl border border-slate-200">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-sky-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-slate-500">Fetching circle activities...</p>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="p-12 text-center bg-white rounded-xl border border-slate-200 space-y-3">
|
||||
<Sparkles className="w-8 h-8 text-slate-400 mx-auto" />
|
||||
<h3 className="text-base font-semibold text-slate-800">No Activities Planned Yet</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
Start coordinating group rides or meetup activities with your companion circle members.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-semibold rounded-lg inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Schedule First Activity</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activities.map((act) => (
|
||||
<div key={act.id} className="p-4 bg-white rounded-xl border border-slate-200 space-y-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 text-base">{act.title}</h3>
|
||||
{act.description && (
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">{act.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-slate-100 text-slate-700 border border-slate-200 rounded-md capitalize shrink-0">
|
||||
{act.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-slate-600 bg-slate-50 p-2.5 rounded-lg border border-slate-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="truncate">{act.location_label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span>{new Date(act.starts_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100 text-xs">
|
||||
<span className="text-slate-500 font-medium">
|
||||
RSVP Status:
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(['going', 'maybe', 'not_going'] as RsvpStatus[]).map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
onClick={() => rsvpMutation.mutate({ activityId: act.id, status: st })}
|
||||
disabled={rsvpMutation.isPending}
|
||||
className={`px-3 py-1 rounded-md capitalize font-semibold transition-colors ${
|
||||
st === 'going'
|
||||
? 'hover:bg-emerald-600 hover:text-white bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: st === 'maybe'
|
||||
? 'hover:bg-amber-600 hover:text-white bg-amber-50 text-amber-700 border border-amber-200'
|
||||
: 'hover:bg-slate-700 hover:text-white bg-slate-100 text-slate-600 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
{st.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal: Create Activity */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white w-full max-w-md rounded-2xl border border-slate-200 p-6 space-y-4 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="font-bold text-lg text-slate-900 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-sky-600" />
|
||||
<span>Plan New Activity</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 text-lg font-bold leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateSubmit} className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Activity Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Group Commute to Tech Park"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Location Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={locationLabel}
|
||||
onChange={(e) => setLocationLabel(e.target.value)}
|
||||
placeholder="e.g. Central Station Entrance"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Start Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt}
|
||||
onChange={(e) => setStartsAt(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Additional details or safety instructions..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Max Attendees (Optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxAttendees}
|
||||
onChange={(e) => setMaxAttendees(e.target.value)}
|
||||
placeholder="e.g. 4"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500/20 focus:border-sky-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createActivityMutation.isPending}
|
||||
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-white font-semibold rounded-lg flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
{createActivityMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
<span>Schedule Activity</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/features/DriverAvailabilityToggle.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { AlertCircle, Car, Power } from 'lucide-react';
|
||||
|
||||
interface DriverAvailabilityToggleProps {
|
||||
onStatusChange?: (isOnline: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DriverAvailabilityToggle({ onStatusChange }: DriverAvailabilityToggleProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: profileData, isLoading } = useQuery({
|
||||
queryKey: ['driver-profile'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/driver/profile');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const profile = profileData?.profile;
|
||||
const isOnline = Boolean(profile?.is_available);
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (nextStatus: boolean) => {
|
||||
const res = await api.put('/api/driver/availability', {
|
||||
is_available: nextStatus ? 1 : 0,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['driver-profile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides', 'nearby'] });
|
||||
if (onStatusChange) {
|
||||
onStatusChange(Boolean(data?.profile?.is_available));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(!isOnline);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl animate-pulse">
|
||||
<div className="h-6 w-32 bg-slate-200 dark:bg-slate-800 rounded" />
|
||||
<div className="h-8 w-16 bg-slate-200 dark:bg-slate-800 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/50 rounded-xl flex items-center justify-between text-amber-800 dark:text-amber-300">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600" />
|
||||
<span>Driver vehicle profile required to go online.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2.5 rounded-lg ${
|
||||
isOnline
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Dispatch Availability
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
isOnline
|
||||
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isOnline ? 'ONLINE' : 'OFFLINE'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
{isOnline
|
||||
? 'Receiving nearby trip requests'
|
||||
: 'Turn on to start accepting rides'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
className={`relative inline-flex h-7 w-14 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 ${
|
||||
isOnline ? 'bg-emerald-600' : 'bg-slate-300 dark:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
isOnline ? 'translate-x-7' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Car className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>
|
||||
{profile.vehicle_color} {profile.vehicle_make} {profile.vehicle_model}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-mono font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">
|
||||
{profile.license_plate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/features/DriverProfileForm.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { AlertCircle, Car, CheckCircle2, Loader2, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface DriverProfileFormProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function DriverProfileForm({ onSuccess }: DriverProfileFormProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const [vehicleMake, setVehicleMake] = useState('');
|
||||
const [vehicleModel, setVehicleModel] = useState('');
|
||||
const [licensePlate, setLicensePlate] = useState('');
|
||||
const [vehicleColor, setVehicleColor] = useState('');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const { data: profileData, isLoading: isFetching } = useQuery({
|
||||
queryKey: ['driver-profile'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/driver/profile');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const profile = profileData?.profile;
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setVehicleMake(profile.vehicle_make || '');
|
||||
setVehicleModel(profile.vehicle_model || '');
|
||||
setLicensePlate(profile.license_plate || '');
|
||||
setVehicleColor(profile.vehicle_color || '');
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
vehicle_make: string;
|
||||
vehicle_model: string;
|
||||
license_plate: string;
|
||||
vehicle_color: string;
|
||||
}) => {
|
||||
const res = await api.post('/api/driver/profile', payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['driver-profile'] });
|
||||
if (user) {
|
||||
updateUser({ ...user, role: 'driver' });
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setErrorMsg(err?.response?.data?.msg || err?.message || 'Failed to save vehicle profile.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMsg('');
|
||||
|
||||
if (!String((vehicleMake) ?? '').trim() || !String((vehicleModel) ?? '').trim() || !String((licensePlate) ?? '').trim() || !String((vehicleColor) ?? '').trim()) {
|
||||
setErrorMsg('All vehicle fields are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
saveMutation.mutate({
|
||||
vehicle_make: String((vehicleMake) ?? '').trim(),
|
||||
vehicle_model: String((vehicleModel) ?? '').trim(),
|
||||
license_plate: String((licensePlate) ?? '').trim(),
|
||||
vehicle_color: String((vehicleColor) ?? '').trim(),
|
||||
});
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 bg-white rounded-xl border border-slate-200">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-slate-600" />
|
||||
<span className="ml-2 text-sm text-slate-600">Loading driver vehicle profile...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="w-full md:w-1/3 flex flex-col items-center text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<img
|
||||
src="/assets/application-02-driver-vehicle-form.png"
|
||||
alt="Collect driver vehicle details visual"
|
||||
className="w-full h-36 object-cover rounded-md mb-3"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-slate-900 font-semibold text-base">
|
||||
<Car className="w-5 h-5 text-sky-600" />
|
||||
<span>Driver Vehicle Info</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Passengers identify your vehicle by make, model, color, and plate number during pick-up.
|
||||
</p>
|
||||
{profile && (
|
||||
<div className="mt-3 w-full bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs rounded-md p-2 flex items-center justify-center gap-1.5 font-medium">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
<span>Registered Vehicle Saved</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full md:w-2/3 flex flex-col gap-4">
|
||||
{errorMsg && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Vehicle Make
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vehicleMake}
|
||||
onChange={(e) => setVehicleMake(e.target.value)}
|
||||
placeholder="e.g. Toyota, Honda, Tesla"
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Vehicle Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vehicleModel}
|
||||
onChange={(e) => setVehicleModel(e.target.value)}
|
||||
placeholder="e.g. Camry, Civic, Model 3"
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||
License Plate
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={licensePlate}
|
||||
onChange={(e) => setLicensePlate(e.target.value)}
|
||||
placeholder="e.g. 7XYZ890"
|
||||
className="w-full px-3 py-2 text-sm uppercase tracking-wider border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Vehicle Color
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vehicleColor}
|
||||
onChange={(e) => setVehicleColor(e.target.value)}
|
||||
placeholder="e.g. Silver, Black, White"
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-slate-900 hover:bg-slate-800 text-white font-medium text-sm rounded-md transition-colors disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
<span>{profile ? 'Update Vehicle Details' : 'Save Vehicle Details'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/features/DriverRequestCard.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { AlertCircle, Check, Clock, MapPin, Navigation, RefreshCw, User, X } from 'lucide-react';
|
||||
|
||||
interface DriverRequestCardProps {
|
||||
onAcceptRide?: (ride: any) => void;
|
||||
}
|
||||
|
||||
export default function DriverRequestCard({ onAcceptRide }: DriverRequestCardProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [dismissedIds, setDismissedIds] = useState<string[]>([]);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['rides', 'nearby'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/rides/nearby');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 4000,
|
||||
});
|
||||
|
||||
const rawRides = Array.isArray(data?.rides) ? data.rides : [];
|
||||
const nearbyRides = rawRides.filter(
|
||||
(r: any) => r && r.status === 'requested' && !dismissedIds.includes(r.id)
|
||||
);
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async (rideId: string) => {
|
||||
const res = await api.post(`/api/rides/${rideId}/accept`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (responseData) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rides', 'nearby'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides', 'my'] });
|
||||
if (onAcceptRide && responseData?.ride) {
|
||||
onAcceptRide(responseData.ride);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleDecline = (rideId: string) => {
|
||||
setDismissedIds((prev) => [...prev, rideId]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-5 w-36 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
|
||||
<div className="h-4 w-12 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="h-24 bg-slate-100 dark:bg-slate-800/50 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 text-center">
|
||||
<AlertCircle className="w-8 h-8 text-amber-500 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
Unable to load incoming requests
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-700 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nearbyRides.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center mx-auto mb-3 text-slate-400">
|
||||
<Clock className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Searching for Nearby Requests
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-xs mx-auto">
|
||||
Keep your availability switch ON. Incoming rider dispatch cards will show up here automatically.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
||||
</span>
|
||||
<h2 className="text-sm font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Incoming Requests ({nearbyRides.length})
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">Live Polling</span>
|
||||
</div>
|
||||
|
||||
{nearbyRides.map((ride: any) => {
|
||||
const passengerName =
|
||||
ride.passenger_name ??
|
||||
(ride.passenger_email ? ride.passenger_email.split('@')[0] : 'Passenger');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ride.id}
|
||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 shadow-sm hover:border-slate-300 dark:hover:border-slate-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-300 font-semibold text-xs">
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{passengerName}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{ride.passenger_phone || 'Passenger Request'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2.5 py-1 text-xs font-semibold rounded-full bg-blue-50 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
||||
New Request
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="py-3 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-emerald-600 dark:text-emerald-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Pickup Location
|
||||
</p>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 mt-0.5">
|
||||
{ride.pickup_location || 'Pickup Point'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-sky-600 dark:text-sky-400">
|
||||
<Navigation className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Destination
|
||||
</p>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 mt-0.5">
|
||||
{ride.destination_location || 'Dropoff Point'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-100 dark:border-slate-800 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecline(ride.id)}
|
||||
className="flex-1 py-2 px-3 inline-flex items-center justify-center gap-1.5 text-xs font-semibold rounded-lg border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => acceptMutation.mutate(ride.id)}
|
||||
disabled={acceptMutation.isPending}
|
||||
className="flex-1 py-2 px-3 inline-flex items-center justify-center gap-1.5 text-xs font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white transition-colors shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{acceptMutation.isPending ? 'Accepting...' : 'Accept Ride'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
frontend/src/components/features/DriverTripNavigation.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, CheckCircle2, Compass, MapPin, Navigation, Radio, ShieldAlert, UserCheck } from 'lucide-react';
|
||||
import { api } from '../../api';
|
||||
|
||||
export interface Ride {
|
||||
id: string;
|
||||
passenger_email: string;
|
||||
passenger_name?: string;
|
||||
passenger_phone?: string;
|
||||
driver_email?: string;
|
||||
driver_name?: string;
|
||||
driver_phone?: string;
|
||||
pickup_location: string;
|
||||
pickup_lat: number;
|
||||
pickup_lng: number;
|
||||
destination_location: string;
|
||||
destination_lat: number;
|
||||
destination_lng: number;
|
||||
status: 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||
driver_lat?: number | null;
|
||||
driver_lng?: number | null;
|
||||
rating?: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface DriverTripNavigationProps {
|
||||
ride?: Ride;
|
||||
onRideUpdated?: () => void;
|
||||
}
|
||||
|
||||
export default function DriverTripNavigation({ ride, onRideUpdated }: DriverTripNavigationProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [transmitting, setTransmitting] = useState(true);
|
||||
const [, setSimulatedProgress] = useState(0);
|
||||
const [currentCoords, setCurrentCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [lastPingTime, setLastPingTime] = useState<string | null>(null);
|
||||
const [pingError, setPingError] = useState<string | null>(null);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ride) {
|
||||
if (ride.driver_lat && ride.driver_lng) {
|
||||
setCurrentCoords({ lat: Number(ride.driver_lat), lng: Number(ride.driver_lng) });
|
||||
} else {
|
||||
setCurrentCoords({ lat: Number(ride.pickup_lat), lng: Number(ride.pickup_lng) });
|
||||
}
|
||||
}
|
||||
}, [ride?.id, ride?.driver_lat, ride?.driver_lng, ride?.pickup_lat, ride?.pickup_lng]);
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: async (nextStatus: 'picked_up' | 'completed') => {
|
||||
if (!ride?.id) throw new Error('No active ride');
|
||||
const res = await api.post(`/api/rides/${ride.id}/status`, { status: nextStatus });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['nearby-rides'] });
|
||||
if (ride?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: ['ride', ride.id] });
|
||||
}
|
||||
if (onRideUpdated) onRideUpdated();
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!ride?.id) throw new Error('No active ride');
|
||||
const res = await api.post(`/api/rides/${ride.id}/cancel`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['nearby-rides'] });
|
||||
if (ride?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: ['ride', ride.id] });
|
||||
}
|
||||
if (onRideUpdated) onRideUpdated();
|
||||
},
|
||||
});
|
||||
|
||||
const locationMutation = useMutation({
|
||||
mutationFn: async (coords: { lat: number; lng: number }) => {
|
||||
if (!ride?.id) return;
|
||||
const res = await api.post(`/api/rides/${ride.id}/location`, {
|
||||
driver_lat: coords.lat,
|
||||
driver_lng: coords.lng,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLastPingTime(new Date().toLocaleTimeString());
|
||||
setPingError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setPingError(err?.message || 'GPS transmitter error');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ride || !transmitting) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ride.status !== 'accepted' && ride.status !== 'picked_up') {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const nextLat = pos.coords.latitude;
|
||||
const nextLng = pos.coords.longitude;
|
||||
setCurrentCoords({ lat: nextLat, lng: nextLng });
|
||||
locationMutation.mutate({ lat: nextLat, lng: nextLng });
|
||||
},
|
||||
() => {
|
||||
simulateAndPing();
|
||||
},
|
||||
{ timeout: 3000, enableHighAccuracy: true }
|
||||
);
|
||||
} else {
|
||||
simulateAndPing();
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [ride?.id, ride?.status, transmitting]);
|
||||
|
||||
const simulateAndPing = () => {
|
||||
if (!ride) return;
|
||||
setSimulatedProgress((prev) => {
|
||||
const nextStep = prev >= 0.95 ? 0.05 : prev + 0.05;
|
||||
const startLat = ride.status === 'accepted' ? (ride.driver_lat || ride.pickup_lat - 0.01) : ride.pickup_lat;
|
||||
const startLng = ride.status === 'accepted' ? (ride.driver_lng || ride.pickup_lng - 0.01) : ride.pickup_lng;
|
||||
const endLat = ride.status === 'accepted' ? ride.pickup_lat : ride.destination_lat;
|
||||
const endLng = ride.status === 'accepted' ? ride.pickup_lng : ride.destination_lng;
|
||||
|
||||
const calcLat = startLat + (endLat - startLat) * nextStep;
|
||||
const calcLng = startLng + (endLng - startLng) * nextStep;
|
||||
|
||||
setCurrentCoords({ lat: calcLat, lng: calcLng });
|
||||
locationMutation.mutate({ lat: calcLat, lng: calcLng });
|
||||
return nextStep;
|
||||
});
|
||||
};
|
||||
|
||||
if (!ride) {
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-800 text-slate-400 p-6 rounded-xl text-center">
|
||||
<Compass className="w-8 h-8 mx-auto mb-2 text-slate-500 animate-spin" />
|
||||
<p className="text-sm">No active ride navigation session</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAccepted = ride.status === 'accepted';
|
||||
const isPickedUp = ride.status === 'picked_up';
|
||||
const isCompleted = ride.status === 'completed';
|
||||
const isCancelled = ride.status === 'cancelled';
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 text-slate-100 border border-slate-800 rounded-xl p-5 shadow-xl space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-sky-500/10 text-sky-400 rounded-lg border border-sky-500/20">
|
||||
<Navigation className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white">Active Trip Navigation</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{ride.passenger_name ? `Passenger: ${ride.passenger_name}` : `Passenger ID: ${ride.passenger_email}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isAccepted && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
En Route to Pickup
|
||||
</span>
|
||||
)}
|
||||
{isPickedUp && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
Driving to Destination
|
||||
</span>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
Completed
|
||||
</span>
|
||||
)}
|
||||
{isCancelled && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
Cancelled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 bg-slate-950/60 p-4 rounded-lg border border-slate-800/80">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="mt-0.5">
|
||||
<MapPin className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] uppercase tracking-wider font-semibold text-emerald-400">
|
||||
Pickup Point
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 truncate">{ride.pickup_location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-2.5 border-l-2 border-dashed border-slate-700 h-4 my-1" />
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="mt-0.5">
|
||||
<MapPin className="w-4 h-4 text-sky-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] uppercase tracking-wider font-semibold text-sky-400">
|
||||
Destination
|
||||
</span>
|
||||
<p className="text-sm text-slate-200 truncate">{ride.destination_location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isAccepted || isPickedUp) && (
|
||||
<div className="bg-slate-950/80 rounded-lg p-3.5 border border-slate-800 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-full ${transmitting ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-800 text-slate-500'}`}>
|
||||
<Radio className={`w-4 h-4 ${transmitting ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs font-medium text-slate-200">
|
||||
{transmitting ? 'GPS Transmitter Active' : 'GPS Transmitter Paused'}
|
||||
</span>
|
||||
{lastPingTime && (
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Last ping: {lastPingTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{currentCoords
|
||||
? `Lat: ${currentCoords.lat.toFixed(5)}, Lng: ${currentCoords.lng.toFixed(5)}`
|
||||
: 'Acquiring GPS fix...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTransmitting(!transmitting)}
|
||||
className="text-xs px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800 text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{transmitting ? 'Pause GPS' : 'Resume GPS'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pingError && (
|
||||
<div className="flex items-center space-x-2 text-xs text-amber-400 bg-amber-500/10 p-2.5 rounded-md border border-amber-500/20">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{pingError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 space-y-3">
|
||||
{isAccepted && (
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={statusMutation.isPending}
|
||||
onClick={() => statusMutation.mutate('picked_up')}
|
||||
className="flex-1 flex items-center justify-center space-x-2 bg-emerald-600 hover:bg-emerald-500 text-white font-medium py-3 px-4 rounded-lg transition-all shadow-lg shadow-emerald-950/40 disabled:opacity-50"
|
||||
>
|
||||
<UserCheck className="w-5 h-5" />
|
||||
<span>
|
||||
{statusMutation.isPending ? 'Updating...' : 'Arrived & Picked Up Passenger'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelMutation.isPending}
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-red-400 rounded-lg font-medium text-sm transition-colors border border-slate-700 flex items-center justify-center space-x-1"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
<span>Cancel Trip</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPickedUp && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={statusMutation.isPending}
|
||||
onClick={() => statusMutation.mutate('completed')}
|
||||
className="w-full flex items-center justify-center space-x-2 bg-sky-600 hover:bg-sky-500 text-white font-medium py-3 px-4 rounded-lg transition-all shadow-lg shadow-sky-950/40 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>
|
||||
{statusMutation.isPending ? 'Completing...' : 'Arrived at Destination - Complete Ride'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="flex items-center justify-center space-x-2 text-emerald-400 bg-emerald-500/10 p-3 rounded-lg border border-emerald-500/20 text-sm font-medium">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>Trip successfully completed. Ready for next dispatch.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCancelled && (
|
||||
<div className="flex items-center justify-center space-x-2 text-red-400 bg-red-500/10 p-3 rounded-lg border border-red-500/20 text-sm font-medium">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>This trip has been cancelled.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
frontend/src/components/features/RideBookingForm.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { ArrowRightLeft, Car, CheckCircle2, Clock, Compass, DollarSign, MapPin, Navigation } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PresetLocation {
|
||||
label: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
const PRESET_LOCATIONS: PresetLocation[] = [
|
||||
{ label: 'Downtown Central Station (100 Main St)', lat: 37.7749, lng: -122.4194 },
|
||||
{ label: 'Tech Innovation Park (500 Oak Ave)', lat: 37.7885, lng: -122.4014 },
|
||||
{ label: 'Metro Airport Terminal 2', lat: 37.6213, lng: -122.379 },
|
||||
{ label: 'Financial District Plaza', lat: 37.7925, lng: -122.401 },
|
||||
{ label: 'Westside Shopping Galleria', lat: 37.765, lng: -122.45 }
|
||||
];
|
||||
|
||||
export interface RideBookingFormProps {
|
||||
onRideCreated?: (ride: any) => void;
|
||||
onSetCoordinates?: (pickup: [number, number] | null, destination: [number, number] | null) => void;
|
||||
}
|
||||
|
||||
function calculateDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return Math.round(R * c * 10) / 10;
|
||||
}
|
||||
|
||||
export default function RideBookingForm({ onRideCreated, onSetCoordinates }: RideBookingFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pickupText, setPickupText] = useState(PRESET_LOCATIONS[0].label);
|
||||
const [pickupCoords, setPickupCoords] = useState<[number, number]>([
|
||||
PRESET_LOCATIONS[0].lat,
|
||||
PRESET_LOCATIONS[0].lng
|
||||
]);
|
||||
|
||||
const [destText, setDestText] = useState(PRESET_LOCATIONS[1].label);
|
||||
const [destCoords, setDestCoords] = useState<[number, number]>([
|
||||
PRESET_LOCATIONS[1].lat,
|
||||
PRESET_LOCATIONS[1].lng
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSetCoordinates) {
|
||||
onSetCoordinates(pickupCoords, destCoords);
|
||||
}
|
||||
}, [pickupCoords, destCoords, onSetCoordinates]);
|
||||
|
||||
const distance = calculateDistanceKm(
|
||||
pickupCoords[0],
|
||||
pickupCoords[1],
|
||||
destCoords[0],
|
||||
destCoords[1]
|
||||
);
|
||||
|
||||
const estimatedFare = (3.5 + distance * 1.8).toFixed(2);
|
||||
const estimatedTimeMins = Math.max(3, Math.round(distance * 2.5));
|
||||
|
||||
const handleSwap = () => {
|
||||
const tempText = pickupText;
|
||||
const tempCoords = pickupCoords;
|
||||
setPickupText(destText);
|
||||
setPickupCoords(destCoords);
|
||||
setDestText(tempText);
|
||||
setDestCoords(tempCoords);
|
||||
};
|
||||
|
||||
const createRideMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
pickup_location: pickupText,
|
||||
pickup_lat: pickupCoords[0],
|
||||
pickup_lng: pickupCoords[1],
|
||||
destination_location: destText,
|
||||
destination_lat: destCoords[0],
|
||||
destination_lng: destCoords[1]
|
||||
};
|
||||
const res = await api.post('/api/rides', payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success('Ride requested successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||
if (onRideCreated && data.ride) {
|
||||
onRideCreated(data.ride);
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.msg || err?.message || 'Failed to request ride');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!String((pickupText) ?? '').trim() || !String((destText) ?? '').trim()) {
|
||||
toast.error('Please enter both pickup and destination locations.');
|
||||
return;
|
||||
}
|
||||
if (pickupCoords[0] === destCoords[0] && pickupCoords[1] === destCoords[1]) {
|
||||
toast.error('Pickup and destination points must be different.');
|
||||
return;
|
||||
}
|
||||
createRideMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-300 shadow-sm p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="w-5 h-5 text-slate-800" />
|
||||
<h2 className="font-semibold text-slate-900 text-lg">Request a Ride</h2>
|
||||
</div>
|
||||
<span className="text-xs px-2.5 py-1 bg-slate-100 text-slate-700 font-medium rounded-full border border-slate-200">
|
||||
Instant Dispatch
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-600 flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-600 inline-block"></span>
|
||||
Pickup Location
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={pickupText}
|
||||
onChange={(e) => setPickupText(e.target.value)}
|
||||
placeholder="Enter pickup location..."
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm bg-slate-50 border border-slate-300 rounded-lg text-slate-900 focus:bg-white focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
<MapPin className="w-4 h-4 text-emerald-600 absolute left-3 top-3" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{PRESET_LOCATIONS.map((preset, idx) => (
|
||||
<button
|
||||
key={`p-${idx}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPickupText(preset.label);
|
||||
setPickupCoords([preset.lat, preset.lng]);
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||
pickupText === preset.label
|
||||
? 'bg-emerald-50 text-emerald-800 border-emerald-300 font-medium'
|
||||
: 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{String((preset.label.split('(')[0]) ?? '').trim()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center -my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
title="Swap pickup & destination"
|
||||
className="p-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 border border-slate-300 transition-colors"
|
||||
>
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-600 flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-sky-600 inline-block"></span>
|
||||
Destination Location
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={destText}
|
||||
onChange={(e) => setDestText(e.target.value)}
|
||||
placeholder="Enter destination..."
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm bg-slate-50 border border-slate-300 rounded-lg text-slate-900 focus:bg-white focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
<Navigation className="w-4 h-4 text-sky-600 absolute left-3 top-3" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{PRESET_LOCATIONS.map((preset, idx) => (
|
||||
<button
|
||||
key={`d-${idx}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDestText(preset.label);
|
||||
setDestCoords([preset.lat, preset.lng]);
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||
destText === preset.label
|
||||
? 'bg-sky-50 text-sky-800 border-sky-300 font-medium'
|
||||
: 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{String((preset.label.split('(')[0]) ?? '').trim()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 flex items-center justify-center gap-1">
|
||||
<Compass className="w-3.5 h-3.5" />
|
||||
Distance
|
||||
</div>
|
||||
<div className="font-semibold text-slate-900 text-sm mt-0.5">{distance} km</div>
|
||||
</div>
|
||||
<div className="border-x border-slate-200">
|
||||
<div className="text-xs text-slate-500 flex items-center justify-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Est. Time
|
||||
</div>
|
||||
<div className="font-semibold text-slate-900 text-sm mt-0.5">~{estimatedTimeMins} mins</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 flex items-center justify-center gap-1">
|
||||
<DollarSign className="w-3.5 h-3.5" />
|
||||
Est. Fare
|
||||
</div>
|
||||
<div className="font-semibold text-slate-900 text-sm mt-0.5">${estimatedFare}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRideMutation.isPending}
|
||||
className="w-full bg-slate-900 hover:bg-slate-800 text-white font-medium py-3 rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createRideMutation.isPending ? (
|
||||
<span>Processing Ride Request...</span>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Confirm & Request Ride</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
frontend/src/components/features/RideTrackingSheet.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { AlertTriangle, Car, CheckCircle, MapPin, Navigation, Phone, RefreshCw, ShieldCheck, Star, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface RideTrackingSheetProps {
|
||||
rideId: string | null;
|
||||
onRideCancelled?: () => void;
|
||||
onRideCompleted?: () => void;
|
||||
}
|
||||
|
||||
export default function RideTrackingSheet({ rideId, onRideCancelled, onRideCompleted }: RideTrackingSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rating, setRating] = useState<number>(5);
|
||||
const [hoverRating, setHoverRating] = useState<number>(0);
|
||||
const [hasRated, setHasRated] = useState<boolean>(false);
|
||||
|
||||
const { data: rideData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['ride', rideId],
|
||||
queryFn: async () => {
|
||||
if (!rideId) return null;
|
||||
const res = await api.get(`/api/rides/${rideId}`);
|
||||
return res.data?.ride ?? res.data;
|
||||
},
|
||||
enabled: !!rideId,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'completed' || status === 'cancelled') return false;
|
||||
return 3000;
|
||||
}
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!rideId) return;
|
||||
const res = await api.post(`/api/rides/${rideId}/cancel`);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Ride cancelled');
|
||||
queryClient.invalidateQueries({ queryKey: ['ride', rideId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||
if (onRideCancelled) onRideCancelled();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.msg || err?.message || 'Failed to cancel ride');
|
||||
}
|
||||
});
|
||||
|
||||
const rateMutation = useMutation({
|
||||
mutationFn: async (selectedRating: number) => {
|
||||
if (!rideId) return;
|
||||
const res = await api.post(`/api/rides/${rideId}/rate`, { rating: selectedRating });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Thank you for rating your trip!');
|
||||
setHasRated(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['ride', rideId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides'] });
|
||||
if (onRideCompleted) onRideCompleted();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.msg || err?.message || 'Failed to submit rating');
|
||||
}
|
||||
});
|
||||
|
||||
if (!rideId) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-300 p-6 text-center space-y-3">
|
||||
<Car className="w-10 h-10 text-slate-400 mx-auto" />
|
||||
<h3 className="font-semibold text-slate-800 text-base">No Active Ride Selected</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
Enter your pickup location and destination in the booking form above to start a live ride request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-300 p-6 text-center space-y-3">
|
||||
<RefreshCw className="w-6 h-6 text-slate-600 animate-spin mx-auto" />
|
||||
<p className="text-sm font-medium text-slate-700">Loading ride tracking details...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ride = rideData;
|
||||
if (!ride) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-300 p-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto" />
|
||||
<p className="text-sm font-medium text-slate-700">Ride details unavailable or not found.</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-xs font-medium text-slate-900 underline hover:text-slate-700"
|
||||
>
|
||||
Try Reloading
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentStatus = ride.status || 'requested';
|
||||
|
||||
const stages = [
|
||||
{ key: 'requested', label: 'Requested', desc: 'Searching for nearby drivers' },
|
||||
{ key: 'accepted', label: 'Accepted', desc: 'Driver en route to pickup' },
|
||||
{ key: 'picked_up', label: 'Picked Up', desc: 'Trip in progress' },
|
||||
{ key: 'completed', label: 'Completed', desc: 'Arrived at destination' }
|
||||
];
|
||||
|
||||
const currentStageIndex = stages.findIndex((s) => s.key === currentStatus);
|
||||
const isCancelled = currentStatus === 'cancelled';
|
||||
const isCompleted = currentStatus === 'completed';
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-300 shadow-md p-5 space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-wider text-slate-500 font-semibold">Ride Status</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{isCancelled ? (
|
||||
<span className="text-sm font-bold text-red-600 flex items-center gap-1.5">
|
||||
<XCircle className="w-4 h-4" /> Ride Cancelled
|
||||
</span>
|
||||
) : isCompleted ? (
|
||||
<span className="text-sm font-bold text-emerald-700 flex items-center gap-1.5">
|
||||
<CheckCircle className="w-4 h-4" /> Trip Completed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold text-slate-900 flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-sky-500 animate-pulse"></span>
|
||||
{stages.find((s) => s.key === currentStatus)?.label || currentStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1.5 rounded-lg border border-slate-200 hover:bg-slate-100 text-slate-600 transition-colors"
|
||||
title="Refresh ride status"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCancelled && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{stages.map((stage, idx) => {
|
||||
const isPast = currentStageIndex > idx;
|
||||
const isCurrent = currentStageIndex === idx;
|
||||
return (
|
||||
<div key={stage.key} className="space-y-1">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
isPast || isCurrent
|
||||
? isCompleted
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-slate-900'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-[10px] text-center font-medium text-slate-600 truncate">
|
||||
{stage.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200 space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-slate-500 font-medium">Pickup</div>
|
||||
<div className="font-semibold text-slate-900">{ride.pickup_location}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 pt-2 flex items-start gap-2">
|
||||
<Navigation className="w-4 h-4 text-sky-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-slate-500 font-medium">Destination</div>
|
||||
<div className="font-semibold text-slate-900">{ride.destination_location}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ride.driver_email ? (
|
||||
<div className="bg-slate-900 text-white rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center">
|
||||
<Car className="w-5 h-5 text-sky-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">
|
||||
{ride.driver_name || ride.driver_email.split('@')[0]}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Verified Driver
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ride.driver_phone && (
|
||||
<a
|
||||
href={`tel:${ride.driver_phone}`}
|
||||
className="p-2 rounded-full bg-slate-800 hover:bg-slate-700 text-slate-200 transition-colors"
|
||||
title="Call driver"
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{ride.driver_lat && ride.driver_lng && (
|
||||
<div className="text-xs text-slate-300 border-t border-slate-800 pt-2 flex items-center justify-between">
|
||||
<span>Live GPS Active</span>
|
||||
<span className="font-mono text-slate-400">
|
||||
{Number(ride.driver_lat).toFixed(4)}, {Number(ride.driver_lng).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !isCancelled && !isCompleted ? (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin shrink-0 text-amber-600" />
|
||||
<span>Searching for an available driver nearby...</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(currentStatus === 'requested' || currentStatus === 'accepted') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
disabled={cancelMutation.isPending}
|
||||
className="w-full bg-red-50 hover:bg-red-100 text-red-700 font-medium py-2.5 rounded-lg border border-red-200 text-xs flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
{cancelMutation.isPending ? 'Cancelling Ride...' : 'Cancel Ride Request'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-center space-y-3">
|
||||
<h4 className="font-semibold text-slate-900 text-sm">
|
||||
{hasRated || ride.rating ? 'Trip Rating Submitted' : 'How was your ride?'}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{hasRated || ride.rating
|
||||
? `You rated this trip ${ride.rating || rating} stars.`
|
||||
: 'Please rate your driver experience below.'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const active = (hoverRating || ride.rating || rating) >= star;
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={hasRated || !!ride.rating || rateMutation.isPending}
|
||||
onClick={() => {
|
||||
setRating(star);
|
||||
rateMutation.mutate(star);
|
||||
}}
|
||||
onMouseEnter={() => !hasRated && !ride.rating && setHoverRating(star)}
|
||||
onMouseLeave={() => !hasRated && !ride.rating && setHoverRating(0)}
|
||||
className="p-1 focus:outline-none disabled:cursor-default"
|
||||
>
|
||||
<Star
|
||||
className={`w-6 h-6 transition-colors ${
|
||||
active ? 'fill-amber-400 text-amber-400' : 'text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
frontend/src/components/features/SafetyOnboardingCard.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { AlertTriangle, CheckCircle2, Clock, FileCheck, Loader2, Shield } from 'lucide-react';
|
||||
|
||||
interface SafetyOnboardingCardProps {
|
||||
onCompleted?: () => void;
|
||||
}
|
||||
|
||||
export default function SafetyOnboardingCard({ onCompleted }: SafetyOnboardingCardProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [personaLabel, setPersonaLabel] = useState('Standard Commuter');
|
||||
const [safetyConsent, setSafetyConsent] = useState(true);
|
||||
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const { data: profileData, isLoading } = useQuery({
|
||||
queryKey: ['companion-safety-profile'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/companion/safety-profile');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const profile = profileData?.profile;
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
if (profile.persona_label) setPersonaLabel(profile.persona_label);
|
||||
setSafetyConsent(profile.safety_consent === 1);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: async (payload: { persona_label: string; safety_consent: number }) => {
|
||||
const res = await api.put('/api/companion/safety-profile', payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['companion-safety-profile'] });
|
||||
setMsg({ type: 'success', text: 'Companion safety profile saved.' });
|
||||
if (onCompleted) onCompleted();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMsg({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.msg || err?.message || 'Failed to update safety profile.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const bgCheckMutation = useMutation({
|
||||
mutationFn: async (consent: boolean) => {
|
||||
const res = await api.post('/api/companion/safety-profile/background-check', { consent });
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['companion-safety-profile'] });
|
||||
setMsg({ type: 'success', text: 'Background check verification requested.' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMsg({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.msg || err?.message || 'Failed to request background check.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSaveProfile = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
updateProfileMutation.mutate({
|
||||
persona_label: String((personaLabel) ?? '').trim(),
|
||||
safety_consent: safetyConsent ? 1 : 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRequestBgCheck = () => {
|
||||
setMsg(null);
|
||||
bgCheckMutation.mutate(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 bg-slate-900 text-white rounded-xl border border-slate-800">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-sky-400" />
|
||||
<span className="ml-2 text-sm text-slate-300">Loading companion safety profile...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const checkStatus = profile?.background_check_status || 'not_started';
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 text-white rounded-xl border border-slate-800 p-6 shadow-xl">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="w-full md:w-1/3 flex flex-col items-center text-center p-4 bg-slate-800/80 rounded-lg border border-slate-700">
|
||||
<img
|
||||
src="/assets/landing-04-safety-companion-framework.png"
|
||||
alt="Safety Companion Verification Badge Graphic"
|
||||
className="w-full h-32 object-cover rounded-md mb-3"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-white font-semibold text-base">
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
<span>Companion Safety</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Private persona settings & verified companion safety framework for trust on every trip.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 w-full pt-3 border-t border-slate-700/60 flex flex-col items-center">
|
||||
<span className="text-xs text-slate-400 mb-1 font-medium">Verification Status:</span>
|
||||
{checkStatus === 'approved' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-950/80 border border-emerald-500/40 text-emerald-300 text-xs font-medium rounded-full">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> Verified
|
||||
</span>
|
||||
)}
|
||||
{checkStatus === 'pending' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-amber-950/80 border border-amber-500/40 text-amber-300 text-xs font-medium rounded-full">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400 animate-pulse" /> Check Pending
|
||||
</span>
|
||||
)}
|
||||
{checkStatus === 'rejected' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-red-950/80 border border-red-500/40 text-red-300 text-xs font-medium rounded-full">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400" /> Needs Attention
|
||||
</span>
|
||||
)}
|
||||
{checkStatus === 'not_started' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-slate-800 border border-slate-600 text-slate-300 text-xs font-medium rounded-full">
|
||||
Not Started
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3 flex flex-col gap-5">
|
||||
{msg && (
|
||||
<div
|
||||
className={`p-3 rounded-md text-sm flex items-center gap-2 border ${
|
||||
msg.type === 'success'
|
||||
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-200'
|
||||
: 'bg-red-950/60 border-red-500/40 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{msg.type === 'success' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0" />
|
||||
)}
|
||||
<span>{msg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||||
Persona Label
|
||||
</label>
|
||||
<select
|
||||
value={personaLabel}
|
||||
onChange={(e) => setPersonaLabel(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-white text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
>
|
||||
<option value="Standard Commuter">Standard Commuter</option>
|
||||
<option value="Night Travel Safety">Night Travel Safety Focus</option>
|
||||
<option value="Quiet Ride Companion">Quiet Ride Preferred</option>
|
||||
<option value="Frequent Traveler">Frequent City Traveler</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-slate-800/60 border border-slate-700/80 rounded-md">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="safetyConsent"
|
||||
checked={safetyConsent}
|
||||
onChange={(e) => setSafetyConsent(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-slate-600 bg-slate-700 text-sky-500 focus:ring-sky-500 focus:ring-offset-slate-900"
|
||||
/>
|
||||
<label htmlFor="safetyConsent" className="text-xs text-slate-300 leading-relaxed cursor-pointer">
|
||||
I consent to safety telemetry logging during active rides and agreeing to RideFlow companion safety guidelines.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="px-4 py-2 bg-sky-600 hover:bg-sky-500 text-white font-medium text-sm rounded-md transition-colors disabled:opacity-50 min-h-[44px] inline-flex items-center gap-2"
|
||||
>
|
||||
{updateProfileMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>Save Safety Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-200 flex items-center gap-1.5">
|
||||
<FileCheck className="w-4 h-4 text-emerald-400" />
|
||||
<span>Background Check Verification</span>
|
||||
</h4>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Submit for official companion safety verification check.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{checkStatus === 'not_started' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestBgCheck}
|
||||
disabled={bgCheckMutation.isPending}
|
||||
className="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-medium rounded-md transition-colors disabled:opacity-50 min-h-[44px] whitespace-nowrap inline-flex items-center gap-1.5"
|
||||
>
|
||||
{bgCheckMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Shield className="w-4 h-4" />
|
||||
)}
|
||||
<span>Request Verification</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{checkStatus === 'pending' && (
|
||||
<span className="text-xs text-amber-300 font-medium bg-amber-950/60 px-3 py-1.5 rounded border border-amber-800/50">
|
||||
Verification Review in Progress
|
||||
</span>
|
||||
)}
|
||||
|
||||
{checkStatus === 'approved' && (
|
||||
<span className="text-xs text-emerald-300 font-medium bg-emerald-950/60 px-3 py-1.5 rounded border border-emerald-800/50">
|
||||
Identity & Background Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
frontend/src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export { Dialog as AlertDialog, DialogContent as AlertDialogContent, DialogTitle as AlertDialogTitle, DialogDescription as AlertDialogDescription, DialogClose as AlertDialogCancel } from './dialog';
|
||||
7
frontend/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Alert = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='alert' className={cx('rounded-lg border p-4 text-sm', className)} {...props} />;
|
||||
export const AlertTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h5 className='mb-1 font-medium' {...props} />;
|
||||
export const AlertDescription = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='text-muted-foreground' {...props} />;
|
||||
export default Alert;
|
||||
5
frontend/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Badge = ({ className, variant, size, ...props }: React.HTMLAttributes<HTMLSpanElement> & { variant?: string; size?: string }) => <span className={cx('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', className)} {...props} />;
|
||||
export default Badge;
|
||||
6
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Button = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }>(({ className, variant, size, ...props }, ref) => <button ref={ref} className={cx('inline-flex min-h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', className)} {...props} />);
|
||||
Button.displayName = 'Button';
|
||||
export default Button;
|
||||
10
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />;
|
||||
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-1.5 p-6', className)} {...props} />;
|
||||
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => <h3 className={cx('text-lg font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||
export const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={cx('text-sm text-muted-foreground', className)} {...props} />;
|
||||
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('p-6 pt-0', className)} {...props} />;
|
||||
export const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex items-center p-6 pt-0', className)} {...props} />;
|
||||
export default Card;
|
||||
5
frontend/src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' className='size-4 rounded border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
export default Checkbox;
|
||||
9
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Dialog = ({ open = true, children }: { open?: boolean; children?: React.ReactNode }) => open ? <div role='dialog' aria-modal='true' className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4'>{children}</div> : null;
|
||||
export const DialogContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg', className)} {...props} />;
|
||||
export const DialogTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h2 className='text-lg font-semibold' {...props} />;
|
||||
export const DialogDescription = (props: React.HTMLAttributes<HTMLParagraphElement>) => <p className='text-sm text-muted-foreground' {...props} />;
|
||||
export const DialogClose = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' aria-label='Close' {...props} />;
|
||||
export default Dialog;
|
||||
6
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DropdownMenu = ({ children }: { children?: React.ReactNode }) => <div className='relative'>{children}</div>;
|
||||
export const DropdownMenuTrigger = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' {...props} />;
|
||||
export const DropdownMenuContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='absolute right-0 z-20 mt-2 min-w-40 rounded-md border bg-popover p-1 shadow-md' {...props} />;
|
||||
export const DropdownMenuItem = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' className='flex w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent' {...props} />;
|
||||
4
frontend/src/components/ui/empty.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Empty = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex min-h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-6 text-center', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default Empty;
|
||||
4
frontend/src/components/ui/field-group.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
export const FieldGroup = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex flex-col gap-4', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default FieldGroup;
|
||||
7
frontend/src/components/ui/field.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Field = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex flex-col gap-2', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export const FieldLabel = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export const FieldDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={['text-sm text-muted-foreground', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export const FieldError = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p role='alert' className={['text-sm text-destructive', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default Field;
|
||||
5
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
|
||||
Input.displayName = 'Input';
|
||||
export default Input;
|
||||
4
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Label = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium leading-none', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default Label;
|
||||
7
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(({ className, ...props }, ref) => <select ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm', className].filter(Boolean).join(' ')} {...props} />);
|
||||
Select.displayName = 'Select';
|
||||
export const SelectItem = (props: React.OptionHTMLAttributes<HTMLOptionElement>) => <option {...props} />;
|
||||
export const SelectGroup = (props: React.OptgroupHTMLAttributes<HTMLOptGroupElement>) => <optgroup {...props} />;
|
||||
export default Select;
|
||||
4
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Separator = ({ orientation = 'horizontal', className, ...props }: React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) => <div role='separator' aria-orientation={orientation} className={[orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full', 'shrink-0 bg-border', className].filter(Boolean).join(' ')} {...props} />;
|
||||
export default Separator;
|
||||
3
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export { Dialog as Sheet, DialogContent as SheetContent, DialogTitle as SheetTitle, DialogDescription as SheetDescription, DialogClose as SheetClose } from './dialog';
|
||||
5
frontend/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Skeleton = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
export default Skeleton;
|
||||
5
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' role='switch' className='size-5 rounded-full border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
|
||||
Switch.displayName = 'Switch';
|
||||
export default Switch;
|
||||
10
frontend/src/components/ui/table.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Table = ({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <div className='w-full overflow-auto'><table className={cx('w-full caption-bottom text-sm', className)} {...props} /></div>;
|
||||
export const TableHeader = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead {...props} />;
|
||||
export const TableBody = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />;
|
||||
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr className={cx('border-b', className)} {...props} />;
|
||||
export const TableHead = (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th className='h-10 px-2 text-left align-middle font-medium' {...props} />;
|
||||
export const TableCell = (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td className='p-2 align-middle' {...props} />;
|
||||
export default Table;
|
||||
8
frontend/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
|
||||
export const Tabs = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-3', className)} {...props} />;
|
||||
export const TabsList = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tablist' className={cx('inline-flex h-10 items-center gap-1 rounded-md bg-muted p-1', className)} {...props} />;
|
||||
export const TabsTrigger = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button role='tab' className={cx('rounded px-3 py-1.5 text-sm', className)} {...props} />;
|
||||
export const TabsContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tabpanel' className={cx('mt-2', className)} {...props} />;
|
||||
export default Tabs;
|
||||
5
frontend/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(({ className, ...props }, ref) => <textarea ref={ref} className={['flex min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
|
||||
Textarea.displayName = 'Textarea';
|
||||
export default Textarea;
|
||||
5
frontend/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Tooltip = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
|
||||
export const TooltipTrigger = (props: React.HTMLAttributes<HTMLElement>) => <span {...props} />;
|
||||
export const TooltipContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div role='tooltip' className='rounded bg-foreground px-2 py-1 text-xs text-background' {...props} />;
|
||||
154
frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import { authLink } from '../lib/mAuth';
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
fullName?: string;
|
||||
full_name?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
citizen_id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserData | null;
|
||||
token: string | null;
|
||||
login: () => Promise<void>;
|
||||
checkAuth: () => Promise<UserData | null>;
|
||||
logout: () => void;
|
||||
updateUser: (userData: UserData) => void;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const updateUser = (newUserData: UserData) => {
|
||||
setUser(newUserData);
|
||||
localStorage.setItem('auth_user', JSON.stringify(newUserData));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the backend check-in endpoint to sync the mAuth user
|
||||
* with the local database and get the full user profile.
|
||||
*/
|
||||
const handleCheckIn = async (authToken: string) => {
|
||||
try {
|
||||
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||
const res = await api.post('/user/check-in', {
|
||||
mauth_token: authToken,
|
||||
});
|
||||
if (res.data?.success && res.data.user) {
|
||||
setUser(res.data.user);
|
||||
localStorage.setItem('auth_user', JSON.stringify(res.data.user));
|
||||
return res.data.user;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Check-in failed:", err);
|
||||
logout();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Public compatibility method for generated onboarding and role guards.
|
||||
// It always reuses the scaffolded check-in path instead of inventing a
|
||||
// second token-validation flow in individual pages.
|
||||
const checkAuth = async (): Promise<UserData | null> => {
|
||||
const authToken = token || localStorage.getItem('user_auth_token') || localStorage.getItem('auth_token');
|
||||
if (!authToken) return null;
|
||||
setToken(authToken);
|
||||
return handleCheckIn(authToken);
|
||||
};
|
||||
|
||||
/**
|
||||
* mAuth Native Initializer:
|
||||
* 1. Check for user_auth_token in localStorage (set by /auth route).
|
||||
* 2. If present, call backend check-in to validate & sync user data.
|
||||
* The backend handles mAuth token verification — we must NOT call
|
||||
* fetchUserData here too, as that would consume/invalidate the token
|
||||
* before the backend can use it.
|
||||
* 3. If absent or invalid, clear auth state so the login button is shown.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initializer = async () => {
|
||||
const origin = window.location.origin.toString();
|
||||
if (!localStorage.getItem('mworld_origin')) {
|
||||
localStorage.setItem('mworld_origin', origin);
|
||||
}
|
||||
|
||||
const uat = localStorage.getItem('user_auth_token');
|
||||
if (uat) {
|
||||
setToken(uat);
|
||||
|
||||
// Restore cached user data immediately for fast UI
|
||||
const savedUser = localStorage.getItem('auth_user');
|
||||
if (savedUser && savedUser !== 'undefined') {
|
||||
try {
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Validate token & sync user via backend check-in
|
||||
// (backend calls mAuth internally to verify the token)
|
||||
const freshUser = await handleCheckIn(uat);
|
||||
if (!freshUser) {
|
||||
// Token is invalid or check-in failed — clean up
|
||||
localStorage.removeItem('user_auth_token');
|
||||
logout();
|
||||
}
|
||||
}
|
||||
// If no token present, user stays logged out (login button shown)
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initializer();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Triggers the mAuth native login flow:
|
||||
* Generates an auth link and redirects the user to mAuth in the same window.
|
||||
*/
|
||||
const login = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||
const link = await authLink(currentOrigin);
|
||||
if (!link) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Native flow: redirect in same window (not window.open)
|
||||
window.location.href = link;
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('auth_user');
|
||||
localStorage.removeItem('user_auth_token');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, checkAuth, logout, updateUser, isAuthenticated: !!user, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||
return context;
|
||||
};
|
||||
46
frontend/src/hooks/useNotifications.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
recipient_email: string;
|
||||
type: 'info' | 'success' | 'warning' | 'mention' | 'system';
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string;
|
||||
actor_email?: string;
|
||||
is_read: number | boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Poll unread count every 15 seconds
|
||||
const countQuery = useQuery({
|
||||
queryKey: ['notification-count'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await api.get('/notifications/count');
|
||||
return typeof res.data?.count === 'number' ? res.data.count : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
refetchInterval: 15000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const invalidateNotifications = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
|
||||
};
|
||||
|
||||
return {
|
||||
unreadCount: countQuery.data ?? 0,
|
||||
isLoadingCount: countQuery.isLoading,
|
||||
refetchCount: countQuery.refetch,
|
||||
invalidateNotifications,
|
||||
};
|
||||
}
|
||||
|
||||
export default useNotifications;
|
||||
91
frontend/src/index.css
Normal file
@ -0,0 +1,91 @@
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 0 0% 0%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 0%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 0%;
|
||||
--primary: 222 47% 11%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 35%;
|
||||
--accent: 199 89% 39%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--success: 142 72% 29%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 32 95% 37%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
--info: 199 96% 32%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--border: 214 32% 81%;
|
||||
--input: 214 32% 81%;
|
||||
--ring: 222 47% 11%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.signal-pulse-emerald {
|
||||
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0.4);
|
||||
animation: pulse-emerald 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-emerald {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0.5);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(21, 128, 61, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.signal-pulse-blue {
|
||||
box-shadow: 0 0 0 0 rgba(2, 132, 199, 0.4);
|
||||
animation: pulse-blue 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-blue {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(2, 132, 199, 0.5);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(2, 132, 199, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(2, 132, 199, 0);
|
||||
}
|
||||
}
|
||||
47
frontend/src/lib/mAuth.ts
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const mAuthAPIURL = "https://api.mauth.mworld.cloud";
|
||||
|
||||
/**
|
||||
* Creates a new mAuth authentication link.
|
||||
* Requires the current origin URL.
|
||||
*/
|
||||
export const authLink = async (currentOrigin: string): Promise<string | null> => {
|
||||
try {
|
||||
const r = await axios.get(`${mAuthAPIURL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}&type=native`);
|
||||
console.log(r.data);
|
||||
const link = r.data?.mAuth?.auth_link;
|
||||
return link || null;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the data of the user who authenticated.
|
||||
* Requires origin URL and the user auth token.
|
||||
*
|
||||
* WARNING: This CONSUMES the mAuth token. The backend `/user/check-in`
|
||||
* endpoint already verifies the token server-side, so calling this during
|
||||
* app bootstrap would invalidate the token before the backend can use it.
|
||||
* Do NOT call this from AuthContext — it exists only for one-off flows that
|
||||
* need raw mAuth profile fields without a backend round-trip.
|
||||
*/
|
||||
export const fetchUserData = async (origin: string, token: string) => {
|
||||
try {
|
||||
const payload = {
|
||||
origin: origin,
|
||||
required_user_data: ["fullName", "email", "phone"],
|
||||
};
|
||||
const r = await axios.get(
|
||||
`${mAuthAPIURL}/mAuthUser/${token}?payload=${JSON.stringify(payload)}`
|
||||
);
|
||||
console.log(r.data);
|
||||
return r.data;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
36
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,36 @@
|
||||
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatPrice(price: any): string {
|
||||
const num = Number(price);
|
||||
if (isNaN(num)) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export function formatMileage(mileage: any): string {
|
||||
const num = Number(mileage);
|
||||
if (isNaN(num)) return '';
|
||||
return new Intl.NumberFormat('en-US').format(num) + ' miles';
|
||||
}
|
||||
|
||||
export function formatDate(dateString: any): string {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
31
frontend/src/main.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
|
||||
import React, { Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
229
frontend/src/pages/Admin.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Activity, FileText, LayoutDashboard, Radio, RefreshCw, Shield, Users } from 'lucide-react';
|
||||
import { adminApi } from '../api';
|
||||
import AdminStatsCards from '../components/features/AdminStatsCards';
|
||||
import AdminUserTable from '../components/features/AdminUserTable';
|
||||
import AdminRideLogsTable from '../components/features/AdminRideLogsTable';
|
||||
|
||||
export default function Admin() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [lastRefreshed, setLastRefreshed] = useState<string>(new Date().toLocaleTimeString());
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [, setTokenVerified] = useState<boolean | null>(null);
|
||||
|
||||
// Determine active tab from URL path
|
||||
const getActiveTabFromPath = (path: string) => {
|
||||
if (path.includes('/admin/users')) return 'users';
|
||||
if (path.includes('/admin/rides')) return 'rides';
|
||||
return 'overview';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'rides'>(
|
||||
getActiveTabFromPath(location.pathname)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(getActiveTabFromPath(location.pathname));
|
||||
}, [location.pathname]);
|
||||
|
||||
// Initial verification check using correct full admin endpoint path
|
||||
useEffect(() => {
|
||||
adminApi.get('/api/admin/verify-token')
|
||||
.then((res) => {
|
||||
if (res.data?.success) {
|
||||
setTokenVerified(true);
|
||||
} else {
|
||||
setTokenVerified(false);
|
||||
}
|
||||
})
|
||||
.catch(() => setTokenVerified(false));
|
||||
}, []);
|
||||
|
||||
const handleTabChange = (tab: 'overview' | 'users' | 'rides') => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'overview') navigate('/admin');
|
||||
else if (tab === 'users') navigate('/admin/users');
|
||||
else if (tab === 'rides') navigate('/admin/rides');
|
||||
};
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
setIsRefreshing(true);
|
||||
setLastRefreshed(new Date().toLocaleTimeString());
|
||||
setTimeout(() => setIsRefreshing(false), 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 font-sans pb-16">
|
||||
|
||||
<header className="border-b border-slate-800 bg-slate-900/90 backdrop-blur sticky top-0 z-30 px-4 lg:px-8 py-4">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-sky-500/10 border border-sky-500/30 flex items-center justify-center text-sky-400 font-bold">
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold tracking-tight text-white font-display">RideFlow Operator Panel</h1>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Live Telemetry
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Real-time dispatch oversight & platform audit logs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<span className="text-[11px] uppercase tracking-wider text-slate-400 block font-mono">Last Synced</span>
|
||||
<span className="text-xs text-slate-200 font-mono">{lastRefreshed}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin text-sky-400' : ''}`} />
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Admin Workspace */}
|
||||
<main className="max-w-7xl mx-auto px-4 lg:px-8 pt-6 space-y-6">
|
||||
|
||||
<div className="flex border-b border-slate-800 gap-2 overflow-x-auto pb-px">
|
||||
<button
|
||||
onClick={() => handleTabChange('overview')}
|
||||
className={`inline-flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
activeTab === 'overview'
|
||||
? 'border-sky-500 text-sky-400 bg-sky-500/5'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Overview Telemetry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('users')}
|
||||
className={`inline-flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
activeTab === 'users'
|
||||
? 'border-sky-500 text-sky-400 bg-sky-500/5'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
User Directory
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('rides')}
|
||||
className={`inline-flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
activeTab === 'rides'
|
||||
? 'border-sky-500 text-sky-400 bg-sky-500/5'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
System Ride Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content Panes */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 overflow-hidden relative group">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 items-center">
|
||||
<div className="p-6 md:col-span-2 space-y-2">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
<Radio className="w-3.5 h-3.5 animate-pulse" /> Platform Operations
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white font-display">System Status & Real-time Metrics</h2>
|
||||
<p className="text-sm text-slate-400 max-w-xl">
|
||||
Monitor active passenger requests, driver availability status, total system volume, and completed rides across the urban transit network.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 flex justify-end md:border-l border-slate-800/80">
|
||||
<img
|
||||
src="/assets/admin-01-admin-kpi-summary.png"
|
||||
alt="Display top-level system stats including total users, online drivers, system for RideFlow"
|
||||
className="w-full max-h-36 object-cover rounded-lg border border-slate-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<AdminStatsCards />
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-sky-400" /> Recent User Registrations
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleTabChange('users')}
|
||||
className="text-xs text-sky-400 hover:underline font-medium"
|
||||
>
|
||||
View All Users →
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<AdminUserTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-amber-400" /> Active & Recent Rides
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleTabChange('rides')}
|
||||
className="text-xs text-sky-400 hover:underline font-medium"
|
||||
>
|
||||
View All Ride Logs →
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<AdminRideLogsTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-4 animate-fadeIn bg-slate-900 border border-slate-800 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white font-display">User Management Directory</h2>
|
||||
<p className="text-xs text-slate-400">All registered passengers, active drivers, and system administrators</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminUserTable />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'rides' && (
|
||||
<div className="space-y-4 animate-fadeIn bg-slate-900 border border-slate-800 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white font-display">System Ride Logs & Audit Trail</h2>
|
||||
<p className="text-xs text-slate-400">Comprehensive log of requested, accepted, picked up, completed, and cancelled rides</p> //
|
||||
</div>
|
||||
</div>
|
||||
<AdminRideLogsTable />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
frontend/src/pages/Auth.tsx
Normal file
33
frontend/src/pages/Circles.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import CircleActivityPlanner from '../components/features/CircleActivityPlanner';
|
||||
import { ShieldCheck, Users } from 'lucide-react';
|
||||
|
||||
export default function CirclesPage() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-slate-50 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-sky-100 text-sky-700 rounded-lg">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Companion Circles & Activity Hub</h1>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1 max-w-2xl leading-relaxed">
|
||||
Coordinate trusted companion circles, manage activity invitations, and plan recurring safety meetups with seamless state tracking.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs bg-slate-100 text-slate-700 px-3 py-2 rounded-xl border border-slate-200 self-stretch md:self-auto justify-center">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||
<span>Verified Community Network</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Planner Workspace */}
|
||||
<CircleActivityPlanner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
frontend/src/pages/DriverWorkstation.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import MapView, { MapMarker } from '../components/MapView';
|
||||
import DriverAvailabilityToggle from '../components/features/DriverAvailabilityToggle';
|
||||
import DriverRequestCard from '../components/features/DriverRequestCard';
|
||||
import DriverTripNavigation from '../components/features/DriverTripNavigation';
|
||||
import { Ride } from '../types/ride';
|
||||
import { Car, Navigation, Radio, RefreshCw, ShieldAlert } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function DriverWorkstation() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch driver profile to check setup & availability
|
||||
const { data: profileRes, isLoading: isProfileLoading, refetch: refetchProfile } = useQuery({
|
||||
queryKey: ['driver-profile'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/driver/profile');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const driverProfile = profileRes?.profile;
|
||||
const isAvailable = Boolean(driverProfile?.is_available);
|
||||
|
||||
// Fetch driver's current rides to locate an active trip
|
||||
const { data: myRidesRes, refetch: refetchMyRides } = useQuery({
|
||||
queryKey: ['rides-my'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/rides/my');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const myRides: Ride[] = Array.isArray(myRidesRes?.rides) ? myRidesRes.rides : [];
|
||||
const activeRide = myRides.find(
|
||||
(r) => r.status === 'accepted' || r.status === 'picked_up'
|
||||
);
|
||||
|
||||
// Fetch nearby ride requests when available & not in an active trip
|
||||
const { refetch: refetchNearby } = useQuery({
|
||||
queryKey: ['rides-nearby'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/rides/nearby');
|
||||
return res.data;
|
||||
},
|
||||
enabled: isAvailable && !activeRide,
|
||||
refetchInterval: isAvailable && !activeRide ? 4000 : false,
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchProfile();
|
||||
refetchMyRides();
|
||||
if (isAvailable && !activeRide) refetchNearby();
|
||||
};
|
||||
|
||||
const handleAcceptRide = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rides-my'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides-nearby'] });
|
||||
};
|
||||
|
||||
const handleRideUpdated = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rides-my'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides-nearby'] });
|
||||
};
|
||||
|
||||
// Determine map markers strictly matching MapMarker contract
|
||||
const mapMarkers: MapMarker[] = [];
|
||||
if (activeRide) {
|
||||
if (typeof activeRide.pickup_lat === 'number' && typeof activeRide.pickup_lng === 'number') {
|
||||
mapMarkers.push({
|
||||
id: 'pickup',
|
||||
lat: activeRide.pickup_lat,
|
||||
lng: activeRide.pickup_lng,
|
||||
label: `Pickup: ${activeRide.pickup_location}`,
|
||||
type: 'pickup',
|
||||
});
|
||||
}
|
||||
if (typeof activeRide.destination_lat === 'number' && typeof activeRide.destination_lng === 'number') {
|
||||
mapMarkers.push({
|
||||
id: 'destination',
|
||||
lat: activeRide.destination_lat,
|
||||
lng: activeRide.destination_lng,
|
||||
label: `Destination: ${activeRide.destination_location}`,
|
||||
type: 'destination',
|
||||
});
|
||||
}
|
||||
if (typeof activeRide.driver_lat === 'number' && typeof activeRide.driver_lng === 'number') {
|
||||
mapMarkers.push({
|
||||
id: 'driver',
|
||||
lat: activeRide.driver_lat,
|
||||
lng: activeRide.driver_lng,
|
||||
label: 'Your Vehicle Position',
|
||||
type: 'driver',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate polyline for route
|
||||
const polyline: [number, number][] | undefined =
|
||||
activeRide &&
|
||||
typeof activeRide.pickup_lat === 'number' &&
|
||||
typeof activeRide.pickup_lng === 'number' &&
|
||||
typeof activeRide.destination_lat === 'number' &&
|
||||
typeof activeRide.destination_lng === 'number'
|
||||
? [
|
||||
[activeRide.pickup_lat, activeRide.pickup_lng],
|
||||
[activeRide.destination_lat, activeRide.destination_lng],
|
||||
]
|
||||
: undefined;
|
||||
|
||||
// Determine map center
|
||||
const mapCenter: [number, number] =
|
||||
activeRide && typeof activeRide.driver_lat === 'number' && typeof activeRide.driver_lng === 'number'
|
||||
? [activeRide.driver_lat, activeRide.driver_lng]
|
||||
: activeRide && typeof activeRide.pickup_lat === 'number' && typeof activeRide.pickup_lng === 'number'
|
||||
? [activeRide.pickup_lat, activeRide.pickup_lng]
|
||||
: [37.7749, -122.4194];
|
||||
|
||||
if (!driverProfile && !isProfileLoading) {
|
||||
return (
|
||||
<div className="container max-w-3xl py-12 px-4">
|
||||
<div className="bg-card border border-border rounded-xl p-8 text-center space-y-4 shadow-xs">
|
||||
<div className="inline-flex p-3 rounded-full bg-amber-500/10 text-amber-600">
|
||||
<Car className="w-8 h-8" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Driver Profile Required</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto text-sm">
|
||||
To receive ride dispatches and go online as a driver, please register your vehicle details first.
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/app/onboarding"
|
||||
className="inline-flex items-center gap-2 bg-primary text-primary-foreground text-sm font-semibold px-6 py-2.5 rounded-lg hover:opacity-90 transition"
|
||||
>
|
||||
Complete Driver Setup
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-7xl py-6 px-4 space-y-6">
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-card border border-border p-4 rounded-xl shadow-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-slate-900 text-white dark:bg-slate-800">
|
||||
<Navigation className="w-6 h-6 text-sky-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold tracking-tight">Driver Workstation</h1>
|
||||
{driverProfile && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 font-mono dark:bg-slate-800 dark:text-slate-300">
|
||||
{driverProfile.vehicle_make} {driverProfile.vehicle_model} • {driverProfile.license_plate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeRide
|
||||
? 'Active navigation in progress'
|
||||
: isAvailable
|
||||
? 'Online — Scanning for nearby dispatch requests'
|
||||
: 'Offline — Switch online to receive requests'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 self-end md:self-auto">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 rounded-lg border border-border hover:bg-muted text-muted-foreground transition"
|
||||
title="Refresh dispatch feeds"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<DriverAvailabilityToggle
|
||||
onStatusChange={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['driver-profile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rides-nearby'] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Workstation View Split */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
{activeRide ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-sky-600 bg-sky-50 dark:bg-sky-950/40 dark:text-sky-400 px-3 py-1.5 rounded-lg border border-sky-200 dark:border-sky-800">
|
||||
<Radio className="w-4 h-4 animate-pulse text-sky-500" />
|
||||
<span>Active Trip Engagement</span>
|
||||
</div>
|
||||
<DriverTripNavigation ride={activeRide} onRideUpdated={handleRideUpdated} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card border border-border rounded-xl p-4 space-y-4 shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Radio className={`w-4 h-4 ${isAvailable ? 'text-emerald-500 animate-pulse' : 'text-slate-400'}`} />
|
||||
Nearby Dispatch Feed
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{isAvailable ? 'Live' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isAvailable ? (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<div className="inline-flex p-3 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">You are currently Offline</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-xs mx-auto">
|
||||
Toggle your availability to "Online" in the header bar above to receive nearby passenger ride dispatches.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DriverRequestCard onAcceptRide={handleAcceptRide} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="lg:col-span-7 bg-card border border-border rounded-xl p-3 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Navigation className="w-3.5 h-3.5 text-sky-600" />
|
||||
Tactical Navigation Stage
|
||||
</span>
|
||||
{activeRide && (
|
||||
<span className="text-xs bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300 px-2 py-0.5 rounded font-mono font-medium">
|
||||
Live Route Tracking
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MapView
|
||||
center={mapCenter}
|
||||
zoom={13}
|
||||
markers={mapMarkers}
|
||||
polyline={polyline}
|
||||
className="h-[520px] w-full rounded-lg border border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
396
frontend/src/pages/History.tsx
Normal file
@ -0,0 +1,396 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { StarRating } from '../components/StarRating';
|
||||
import { Ride, CompanionCircle, CompanionActivity } from '../types/ride';
|
||||
import { Calendar, Car, CheckCircle2, ChevronDown, ChevronUp, Clock, Filter, MapPin, Navigation, Users, XCircle } from 'lucide-react';
|
||||
|
||||
type TabType = 'all' | 'rides' | 'activities';
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Fetch rides
|
||||
const { data: ridesResponse, isLoading: isRidesLoading } = useQuery({
|
||||
queryKey: ['my-rides-history'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/rides/my');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch companion circles to retrieve activities
|
||||
const { data: circlesResponse, isLoading: isCirclesLoading } = useQuery({
|
||||
queryKey: ['my-circles-history'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/companion/circles/my');
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const rawRides: Ride[] = Array.isArray(ridesResponse?.rides) ? ridesResponse.rides : [];
|
||||
const circles: CompanionCircle[] = Array.isArray(circlesResponse?.circles) ? circlesResponse.circles : [];
|
||||
|
||||
// Process activities from circles
|
||||
const activities: (CompanionActivity & { circle_name?: string })[] = [];
|
||||
circles.forEach((circle) => {
|
||||
if (Array.isArray((circle as any).activities)) {
|
||||
(circle as any).activities.forEach((act: CompanionActivity) => {
|
||||
activities.push({
|
||||
...act,
|
||||
circle_name: circle.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter rides
|
||||
const filteredRides = rawRides.filter((ride) => {
|
||||
if (statusFilter !== 'all' && ride.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter activities
|
||||
const filteredActivities = activities.filter((act) => {
|
||||
if (statusFilter !== 'all' && act.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const isLoading = isRidesLoading || isCirclesLoading;
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" /> Completed
|
||||
</span>
|
||||
);
|
||||
case 'picked_up':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 border border-sky-300">
|
||||
<Navigation className="w-3.5 h-3.5 text-sky-600" /> In Transit
|
||||
</span>
|
||||
);
|
||||
case 'accepted':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-300">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-600" /> Accepted
|
||||
</span>
|
||||
);
|
||||
case 'requested':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-800 border border-slate-300">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-600" /> Requested
|
||||
</span>
|
||||
);
|
||||
case 'cancelled':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-100 text-rose-800 border border-rose-300">
|
||||
<XCircle className="w-3.5 h-3.5 text-rose-600" /> Cancelled
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-800 border border-slate-300">
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-slate-50 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 font-display">
|
||||
Activity and Trip History
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Review past ride bookings, status progressions, driver assignments, and companion circle activities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between border-b border-slate-200 pb-4">
|
||||
<div className="flex items-center gap-2 bg-slate-200/80 p-1 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
activeTab === 'all'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
All Activity
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('rides')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
activeTab === 'rides'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Car className="w-3.5 h-3.5" /> Rides ({filteredRides.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('activities')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
activeTab === 'activities'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" /> Circle Events ({filteredActivities.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<Filter className="w-4 h-4 text-slate-500" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-xs bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-slate-700 focus:ring-1 focus:ring-sky-500 font-medium cursor-pointer"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="picked_up">In Transit</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="requested">Requested</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 bg-white border border-slate-200 rounded-xl animate-pulse p-4 flex flex-col justify-between">
|
||||
<div className="h-4 bg-slate-200 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-slate-100 rounded w-2/3"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Rides List */}
|
||||
{(activeTab === 'all' || activeTab === 'rides') && (
|
||||
<div className="space-y-3">
|
||||
{activeTab === 'all' && filteredRides.length > 0 && (
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-1">
|
||||
Rides ({filteredRides.length})
|
||||
</h2>
|
||||
)}
|
||||
{filteredRides.length === 0 && activeTab === 'rides' && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<Car className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-slate-800">No ride history found</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
When you request or complete trips, they will be listed here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{filteredRides.map((ride) => {
|
||||
const isExpanded = expandedId === ride.id;
|
||||
return (
|
||||
<div
|
||||
key={ride.id}
|
||||
className="bg-white border border-slate-200 rounded-xl overflow-hidden hover:border-slate-300 transition-all shadow-sm"
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleExpand(ride.id)}
|
||||
className="p-4 flex items-start justify-between gap-4 cursor-pointer hover:bg-slate-50/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className="p-2.5 rounded-lg bg-slate-100 text-slate-700 shrink-0 mt-0.5">
|
||||
<Car className="w-5 h-5 text-slate-800" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{formatDate(ride.created_at)}
|
||||
</span>
|
||||
{getStatusBadge(ride.status)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-900 truncate flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||
<span className="truncate">{ride.pickup_location}</span>
|
||||
<span className="text-slate-400 font-normal px-0.5">to</span>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-600 shrink-0" />
|
||||
<span className="truncate">{ride.destination_location}</span>
|
||||
</div>
|
||||
{ride.rating && ride.rating > 0 && (
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
<StarRating value={ride.rating} readonly size={14} />
|
||||
<span className="text-xs text-slate-500 font-medium">
|
||||
({ride.rating}/5)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-600 p-1 shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-slate-100 bg-slate-50/50 text-xs text-slate-600 space-y-2.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="bg-white p-3 rounded-lg border border-slate-200 space-y-1">
|
||||
<p className="font-medium text-slate-500 text-[11px] uppercase tracking-wider">
|
||||
Passenger Detail
|
||||
</p>
|
||||
<p className="text-slate-900 font-semibold">
|
||||
{ride.passenger_name || ride.passenger_email || 'Passenger'}
|
||||
</p>
|
||||
{ride.passenger_phone && (
|
||||
<p className="text-slate-600">Phone: {ride.passenger_phone}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-slate-200 space-y-1">
|
||||
<p className="font-medium text-slate-500 text-[11px] uppercase tracking-wider">
|
||||
Driver Detail
|
||||
</p>
|
||||
{ride.driver_email ? (
|
||||
<>
|
||||
<p className="text-slate-900 font-semibold">
|
||||
{ride.driver_name || ride.driver_email}
|
||||
</p>
|
||||
{ride.driver_phone && (
|
||||
<p className="text-slate-600">Phone: {ride.driver_phone}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-slate-500 italic">Driver not assigned yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-slate-500 text-[11px] pt-1">
|
||||
<span>Ride ID: {ride.id}</span>
|
||||
<span>Updated: {formatDate(ride.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Circle Activities List */}
|
||||
{(activeTab === 'all' || activeTab === 'activities') && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{activeTab === 'all' && filteredActivities.length > 0 && (
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-1">
|
||||
Circle Events ({filteredActivities.length})
|
||||
</h2>
|
||||
)}
|
||||
{filteredActivities.length === 0 && activeTab === 'activities' && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<Users className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-slate-800">No circle events recorded</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Join or create a companion circle to plan group transit activities.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{filteredActivities.map((act) => (
|
||||
<div
|
||||
key={act.id}
|
||||
className="bg-white border border-slate-200 rounded-xl p-4 space-y-2.5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-2 rounded-lg bg-sky-50 text-sky-700 shrink-0">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{act.title}</h3>
|
||||
{act.circle_name && (
|
||||
<p className="text-xs font-medium text-sky-700">{act.circle_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(act.status)}
|
||||
</div>
|
||||
|
||||
{act.description && (
|
||||
<p className="text-xs text-slate-600 line-clamp-2 pl-10">
|
||||
{act.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500 pl-10 flex-wrap pt-1 border-t border-slate-100">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400" /> {act.location_label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-400" /> {formatDate(act.starts_at)}
|
||||
</span>
|
||||
{act.max_attendees && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3.5 h-3.5 text-slate-400" /> Max {act.max_attendees}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'all' &&
|
||||
filteredRides.length === 0 &&
|
||||
filteredActivities.length === 0 && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-10 text-center">
|
||||
<Car className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||
<h3 className="text-base font-semibold text-slate-900">No activity found</h3>
|
||||
<p className="text-xs text-slate-500 mt-1 max-w-sm mx-auto">
|
||||
There are no rides or companion circle activities matching your filter criteria.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
frontend/src/pages/Landing.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Activity, ArrowRight, Car, Check, CheckCircle2, Clock, MapPin, Navigation, ShieldCheck, Smartphone, Zap } from 'lucide-react';
|
||||
|
||||
export default function Landing() {
|
||||
const [pickupPreview, setPickupPreview] = useState('100 Main St, San Francisco');
|
||||
const [dropoffPreview, setDropoffPreview] = useState('500 Oak Ave, San Francisco');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] text-[#000000] font-sans flex flex-col">
|
||||
|
||||
<header className="sticky top-0 z-50 h-16 bg-white/90 backdrop-blur-md border-b border-[#cbd5e1] px-4 md:px-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#0f172a] text-white flex items-center justify-center font-bold text-xl shadow-sm">
|
||||
<Navigation className="w-5 h-5 text-[#0284c7]" />
|
||||
</div>
|
||||
<span className="font-extrabold text-xl tracking-tight text-[#0f172a]">RideFlow</span>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8 text-sm font-semibold text-[#0f172a]">
|
||||
<a href="#hero-interactive-preview" className="hover:text-[#0284c7] transition-colors">
|
||||
Book Ride
|
||||
</a>
|
||||
<a href="#dual-role-workstation-overview" className="hover:text-[#0284c7] transition-colors">
|
||||
Driver Workstation
|
||||
</a>
|
||||
<a href="#live-telemetry-capabilities" className="hover:text-[#0284c7] transition-colors">
|
||||
Telemetry
|
||||
</a>
|
||||
<a href="#safety-companion-framework" className="hover:text-[#0284c7] transition-colors">
|
||||
Safety Circles
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="px-5 py-2.5 rounded-md bg-[#0f172a] hover:bg-[#1e293b] text-white font-semibold text-sm transition-all shadow-sm flex items-center gap-2"
|
||||
>
|
||||
<span>Sign In</span>
|
||||
<ArrowRight className="w-4 h-4 text-[#0284c7]" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<main className="flex-1">
|
||||
|
||||
<section id="hero-interactive-preview" className="py-12 md:py-20 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#e2e8f0] text-[#0369a1] text-xs font-semibold uppercase tracking-wider">
|
||||
<Zap className="w-3.5 h-3.5 fill-[#0284c7] text-[#0284c7]" />
|
||||
Urban Signal Precision Engine
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-[#0f172a] leading-[1.1] tracking-tight">
|
||||
Real-Time Urban Mobility, Zero Friction
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-[#64748b] max-w-2xl font-normal leading-relaxed">
|
||||
Book rides instantly, track driver telemetry live on the map, or go online as a driver with a single tap. Designed for instant dispatch and crystal-clear trip status.
|
||||
</p>
|
||||
|
||||
|
||||
<div className="bg-white p-4 rounded-xl border border-[#cbd5e1] shadow-sm space-y-3 max-w-xl">
|
||||
<div className="flex items-center gap-3 bg-[#f1f5f9] px-3.5 py-2.5 rounded-lg border border-[#e2e8f0]">
|
||||
<MapPin className="w-5 h-5 text-[#15803d] shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={pickupPreview}
|
||||
onChange={(e) => setPickupPreview(e.target.value)}
|
||||
placeholder="Enter pickup point..."
|
||||
className="bg-transparent border-none outline-none w-full text-sm font-medium text-[#0f172a] placeholder-[#64748b]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-[#f1f5f9] px-3.5 py-2.5 rounded-lg border border-[#e2e8f0]">
|
||||
<Navigation className="w-5 h-5 text-[#0284c7] shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={dropoffPreview}
|
||||
onChange={(e) => setDropoffPreview(e.target.value)}
|
||||
placeholder="Enter destination..."
|
||||
className="bg-transparent border-none outline-none w-full text-sm font-medium text-[#0f172a] placeholder-[#64748b]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="px-8 py-3.5 rounded-lg bg-[#0f172a] text-white font-bold text-base hover:bg-[#1e293b] transition-all shadow-md flex items-center gap-3"
|
||||
>
|
||||
<span>Request Ride Now</span>
|
||||
<ArrowRight className="w-5 h-5 text-[#0284c7]" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="px-6 py-3.5 rounded-lg bg-white border border-[#cbd5e1] text-[#0f172a] font-bold text-base hover:bg-[#f1f5f9] transition-all flex items-center gap-2"
|
||||
>
|
||||
<Car className="w-5 h-5 text-[#0f172a]" />
|
||||
<span>Drive with RideFlow</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="lg:col-span-5 relative">
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#cbd5e1] shadow-lg bg-white p-2">
|
||||
<img
|
||||
src="/assets/landing-01-hero-interactive-preview.png"
|
||||
alt="Interactive Vector Route Canvas for RideFlow"
|
||||
className="w-full h-auto object-cover rounded-xl"
|
||||
style={{ objectPosition: 'center' }}
|
||||
/>
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-[#0f172a]/90 backdrop-blur-md text-white p-3 rounded-lg border border-slate-700 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#15803d] animate-pulse"></span>
|
||||
<span className="font-semibold text-slate-200">Live Driver Telemetry Active</span>
|
||||
</div>
|
||||
<span className="font-mono text-[#0284c7]">4 Min Pickup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="dual-role-workstation-overview" className="py-16 bg-[#f1f5f9] border-y border-[#e2e8f0] px-4 md:px-8">
|
||||
<div className="max-w-7xl mx-auto space-y-12">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-[#0369a1]">
|
||||
DUAL WORKSTATION SYSTEM
|
||||
</p>
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-[#0f172a] tracking-tight">
|
||||
Engineered for Both Sides of the Journey
|
||||
</h2>
|
||||
<p className="text-base text-[#64748b] max-w-2xl mx-auto">
|
||||
Seamless passenger booking paired with a tactical high-density workstation for professional drivers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-stretch">
|
||||
|
||||
<div className="bg-white rounded-xl p-6 md:p-8 border border-[#cbd5e1] shadow-sm flex flex-col justify-between space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0284c7] font-bold">
|
||||
<Smartphone className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-[#0f172a]">Passenger Experience</h3>
|
||||
<p className="text-sm text-[#64748b] leading-relaxed">
|
||||
Select pickup and dropoff points with automatic fare calculations, monitor your driver's real-time vehicle movement on interactive maps, and rate your trip seamlessly.
|
||||
</p>
|
||||
<ul className="space-y-2.5 pt-2 text-sm text-[#0f172a]">
|
||||
<li className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||
<span>Upfront route preview and estimated fare transparency</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||
<span>Live driver GPS location polling with map markers</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||
<span>Sequential trip status updates: Accepted to Picked Up</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="w-full py-3 rounded-lg bg-[#0f172a] hover:bg-[#1e293b] text-white font-semibold text-sm transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>Launch Passenger Workstation</span>
|
||||
<ArrowRight className="w-4 h-4 text-[#0284c7]" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-white rounded-xl p-6 md:p-8 border border-[#cbd5e1] shadow-sm flex flex-col justify-between space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#15803d] font-bold">
|
||||
<Car className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-[#0f172a]">Driver Control Station</h3>
|
||||
<p className="text-sm text-[#64748b] leading-relaxed">
|
||||
Toggle online availability, review incoming dispatch requests with distance indicators, navigate sequential trip stages, and transmit live GPS telemetry.
|
||||
</p>
|
||||
<ul className="space-y-2.5 pt-2 text-sm text-[#0f172a]">
|
||||
<li className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||
<span>One-tap online/offline driver availability switch</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||
<span>Instant incoming request cards with pickup/destination</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#15803d] shrink-0" />
|
||||
<span>Automated location transmitter and status progression</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="w-full py-3 rounded-lg bg-[#0f172a] hover:bg-[#1e293b] text-white font-semibold text-sm transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>Launch Driver Workstation</span>
|
||||
<ArrowRight className="w-4 h-4 text-[#0284c7]" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="rounded-xl overflow-hidden border border-[#cbd5e1] bg-white p-2 shadow-sm">
|
||||
<img
|
||||
src="/assets/landing-02-dual-role-workstation-overview.png"
|
||||
alt="Passenger Booking Interface Vector Diagram for RideFlow"
|
||||
className="w-full h-auto object-cover rounded-lg"
|
||||
style={{ objectPosition: 'center' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SECTION 3: Live Telemetry Capabilities */}
|
||||
<section id="live-telemetry-capabilities" className="py-16 px-4 md:px-8 max-w-7xl mx-auto space-y-12">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-[#0369a1]">
|
||||
PLATFORM RELIABILITY
|
||||
</p>
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-[#0f172a] tracking-tight">
|
||||
Powered by High-Precision Telemetry
|
||||
</h2>
|
||||
<p className="text-base text-[#64748b] max-w-2xl mx-auto">
|
||||
Every trip state step is tracked deterministically with high-frequency GPS pinging and strict state order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||
<Activity className="w-5 h-5 text-[#0284c7]" />
|
||||
</div>
|
||||
<h4 className="font-bold text-lg text-[#0f172a]">Sequential Status Engine</h4>
|
||||
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||
Strict status ordering: Requested → Accepted → Picked Up → Completed, preventing invalid state transitions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||
<Navigation className="w-5 h-5 text-[#15803d]" />
|
||||
</div>
|
||||
<h4 className="font-bold text-lg text-[#0f172a]">GPS Location Polling</h4>
|
||||
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||
Active drivers transmit live latitude and longitude updates every few seconds directly to the tracking map.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||
<Clock className="w-5 h-5 text-[#b45309]" />
|
||||
</div>
|
||||
<h4 className="font-bold text-lg text-[#0f172a]">Cancellation Controls</h4>
|
||||
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||
Passengers can cancel ride requests at any time prior to pick up with instant status synchronization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl bg-white border border-[#cbd5e1] space-y-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#f1f5f9] flex items-center justify-center text-[#0f172a]">
|
||||
<Check className="w-5 h-5 text-[#0369a1]" />
|
||||
</div>
|
||||
<h4 className="font-bold text-lg text-[#0f172a]">Trip Rating System</h4>
|
||||
<p className="text-xs text-[#64748b] leading-relaxed">
|
||||
Prompt rating modal appears upon completion for passenger feedback and transparent ride record keeping.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl overflow-hidden border border-[#cbd5e1] bg-[#f1f5f9] p-2">
|
||||
<img
|
||||
src="/assets/landing-03-live-telemetry-capabilities.png"
|
||||
alt="Highlight technical platform reliability including GPS location sharing, for RideFlow"
|
||||
className="w-full h-auto object-cover rounded-lg"
|
||||
style={{ objectPosition: 'center' }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SECTION 4: Safety & Companion Framework */}
|
||||
<section id="safety-companion-framework" className="py-16 bg-[#0f172a] text-white px-4 md:px-8">
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-slate-800 text-[#0284c7] text-xs font-semibold uppercase tracking-wider border border-slate-700">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-[#15803d]" />
|
||||
Private Companion Safety System
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-white tracking-tight leading-tight">
|
||||
Travel with Confidence & Trusted Safety Circles
|
||||
</h2>
|
||||
|
||||
<p className="text-base text-slate-300 leading-relaxed max-w-xl">
|
||||
Configure your safety persona profile, grant safety consent, and initiate background check verification. Form companion circles for group activity planning and coordinated rides.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
||||
<div className="p-4 rounded-lg bg-slate-800/80 border border-slate-700 space-y-1.5">
|
||||
<span className="font-bold text-white text-sm block">Safety Persona Label</span>
|
||||
<p className="text-xs text-slate-400">Custom persona preferences and safety check-ins during night trips.</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-slate-800/80 border border-slate-700 space-y-1.5">
|
||||
<span className="font-bold text-white text-sm block">Background Check Verification</span>
|
||||
<p className="text-xs text-slate-400">Initiate optional safety background check with pending status tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/auth?returnTo=%2Fapp"
|
||||
className="inline-flex items-center gap-3 px-8 py-3.5 rounded-lg bg-[#0284c7] hover:bg-[#0369a1] text-white font-bold text-sm transition-all shadow-md"
|
||||
>
|
||||
<span>Get Started with Safety Setup</span>
|
||||
<ArrowRight className="w-4 h-4 text-white" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5">
|
||||
<div className="rounded-xl overflow-hidden border border-slate-700 p-2 bg-slate-800/50">
|
||||
<img
|
||||
src="/assets/landing-04-safety-companion-framework.png"
|
||||
alt="Companion Safety Verification Badge Graphic for RideFlow"
|
||||
className="w-full h-auto object-cover rounded-lg"
|
||||
style={{ objectPosition: 'center' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-[#cbd5e1] py-8 px-4 md:px-8">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-[#64748b]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-[#0f172a] text-white flex items-center justify-center font-bold text-xs">
|
||||
RF
|
||||
</div>
|
||||
<span className="font-bold text-[#0f172a]">RideFlow</span>
|
||||
<span>© {new Date().getFullYear()} RideFlow Platform. All rights reserved.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 font-medium text-[#0f172a]">
|
||||
<Link to="/auth?returnTo=%2Fapp" className="hover:text-[#0284c7]">
|
||||
Passenger Portal
|
||||
</Link>
|
||||
<Link to="/auth?returnTo=%2Fapp" className="hover:text-[#0284c7]">
|
||||
Driver Workstation
|
||||
</Link>
|
||||
<Link to="/auth?returnTo=%2Fapp" className="hover:text-[#0284c7]">
|
||||
Safety Setup
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
frontend/src/pages/Onboarding.tsx
Normal file
@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api } from '../api';
|
||||
import DriverProfileForm from '../components/features/DriverProfileForm';
|
||||
import SafetyOnboardingCard from '../components/features/SafetyOnboardingCard';
|
||||
import AdminStatsCards from '../components/features/AdminStatsCards';
|
||||
import AdminUserTable from '../components/features/AdminUserTable';
|
||||
import AdminRideLogsTable from '../components/features/AdminRideLogsTable';
|
||||
import { ArrowRight, Car, CheckCircle2, Compass, ShieldAlert, ShieldCheck, Sparkles, User } from 'lucide-react';
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<'passenger' | 'driver' | 'admin'>(
|
||||
user?.role === 'admin' ? 'admin' : user?.role === 'driver' ? 'driver' : 'passenger'
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<'role' | 'vehicle' | 'safety' | 'admin'>('role');
|
||||
const [driverProfileComplete, setDriverProfileComplete] = useState(false);
|
||||
const [safetyProfileComplete, setSafetyProfileComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkProfiles = async () => {
|
||||
try {
|
||||
const [driverRes, safetyRes] = await Promise.all([
|
||||
api.get('/driver/profile').catch(() => ({ data: { profile: null } })),
|
||||
api.get('/companion/safety-profile').catch(() => ({ data: { profile: null } })),
|
||||
]);
|
||||
|
||||
if (driverRes.data?.profile) {
|
||||
setDriverProfileComplete(true);
|
||||
}
|
||||
if (safetyRes.data?.profile) {
|
||||
setSafetyProfileComplete(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching profiles', err);
|
||||
}
|
||||
};
|
||||
checkProfiles();
|
||||
}, []);
|
||||
|
||||
const handleRoleSelect = (role: 'passenger' | 'driver' | 'admin') => {
|
||||
setSelectedRole(role);
|
||||
if (user && user.role !== role) {
|
||||
updateUser({ ...user, role });
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueFromRole = () => {
|
||||
if (selectedRole === 'driver') {
|
||||
setStep('vehicle');
|
||||
} else if (selectedRole === 'admin') {
|
||||
setStep('admin');
|
||||
} else {
|
||||
setStep('safety');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDriverFormSuccess = () => {
|
||||
setDriverProfileComplete(true);
|
||||
setStep('safety');
|
||||
};
|
||||
|
||||
const handleSafetyCompleted = () => {
|
||||
setSafetyProfileComplete(true);
|
||||
};
|
||||
|
||||
const handleFinishOnboarding = () => {
|
||||
if (selectedRole === 'driver') {
|
||||
navigate('/app/driver');
|
||||
} else {
|
||||
navigate('/app/ride');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-[#f8fafc] text-[#0f172a] px-4 py-8 md:py-12">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-semibold bg-[#0284c7]/10 text-[#0284c7] border border-[#0284c7]/20">
|
||||
<Sparkles size={14} />
|
||||
<span>Welcome to RideFlow Setup</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight text-[#0f172a] font-['Plus_Jakarta_Sans']">
|
||||
Configure Your Ride Experience
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-slate-600 max-w-xl mx-auto">
|
||||
Choose your primary role, register vehicle details if driving, and customize your safety companion profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 p-1.5 bg-slate-200/70 rounded-xl max-w-2xl mx-auto text-xs font-medium text-slate-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('role')}
|
||||
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
step === 'role'
|
||||
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<User size={14} />
|
||||
<span>1. Account Role</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedRole !== 'driver'}
|
||||
onClick={() => selectedRole === 'driver' && setStep('vehicle')}
|
||||
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
selectedRole !== 'driver'
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: step === 'vehicle'
|
||||
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Car size={14} />
|
||||
<span>2. Vehicle</span>
|
||||
{driverProfileComplete && <CheckCircle2 size={12} className="text-emerald-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('safety')}
|
||||
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
step === 'safety'
|
||||
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
<span>3. Safety</span>
|
||||
{safetyProfileComplete && <CheckCircle2 size={12} className="text-emerald-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('admin')}
|
||||
className={`py-2 px-3 rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
step === 'admin'
|
||||
? 'bg-white text-[#0f172a] font-bold shadow-sm'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<ShieldAlert size={14} />
|
||||
<span>4. Admin View</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 'role' && (
|
||||
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||
<div className="space-y-1 text-center md:text-left">
|
||||
<h2 className="text-xl font-bold text-[#0f172a]">Select Your Primary Role</h2>
|
||||
<p className="text-xs md:text-sm text-slate-500">
|
||||
You can switch between booking rides, driving, or platform administration at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
onClick={() => handleRoleSelect('passenger')}
|
||||
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all flex flex-col justify-between ${
|
||||
selectedRole === 'passenger'
|
||||
? 'border-[#0284c7] bg-sky-50/40 ring-1 ring-[#0284c7]'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-2.5 rounded-lg bg-sky-100 text-[#0284c7]">
|
||||
<User size={24} />
|
||||
</div>
|
||||
{selectedRole === 'passenger' && (
|
||||
<CheckCircle2 size={20} className="text-[#0284c7]" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#0f172a]">Passenger</h3>
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||
Request rides on interactive maps, view upfront route estimates, track assigned drivers live, and join companion circles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-200/60 flex items-center justify-between text-xs font-semibold text-[#0284c7]">
|
||||
<span>Instant Booking Access</span>
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => handleRoleSelect('driver')}
|
||||
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all flex flex-col justify-between ${
|
||||
selectedRole === 'driver'
|
||||
? 'border-[#0f172a] bg-slate-100/60 ring-1 ring-[#0f172a]'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-2.5 rounded-lg bg-slate-900 text-white">
|
||||
<Car size={24} />
|
||||
</div>
|
||||
{selectedRole === 'driver' && (
|
||||
<CheckCircle2 size={20} className="text-[#0f172a]" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#0f172a]">Driver</h3>
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||
Go online to receive nearby ride requests, update sequential trip progress, transmit live GPS position, and earn fare.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-200/60 flex items-center justify-between text-xs font-semibold text-[#0f172a]">
|
||||
<span>Requires Vehicle Info</span>
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => handleRoleSelect('admin')}
|
||||
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all flex flex-col justify-between ${
|
||||
selectedRole === 'admin'
|
||||
? 'border-purple-600 bg-purple-50/40 ring-1 ring-purple-600'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-2.5 rounded-lg bg-purple-100 text-purple-700">
|
||||
<ShieldAlert size={24} />
|
||||
</div>
|
||||
{selectedRole === 'admin' && (
|
||||
<CheckCircle2 size={20} className="text-purple-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#0f172a]">Administrator</h3>
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||
Access system analytics, monitor platform users, review real-time ride logs, and manage safety controls.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-200/60 flex items-center justify-between text-xs font-semibold text-purple-700">
|
||||
<span>System Management</span>
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||
<img
|
||||
src="/assets/application-01-role-selection.png"
|
||||
alt="Vehicle silhouette visual orientation"
|
||||
className="w-20 h-20 object-contain rounded-lg bg-white border border-slate-200 p-1 flex-shrink-0"
|
||||
/>
|
||||
<div className="text-xs text-slate-600 space-y-1">
|
||||
<p className="font-semibold text-slate-900">Seamless Dual-Role Platform</p>
|
||||
<p>
|
||||
RideFlow provides high-contrast workstation tools for both passengers and drivers with instant telemetry switching.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinueFromRole}
|
||||
className="px-6 py-2.5 bg-[#0f172a] text-white rounded-lg text-xs font-semibold hover:bg-slate-800 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<span>Continue Setup</span>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'vehicle' && selectedRole === 'driver' && (
|
||||
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#0f172a]">Driver Vehicle Registration</h2>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Provide your vehicle make, model, license plate, and color for passenger verification.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="/assets/application-02-driver-vehicle-form.png"
|
||||
alt="Driver Vehicle Registration"
|
||||
className="w-14 h-14 object-contain rounded-lg border border-slate-200 bg-slate-50 p-1 hidden sm:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DriverProfileForm onSuccess={handleDriverFormSuccess} />
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('role')}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Back to Role
|
||||
</button>
|
||||
{driverProfileComplete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('safety')}
|
||||
className="px-5 py-2 bg-[#0284c7] text-white rounded-lg text-xs font-semibold hover:bg-sky-700 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<span>Proceed to Safety Profile</span>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'safety' && (
|
||||
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||
<div className="border-b border-slate-200 pb-4">
|
||||
<h2 className="text-xl font-bold text-[#0f172a]">Companion Safety Configuration</h2>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Set up your private safety persona and optional background verification consent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SafetyOnboardingCard onCompleted={handleSafetyCompleted} />
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (selectedRole === 'driver' ? setStep('vehicle') : setStep('role'))}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinishOnboarding}
|
||||
className="px-6 py-2.5 bg-[#15803d] text-white rounded-lg text-xs font-semibold hover:bg-emerald-700 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
<span>Finish & Open Workstation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'admin' && (
|
||||
<div className="bg-white rounded-2xl border border-slate-300 p-6 md:p-8 shadow-sm space-y-6 animate-in fade-in duration-200">
|
||||
<div className="border-b border-slate-200 pb-4">
|
||||
<h2 className="text-xl font-bold text-[#0f172a]">System Administration Overview</h2>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Review platform telemetry, active user registries, and real-time ride logs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminStatsCards />
|
||||
|
||||
<div className="space-y-6 pt-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[#0f172a] mb-3">Platform User Directory</h3>
|
||||
<AdminUserTable />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[#0f172a] mb-3">Platform Ride Logs</h3>
|
||||
<AdminRideLogsTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('role')}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Back to Role
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinishOnboarding}
|
||||
className="px-6 py-2.5 bg-[#15803d] text-white rounded-lg text-xs font-semibold hover:bg-emerald-700 transition-colors flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
<span>Finish Setup</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-100 rounded-xl p-4 border border-slate-200 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Compass size={16} className="text-[#0284c7]" />
|
||||
<span>Already configured your profile? Jump directly to your destination workstation:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/app/ride')}
|
||||
className="px-3 py-1.5 bg-white border border-slate-300 text-[#0f172a] rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Passenger Map
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/app/driver')}
|
||||
className="px-3 py-1.5 bg-[#0f172a] text-white rounded-lg hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Driver Station
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
frontend/src/pages/PassengerRide.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import MapView, { MapMarker } from '../components/MapView';
|
||||
import RideBookingForm from '../components/features/RideBookingForm';
|
||||
import RideTrackingSheet from '../components/features/RideTrackingSheet';
|
||||
import { Ride } from '../types/ride';
|
||||
import { Car, Compass, MapPin, Navigation, RefreshCw, Shield } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function PassengerRide() {
|
||||
const { user } = useAuth();
|
||||
const [previewPickup, setPreviewPickup] = useState<[number, number] | null>(null);
|
||||
const [previewDestination, setPreviewDestination] = useState<[number, number] | null>(null);
|
||||
const [selectedRideId, setSelectedRideId] = useState<string | null>(null);
|
||||
|
||||
// Fetch active / recent rides for the current passenger
|
||||
const { data: ridesResponse, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['my-rides'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/rides/my');
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const myRides: Ride[] = useMemo(() => {
|
||||
if (ridesResponse && Array.isArray(ridesResponse.rides)) {
|
||||
return ridesResponse.rides;
|
||||
}
|
||||
return [];
|
||||
}, [ridesResponse]);
|
||||
|
||||
// Identify current active ride
|
||||
const activeRide = useMemo(() => {
|
||||
return myRides.find((r) => ['requested', 'accepted', 'picked_up'].includes(r.status)) || null;
|
||||
}, [myRides]);
|
||||
|
||||
const currentTrackingId = selectedRideId || (activeRide ? activeRide.id : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeRide && !selectedRideId) {
|
||||
setSelectedRideId(activeRide.id);
|
||||
}
|
||||
}, [activeRide, selectedRideId]);
|
||||
|
||||
// Compute center position for map
|
||||
const mapCenter: [number, number] = useMemo(() => {
|
||||
if (activeRide) {
|
||||
if (activeRide.driver_lat && activeRide.driver_lng) {
|
||||
return [Number(activeRide.driver_lat), Number(activeRide.driver_lng)];
|
||||
}
|
||||
return [Number(activeRide.pickup_lat), Number(activeRide.pickup_lng)];
|
||||
}
|
||||
if (previewPickup) {
|
||||
return previewPickup;
|
||||
}
|
||||
return [37.7749, -122.4194]; // Default SF coordinates
|
||||
}, [activeRide, previewPickup]);
|
||||
|
||||
// Compute map markers
|
||||
const mapMarkers: MapMarker[] = useMemo(() => {
|
||||
const markers: MapMarker[] = [];
|
||||
|
||||
if (activeRide) {
|
||||
markers.push({
|
||||
id: 'pickup',
|
||||
lat: Number(activeRide.pickup_lat),
|
||||
lng: Number(activeRide.pickup_lng),
|
||||
label: `Pickup: ${activeRide.pickup_location}`,
|
||||
type: 'pickup',
|
||||
});
|
||||
|
||||
markers.push({
|
||||
id: 'dest',
|
||||
lat: Number(activeRide.destination_lat),
|
||||
lng: Number(activeRide.destination_lng),
|
||||
label: `Destination: ${activeRide.destination_location}`,
|
||||
type: 'destination',
|
||||
});
|
||||
|
||||
if (
|
||||
activeRide.driver_lat !== undefined &&
|
||||
activeRide.driver_lat !== null &&
|
||||
activeRide.driver_lng !== undefined &&
|
||||
activeRide.driver_lng !== null
|
||||
) {
|
||||
markers.push({
|
||||
id: 'driver',
|
||||
lat: Number(activeRide.driver_lat),
|
||||
lng: Number(activeRide.driver_lng),
|
||||
label: activeRide.driver_name ? `Driver: ${activeRide.driver_name}` : 'Assigned Driver',
|
||||
type: 'driver',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (previewPickup) {
|
||||
markers.push({
|
||||
id: 'preview-pickup',
|
||||
lat: previewPickup[0],
|
||||
lng: previewPickup[1],
|
||||
label: 'Pickup Location',
|
||||
type: 'pickup',
|
||||
});
|
||||
}
|
||||
if (previewDestination) {
|
||||
markers.push({
|
||||
id: 'preview-dest',
|
||||
lat: previewDestination[0],
|
||||
lng: previewDestination[1],
|
||||
label: 'Destination Location',
|
||||
type: 'destination',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}, [activeRide, previewPickup, previewDestination]);
|
||||
|
||||
// Compute polyline route
|
||||
const polylineRoute: [number, number][] | undefined = useMemo(() => {
|
||||
if (activeRide) {
|
||||
const points: [number, number][] = [];
|
||||
if (activeRide.driver_lat && activeRide.driver_lng && activeRide.status === 'accepted') {
|
||||
points.push([Number(activeRide.driver_lat), Number(activeRide.driver_lng)]);
|
||||
}
|
||||
points.push([Number(activeRide.pickup_lat), Number(activeRide.pickup_lng)]);
|
||||
points.push([Number(activeRide.destination_lat), Number(activeRide.destination_lng)]);
|
||||
return points;
|
||||
}
|
||||
|
||||
if (previewPickup && previewDestination) {
|
||||
return [previewPickup, previewDestination];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [activeRide, previewPickup, previewDestination]);
|
||||
|
||||
const handleCoordinatesChange = (
|
||||
pickup: [number, number] | null,
|
||||
destination: [number, number] | null
|
||||
) => {
|
||||
setPreviewPickup(pickup);
|
||||
setPreviewDestination(destination);
|
||||
};
|
||||
|
||||
const handleRideCreated = (ride: Ride) => {
|
||||
if (ride && ride.id) {
|
||||
setSelectedRideId(ride.id);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRideCancelled = () => {
|
||||
setSelectedRideId(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleRideCompleted = () => {
|
||||
setSelectedRideId(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-64px)] overflow-hidden bg-slate-950 flex flex-col md:flex-row">
|
||||
|
||||
<div className="absolute top-4 left-4 right-4 z-20 pointer-events-none flex items-center justify-between gap-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 bg-slate-900/90 backdrop-blur-md border border-slate-700/60 px-3 py-1.5 rounded-full shadow-lg text-xs font-medium text-slate-200">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span className="hidden sm:inline text-slate-400">RideFlow Telemetry:</span>
|
||||
<span className="font-mono font-semibold text-emerald-400">GPS ACTIVE</span>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="p-2 rounded-full bg-slate-900/90 hover:bg-slate-800 border border-slate-700/60 text-slate-300 transition-colors shadow-lg"
|
||||
title="Refresh Dispatch"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefetching ? 'animate-spin text-sky-400' : ''}`} />
|
||||
</button>
|
||||
<Link
|
||||
to="/app/history"
|
||||
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-900/90 hover:bg-slate-800 border border-slate-700/60 text-xs font-medium text-slate-300 transition-colors shadow-lg"
|
||||
>
|
||||
<Compass className="w-3.5 h-3.5 text-sky-400" />
|
||||
<span>Ride History</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative w-full h-full flex-1 z-0">
|
||||
<MapView
|
||||
center={mapCenter}
|
||||
zoom={14}
|
||||
markers={mapMarkers}
|
||||
polyline={polylineRoute}
|
||||
className="w-full h-full min-h-[300px] md:min-h-full bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="z-10 w-full md:w-[420px] max-h-[60vh] md:max-h-full overflow-y-auto bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800 p-4 md:p-6 shadow-2xl flex flex-col justify-between">
|
||||
{currentTrackingId ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="w-5 h-5 text-sky-400" />
|
||||
<h2 className="text-base font-semibold text-slate-100">Active Ride Status</h2>
|
||||
</div>
|
||||
{activeRide && (
|
||||
<span className="text-xs font-mono uppercase px-2 py-0.5 rounded bg-sky-950 text-sky-300 border border-sky-800/60">
|
||||
{activeRide.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RideTrackingSheet
|
||||
rideId={currentTrackingId}
|
||||
onRideCancelled={handleRideCancelled}
|
||||
onRideCompleted={handleRideCompleted}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-100 tracking-tight">Request a Ride</h2>
|
||||
<p className="text-xs text-slate-400">Select pickup and destination on map or preset points</p>
|
||||
</div>
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<RideBookingForm
|
||||
onRideCreated={handleRideCreated}
|
||||
onSetCoordinates={handleCoordinatesChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="mt-6 pt-3 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span className="truncate max-w-[180px]">{user?.email || 'Passenger Workspace'}</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/app/onboarding"
|
||||
className="text-sky-400 hover:underline font-medium text-xs"
|
||||
>
|
||||
Safety Profile
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
frontend/src/pages/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { default as LandingPage } from './Landing';
|
||||
export { default as OnboardingPage } from './Onboarding';
|
||||
export { default as PassengerRidePage } from './PassengerRide';
|
||||
export { default as DriverWorkstationPage } from './DriverWorkstation';
|
||||
export { default as CirclesPage } from './Circles';
|
||||
export { default as HistoryPage } from './History';
|
||||
export { default as OnboardingPage } from './Onboarding';
|
||||
export { default as AdminPage } from './Admin';
|
||||
139
frontend/src/types/ride.ts
Normal file
@ -0,0 +1,139 @@
|
||||
export type UserRole = 'passenger' | 'driver' | 'admin' | 'member';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string | null;
|
||||
username?: string | null;
|
||||
role: UserRole;
|
||||
mauth_token?: string | null;
|
||||
phone?: string | null;
|
||||
citizen_id?: string | null;
|
||||
is_banned?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type BackgroundCheckStatus = 'not_started' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface CompanionSafetyProfile {
|
||||
user_email: string;
|
||||
persona_label: string;
|
||||
safety_consent: number;
|
||||
background_check_status: BackgroundCheckStatus;
|
||||
reviewed_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type CircleCadence = 'weekly' | 'biweekly' | 'monthly';
|
||||
export type CircleStatus = 'forming' | 'active' | 'paused';
|
||||
export type MemberStatus = 'invited' | 'accepted' | 'declined' | 'left';
|
||||
|
||||
export interface CompanionCircleMember {
|
||||
circle_id: string;
|
||||
user_email: string;
|
||||
status: MemberStatus;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CompanionCircle {
|
||||
id: string;
|
||||
name: string;
|
||||
cadence: CircleCadence;
|
||||
status: CircleStatus;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
members?: CompanionCircleMember[];
|
||||
}
|
||||
|
||||
export type ActivityStatus = 'planned' | 'cancelled' | 'completed';
|
||||
|
||||
export interface CompanionActivity {
|
||||
id: string;
|
||||
circle_id: string;
|
||||
created_by_email: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location_label: string;
|
||||
starts_at: string;
|
||||
max_attendees?: number | null;
|
||||
status: ActivityStatus;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type RsvpStatus = 'going' | 'maybe' | 'not_going';
|
||||
|
||||
export interface CompanionActivityRsvp {
|
||||
activity_id: string;
|
||||
user_email: string;
|
||||
status: RsvpStatus;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface DriverProfile {
|
||||
id: string;
|
||||
user_email: string;
|
||||
vehicle_make: string;
|
||||
vehicle_model: string;
|
||||
license_plate: string;
|
||||
vehicle_color: string;
|
||||
is_available: number;
|
||||
current_lat?: number | null;
|
||||
current_lng?: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type RideStatus = 'requested' | 'accepted' | 'picked_up' | 'completed' | 'cancelled';
|
||||
|
||||
export interface Ride {
|
||||
id: string;
|
||||
passenger_email: string;
|
||||
driver_email?: string | null;
|
||||
pickup_location: string;
|
||||
pickup_lat: number;
|
||||
pickup_lng: number;
|
||||
destination_location: string;
|
||||
destination_lat: number;
|
||||
destination_lng: number;
|
||||
status: RideStatus;
|
||||
driver_lat?: number | null;
|
||||
driver_lng?: number | null;
|
||||
rating?: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
passenger_name?: string | null;
|
||||
passenger_phone?: string | null;
|
||||
driver_name?: string | null;
|
||||
driver_phone?: string | null;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
total_users: number;
|
||||
active_drivers: number;
|
||||
total_rides: number;
|
||||
active_rides: number;
|
||||
completed_rides: number;
|
||||
}
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'mention' | 'system';
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
recipient_email: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string | null;
|
||||
actor_email?: string | null;
|
||||
is_read: number;
|
||||
created_at?: string;
|
||||
}
|
||||
80
frontend/tailwind.config.js
Normal file
@ -0,0 +1,80 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
foreground: 'hsl(var(--success-foreground))',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(var(--warning))',
|
||||
foreground: 'hsl(var(--warning-foreground))',
|
||||
},
|
||||
info: {
|
||||
DEFAULT: 'hsl(var(--info))',
|
||||
foreground: 'hsl(var(--info-foreground))',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
transit: {
|
||||
slate: '#0f172a',
|
||||
surface: '#ffffff',
|
||||
canvas: '#f8fafc',
|
||||
border: '#cbd5e1',
|
||||
telemetry: '#0284c7',
|
||||
emerald: '#15803d',
|
||||
amber: '#b45309',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 6px)',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"Plus Jakarta Sans"', 'sans-serif'],
|
||||
body: ['"DM Sans"', 'sans-serif'],
|
||||
sans: ['"DM Sans"', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
transit: '0 10px 30px -5px rgba(15, 23, 42, 0.08)',
|
||||
floating: '0 20px 40px -15px rgba(15, 23, 42, 0.15)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
27
frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"react-query": "@tanstack/react-query",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
|
||||
return;
|
||||
}
|
||||
warn(warning);
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||