623 lines
28 KiB
TypeScript
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’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>
|
|
);
|
|
}
|