256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
interface JoinRideModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onJoinSuccess: (code: string, rideTitle: string) => void;
|
|
}
|
|
|
|
export function JoinRideModal({ isOpen, onClose, onJoinSuccess }: JoinRideModalProps) {
|
|
const [code, setCode] = useState('');
|
|
const [isCameraActive, setIsCameraActive] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [successInfo, setSuccessInfo] = useState<{ title: string; lead: string } | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Reset state when modal is opened/closed
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setCode('');
|
|
setErrorMsg(null);
|
|
setSuccessInfo(null);
|
|
setIsCameraActive(false);
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Handle ESC key press
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isOpen) {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const raw = e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '');
|
|
setCode(raw);
|
|
if (errorMsg) setErrorMsg(null);
|
|
};
|
|
|
|
const processCode = (rideCode: string) => {
|
|
const cleanCode = rideCode.trim().toUpperCase();
|
|
if (!cleanCode || cleanCode.length < 4) {
|
|
setErrorMsg('Please enter a valid ride code (at least 4 characters).');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setErrorMsg(null);
|
|
|
|
// Simulated lookup
|
|
setTimeout(() => {
|
|
setIsSubmitting(false);
|
|
let resolvedTitle = 'Squad Formation Ride';
|
|
let resolvedLead = 'ApexLead';
|
|
|
|
if (cleanCode.includes('APEX') || cleanCode === 'CA-84' || cleanCode === 'APEX84') {
|
|
resolvedTitle = 'Bay Area Dawn Patrol';
|
|
resolvedLead = 'ApexLead';
|
|
} else if (cleanCode.includes('MALIBU') || cleanCode === 'MALIBU7') {
|
|
resolvedTitle = 'Malibu Canyons Fast Pace';
|
|
resolvedLead = 'Dan_KTM';
|
|
} else if (cleanCode.includes('DRAGON') || cleanCode === 'DRAGON318') {
|
|
resolvedTitle = 'Tail of the Dragon Run';
|
|
resolvedLead = 'Sweep_Marshal';
|
|
} else {
|
|
resolvedTitle = `Squad Run #${cleanCode}`;
|
|
resolvedLead = 'Pack Marshal';
|
|
}
|
|
|
|
setSuccessInfo({ title: resolvedTitle, lead: resolvedLead });
|
|
onJoinSuccess(cleanCode, resolvedTitle);
|
|
}, 400);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
processCode(code);
|
|
};
|
|
|
|
const handleSimulateScan = () => {
|
|
setCode('APEX84');
|
|
processCode('APEX84');
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="join-pack-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-space-md bg-surface-container-lowest/80 backdrop-blur-md"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div className="bg-surface-container-low max-w-md w-full rounded-2xl p-space-xl shadow-2xl relative border border-outline-variant/30 flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-space-md">
|
|
<div className="flex items-center gap-space-xs">
|
|
<span className="material-symbols-outlined text-primary-container text-[24px]">
|
|
qr_code_scanner
|
|
</span>
|
|
<h3 id="join-pack-title" className="font-headline-md text-headline-md text-on-surface">
|
|
Join Pack Formation
|
|
</h3>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-on-surface-variant hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
|
|
aria-label="Close modal"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Success State */}
|
|
{successInfo ? (
|
|
<div className="flex flex-col items-center text-center py-4">
|
|
<div className="w-14 h-14 rounded-full bg-secondary-fixed/20 text-secondary-fixed flex items-center justify-center mb-3">
|
|
<span className="material-symbols-outlined text-[32px]">check_circle</span>
|
|
</div>
|
|
<span className="font-label-caps text-label-caps text-secondary-fixed mb-1">
|
|
Telemetry Linked
|
|
</span>
|
|
<h4 className="font-headline-md text-headline-md text-on-surface mb-1">
|
|
{successInfo.title}
|
|
</h4>
|
|
<p className="font-body-sm text-body-sm text-on-surface-variant mb-6">
|
|
Lead Marshal: <span className="text-on-surface font-bold">{successInfo.lead}</span>.
|
|
Your telemetry stream is now synchronized with this pack.
|
|
</p>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full py-3 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all"
|
|
>
|
|
Open Pack Radar
|
|
</button>
|
|
</div>
|
|
) : (
|
|
/* Input / Scanner Flow */
|
|
<form onSubmit={handleSubmit} className="flex flex-col">
|
|
<p className="font-body-sm text-body-sm text-on-surface-variant mb-space-md">
|
|
Enter the encrypted 6-character ride code generated by your Lead Pace Setter or scan their cockpit radar QR display.
|
|
</p>
|
|
|
|
{/* Error Message */}
|
|
{errorMsg && (
|
|
<div className="mb-space-md p-space-sm rounded-lg bg-error-container text-on-error-container font-code-mono text-code-mono flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[18px]">error</span>
|
|
<span>{errorMsg}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Code Input */}
|
|
<div className="flex flex-col gap-space-md mb-space-lg">
|
|
<div className="relative">
|
|
<input
|
|
aria-label="Ride Code"
|
|
type="text"
|
|
maxLength={10}
|
|
value={code}
|
|
onChange={handleCodeChange}
|
|
placeholder="e.g. APEX84"
|
|
className="w-full bg-surface-container-lowest font-code-mono text-headline-md text-center tracking-widest text-primary px-space-md py-3 rounded-xl outline-none border border-outline-variant/30 focus:border-primary-container focus:ring-1 focus:ring-primary-container placeholder:text-on-surface-variant/40"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-space-md text-on-surface-variant font-code-mono text-code-mono justify-center text-xs">
|
|
<span className="h-px bg-outline-variant flex-1"></span>
|
|
<span>Or Scan Optical QR</span>
|
|
<span className="h-px bg-outline-variant flex-1"></span>
|
|
</div>
|
|
|
|
{/* Optical Lens / Camera Simulation */}
|
|
{isCameraActive ? (
|
|
<div
|
|
className="relative bg-black rounded-xl h-44 overflow-hidden border border-primary-container flex flex-col items-center justify-center p-3"
|
|
data-testid="radar-lens-viewfinder"
|
|
>
|
|
{/* Viewfinder crosshairs */}
|
|
<div className="absolute inset-4 border border-secondary-fixed/40 rounded pointer-events-none">
|
|
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-secondary-fixed"></div>
|
|
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-secondary-fixed"></div>
|
|
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-secondary-fixed"></div>
|
|
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-secondary-fixed"></div>
|
|
{/* Animated scanline */}
|
|
<div className="w-full h-0.5 bg-gradient-to-r from-transparent via-primary-container to-transparent animate-pulse mt-12"></div>
|
|
</div>
|
|
|
|
<span className="font-code-mono text-xs text-secondary-fixed mb-2 relative z-10">
|
|
Radar Optical Lens Active
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleSimulateScan}
|
|
className="relative z-10 px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container font-code-mono text-xs hover:bg-primary-fixed transition-colors"
|
|
>
|
|
Simulate QR Scan (APEX84)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCameraActive(false)}
|
|
className="mt-2 text-on-surface-variant hover:text-on-surface text-xs font-code-mono underline"
|
|
>
|
|
Switch back to manual entry
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={() => setIsCameraActive(true)}
|
|
className="bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 h-32 rounded-xl flex flex-col items-center justify-center gap-2 text-on-surface-variant hover:text-primary transition-all cursor-pointer group"
|
|
data-testid="activate-radar-lens-button"
|
|
>
|
|
<span className="material-symbols-outlined text-[32px] group-hover:scale-110 transition-transform text-primary-fixed-dim">
|
|
photo_camera
|
|
</span>
|
|
<span className="font-code-mono text-code-mono">Activate Radar Optical Lens</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-primary-container to-primary-fixed text-on-primary-container font-headline-sm text-headline-sm hover:shadow-[0_0_20px_rgba(0,242,254,0.3)] transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<span className="material-symbols-outlined text-[20px] animate-spin">refresh</span>
|
|
Linking Telemetry...
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="material-symbols-outlined text-[20px]">sync_alt</span>
|
|
Connect Squad Telemetry
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|