MotorSignal-App/website/components/founder-portal/founder-routes.tsx

623 lines
28 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import Link from 'next/link';
import {
type ClubRoute,
fetchClubRoutes,
createClubRoute,
deleteClubRoute,
} from '../../lib/club-community';
import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data';
interface FounderRoutesProps {
context: FounderClubContext;
}
export function FounderRoutes({ context }: FounderRoutesProps) {
const { club, permissions, currentUser, isDemoFixture } = context;
const [routes, setRoutes] = useState<ClubRoute[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [curvatureFilter, setCurvatureFilter] = useState<string>('all');
const [surfaceFilter, setSurfaceFilter] = useState<string>('all');
// Modal states
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [routeToDelete, setRouteToDelete] = useState<ClubRoute | null>(null);
// New route form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [distanceMiles, setDistanceMiles] = useState('35.0');
const [estimatedMinutes, setEstimatedMinutes] = useState('45');
const [elevationGainFt, setElevationGainFt] = useState('1500');
const [curvatureRating, setCurvatureRating] = useState<'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace'>('High Twisties');
const [surfaceType, setSurfaceType] = useState<'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass'>('Paved Asphalt');
const [startLocation, setStartLocation] = useState('');
const [endLocation, setEndLocation] = useState('');
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
async function loadRoutes() {
setLoading(true);
setError(null);
try {
const data = await fetchClubRoutes(club.id, { allowDemoPreview: isDemoFixture });
if (active) setRoutes(data);
} catch (err) {
if (active) setError(sanitizePortalError(err, 'Failed to load club routes.'));
} finally {
if (active) setLoading(false);
}
}
loadRoutes();
return () => {
active = false;
};
}, [club.id, isDemoFixture]);
const filteredRoutes = useMemo(() => {
return routes.filter((r) => {
const matchesSearch =
!searchQuery.trim() ||
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.curatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.startLocation.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.endLocation.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCurvature =
curvatureFilter === 'all' || r.curvatureRating === curvatureFilter;
const matchesSurface =
surfaceFilter === 'all' || r.surfaceType === surfaceFilter;
return matchesSearch && matchesCurvature && matchesSurface;
});
}, [routes, searchQuery, curvatureFilter, surfaceFilter]);
async function handleAddRoute(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !startLocation.trim() || !endLocation.trim()) {
setModalError('Title, start location, and end location are required.');
return;
}
setIsSubmitting(true);
setModalError(null);
try {
if (isDemoFixture) {
const newDemoRoute: ClubRoute = {
id: `demo-route-${Date.now()}`,
clubId: club.id,
title: title.trim(),
description: description.trim(),
distanceMiles: parseFloat(distanceMiles) || 20,
estimatedMinutes: parseInt(estimatedMinutes, 10) || 30,
elevationGainFt: parseInt(elevationGainFt, 10) || 500,
curvatureRating,
surfaceType,
startLocation: startLocation.trim(),
endLocation: endLocation.trim(),
curatorUid: currentUser.uid,
curatorName: 'Club Founder',
createdAt: new Date().toISOString(),
};
setRoutes([newDemoRoute, ...routes]);
} else {
await createClubRoute(club.id, {
title: title.trim(),
description: description.trim(),
distanceMiles: parseFloat(distanceMiles) || 20,
estimatedMinutes: parseInt(estimatedMinutes, 10) || 30,
elevationGainFt: parseInt(elevationGainFt, 10) || 500,
curvatureRating,
surfaceType,
startLocation: startLocation.trim(),
endLocation: endLocation.trim(),
});
const updated = await fetchClubRoutes(club.id);
setRoutes(updated);
}
setIsAddModalOpen(false);
setTitle('');
setDescription('');
setStartLocation('');
setEndLocation('');
} catch (err) {
setModalError(sanitizePortalError(err, 'Failed to save route.'));
} finally {
setIsSubmitting(false);
}
}
async function handleDeleteRoute(routeId: string) {
try {
if (isDemoFixture) {
setRoutes((prev) => prev.filter((r) => r.id !== routeId));
} else {
await deleteClubRoute(club.id, routeId);
setRoutes((prev) => prev.filter((r) => r.id !== routeId));
}
setRouteToDelete(null);
} catch (err) {
setError(sanitizePortalError(err, 'Failed to delete route.'));
}
}
function downloadGpx(route: ClubRoute) {
const gpxContent = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="MotorSignal Club Platform" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${route.title}</name>
<desc>${route.description}</desc>
</metadata>
<rte>
<name>${route.title}</name>
<rtept lat="34.526" lon="-83.984"><name>${route.startLocation}</name></rtept>
<rtept lat="34.685" lon="-83.916"><name>${route.endLocation}</name></rtept>
</rte>
</gpx>`;
const blob = new Blob([gpxContent], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${route.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.gpx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
const canCurate = permissions.canPlanRides || permissions.canManageSettings;
const hasExampleRoutes = routes.length > 0 && routes.every((r) => r.isExample);
return (
<div className="w-full space-y-6">
{/* Top Header Card */}
<div className="glass-card p-6 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-brand-border/40">
<div>
<div className="flex items-center gap-3">
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
Curated Club Routes
</h1>
<span
className={`font-mono text-xs px-2.5 py-0.5 rounded-full border font-semibold ${
hasExampleRoutes
? 'bg-amber-500/15 text-amber-300 border-amber-500/30'
: 'bg-brand-cyan/15 text-brand-cyan border-brand-cyan/30'
}`}
>
{hasExampleRoutes ? `${routes.length} Examples` : `${routes.length} Available`}
</span>
</div>
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
Curate twisty runs, elevation passes, and scenic loops for club group rides. Export GPX files directly for offline GPS units and cockpit navigation.
</p>
</div>
{canCurate && (
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-brand-cyan text-brand-bg font-body text-xs font-bold hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 shrink-0"
>
<span className="material-symbols-outlined text-[18px]">add_road</span>
<span>Curate Route</span>
</button>
)}
</div>
{hasExampleRoutes && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-200/90 text-xs flex items-center gap-2.5">
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">info</span>
<span>
These are <strong className="font-semibold text-amber-100">example routes</strong> — your club
hasn&rsquo;t curated any yet. Curate a route to replace them with your own.
</span>
</div>
)}
{error && (
<div className="p-4 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">error</span>
{error}
</span>
<button
onClick={() => setError(null)}
className="hover:text-white transition"
>
✕
</button>
</div>
)}
{/* Filter and Search Controls */}
<div className="glass-card p-4 rounded-xl border border-brand-border/30 flex flex-col md:flex-row items-stretch md:items-center gap-3 justify-between">
<div className="relative flex-1 min-w-[200px]">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-[18px]">
search
</span>
<input
type="text"
placeholder="Search routes by name, waypoint, or curator..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-surface-container/60 border border-brand-border/40 rounded-xl pl-9 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Curvature Filter */}
<select
value={curvatureFilter}
onChange={(e) => setCurvatureFilter(e.target.value)}
aria-label="Filter by Curvature"
className="bg-surface-container/80 border border-brand-border/40 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-brand-cyan/60"
>
<option value="all">All Curvatures</option>
<option value="High Twisties">High Twisties</option>
<option value="Moderate">Moderate</option>
<option value="Scenic Cruise">Scenic Cruise</option>
<option value="Track Pace">Track Pace</option>
</select>
{/* Surface Filter */}
<select
value={surfaceFilter}
onChange={(e) => setSurfaceFilter(e.target.value)}
aria-label="Filter by Surface"
className="bg-surface-container/80 border border-brand-border/40 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-brand-cyan/60"
>
<option value="all">All Surfaces</option>
<option value="Paved Asphalt">Paved Asphalt</option>
<option value="Mountain Pass">Mountain Pass</option>
<option value="Mixed Surface">Mixed Surface</option>
</select>
</div>
</div>
{/* Routes List Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((n) => (
<div key={n} className="glass-card p-5 rounded-2xl border border-brand-border/20 animate-pulse space-y-4">
<div className="h-6 w-3/4 bg-surface-container rounded" />
<div className="h-4 w-full bg-surface-container/60 rounded" />
<div className="h-16 w-full bg-surface-container/40 rounded-xl" />
</div>
))}
</div>
) : filteredRoutes.length === 0 ? (
<div className="glass-card p-12 rounded-2xl border border-brand-border/30 text-center space-y-3">
<span className="material-symbols-outlined text-4xl text-slate-500">route</span>
<h3 className="font-heading text-lg font-bold text-white">No Routes Found</h3>
<p className="font-body text-xs text-slate-400 max-w-sm mx-auto">
{searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all'
? 'No routes match your search or filter criteria. Try resetting your filters.'
: 'No club routes have been curated yet. Be the first to add a route for upcoming club rides!'}
</p>
{(searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all') && (
<button
onClick={() => {
setSearchQuery('');
setCurvatureFilter('all');
setSurfaceFilter('all');
}}
className="text-xs font-semibold text-brand-cyan hover:underline mt-2 inline-block"
>
Reset Filters
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filteredRoutes.map((route) => {
const isHighCurvature = route.curvatureRating === 'High Twisties';
const isModerate = route.curvatureRating === 'Moderate';
return (
<div
key={route.id}
className="glass-card rounded-2xl p-5 border border-brand-border/40 hover:border-brand-cyan/40 transition flex flex-col justify-between space-y-4"
>
<div className="space-y-3">
{/* Card Header & Badge */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-heading text-base font-bold text-white leading-snug">
{route.title}
</h3>
{route.isExample && (
<span className="inline-flex items-center gap-1 mt-1 font-mono text-[9px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold uppercase tracking-wider">
Example
</span>
)}
</div>
<span
className={`shrink-0 font-mono text-[10px] px-2 py-0.5 rounded font-semibold border ${
isHighCurvature
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
: isModerate
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/30'
: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
}`}
>
{route.curvatureRating}
</span>
</div>
<p className="font-body text-xs text-slate-300 line-clamp-2">
{route.description}
</p>
{/* Waypoint Strip */}
<div className="flex items-center gap-1.5 text-[11px] font-mono text-slate-400 bg-surface-container/60 px-3 py-1.5 rounded-lg border border-brand-border/20 truncate">
<span className="material-symbols-outlined text-[14px] text-brand-cyan shrink-0">
trip_origin
</span>
<span className="truncate">{route.startLocation}</span>
<span className="text-slate-600 shrink-0">➔</span>
<span className="truncate">{route.endLocation}</span>
</div>
{/* Metrics Bar */}
<div className="grid grid-cols-3 gap-2 py-2 border-y border-brand-border/20 text-center font-mono">
<div>
<div className="text-[10px] text-slate-500 uppercase">Distance</div>
<div className="text-xs font-bold text-white">{route.distanceMiles} mi</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase">Est. Time</div>
<div className="text-xs font-bold text-white">{route.estimatedMinutes} min</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase">Climb</div>
<div className="text-xs font-bold text-brand-cyan">+{route.elevationGainFt} ft</div>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[13px] text-slate-500">terrain</span>
<span>{route.surfaceType}</span>
</span>
<span className="text-slate-500">By {route.curatorName}</span>
</div>
</div>
{/* Card Actions */}
<div className="pt-2 flex items-center gap-2 justify-between border-t border-brand-border/20">
<button
onClick={() => downloadGpx(route)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container/80 hover:bg-surface-container border border-brand-border/40 text-slate-300 font-body text-xs font-semibold transition"
title="Export GPX waypoint file"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">download</span>
<span>GPX</span>
</button>
<div className="flex items-center gap-1.5">
<Link
href={`/clubs/${club.slug || club.id}/manage/rides${isDemoFixture ? '?preview=demo' : ''}`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-cyan/15 hover:bg-brand-cyan/25 border border-brand-cyan/40 text-brand-cyan font-body text-xs font-semibold transition"
>
<span className="material-symbols-outlined text-[15px]">add_road</span>
<span>Stage Ride</span>
</Link>
{canCurate && (
<button
onClick={() => setRouteToDelete(route)}
className="p-1.5 rounded-lg hover:bg-brand-crimson/10 text-slate-500 hover:text-brand-crimson border border-transparent hover:border-brand-crimson/30 transition"
title="Delete Route"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Curate Route Modal */}
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div
ref={modalRef}
className="glass-card w-full max-w-lg rounded-2xl p-6 border border-brand-border/50 shadow-2xl space-y-4 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between border-b border-brand-border/30 pb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-brand-cyan text-[22px]">add_road</span>
<h2 className="font-heading text-lg font-bold text-white">Curate Club Route</h2>
</div>
<button
onClick={() => setIsAddModalOpen(false)}
className="text-slate-400 hover:text-white transition"
>
✕
</button>
</div>
{modalError && (
<div className="p-3 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs">
{modalError}
</div>
)}
<form onSubmit={handleAddRoute} className="space-y-4 text-xs font-body">
<div>
<label className="block text-slate-300 font-semibold mb-1">Route Title *</label>
<input
type="text"
required
placeholder="e.g. Wolfpen Gap Apex Loop"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Description / Road Notes</label>
<textarea
rows={3}
placeholder="Notes on asphalt condition, high-lean apexes, gravel washouts, fuel stops..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Start Location *</label>
<input
type="text"
required
placeholder="e.g. Shell Outpost, Dahlonega"
value={startLocation}
onChange={(e) => setStartLocation(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">End Location *</label>
<input
type="text"
required
placeholder="e.g. Vogel State Park"
value={endLocation}
onChange={(e) => setEndLocation(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Distance (mi)</label>
<input
type="number"
step="0.1"
min="1"
value={distanceMiles}
onChange={(e) => setDistanceMiles(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Est. Duration (min)</label>
<input
type="number"
min="5"
value={estimatedMinutes}
onChange={(e) => setEstimatedMinutes(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Elevation (+ft)</label>
<input
type="number"
min="0"
value={elevationGainFt}
onChange={(e) => setElevationGainFt(e.target.value)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Curvature Rating</label>
<select
value={curvatureRating}
onChange={(e) => setCurvatureRating(e.target.value as any)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
>
<option value="High Twisties">High Twisties</option>
<option value="Moderate">Moderate</option>
<option value="Scenic Cruise">Scenic Cruise</option>
<option value="Track Pace">Track Pace</option>
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Surface Type</label>
<select
value={surfaceType}
onChange={(e) => setSurfaceType(e.target.value as any)}
className="w-full bg-surface-container border border-brand-border/40 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-brand-cyan/60"
>
<option value="Paved Asphalt">Paved Asphalt</option>
<option value="Mountain Pass">Mountain Pass</option>
<option value="Mixed Surface">Mixed Surface</option>
</select>
</div>
</div>
<div className="pt-3 flex items-center justify-end gap-3 border-t border-brand-border/30">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white transition"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-5 py-2 rounded-xl bg-brand-cyan text-brand-bg font-bold hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 disabled:opacity-50"
>
{isSubmitting ? 'Saving Route...' : 'Save & Publish Route'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{routeToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="glass-card w-full max-w-md rounded-2xl p-6 border border-brand-crimson/40 shadow-2xl space-y-4">
<div className="flex items-center gap-3 text-brand-crimson">
<span className="material-symbols-outlined text-[24px]">warning</span>
<h3 className="font-heading text-base font-bold text-white">Delete Club Route?</h3>
</div>
<p className="font-body text-xs text-slate-300">
Are you sure you want to remove <strong className="text-white">{routeToDelete.title}</strong> from the club catalog? This action cannot be undone.
</p>
<div className="flex items-center justify-end gap-3 pt-2">
<button
onClick={() => setRouteToDelete(null)}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white text-xs transition"
>
Cancel
</button>
<button
onClick={() => handleDeleteRoute(routeToDelete.id)}
className="px-4 py-2 rounded-xl bg-brand-crimson text-white font-bold text-xs hover:bg-brand-crimson/90 transition shadow-lg shadow-brand-crimson/20"
>
Confirm Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}