397 lines
18 KiB
TypeScript
397 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|