+ {statsLoading ? (
+
Loading stations...
+ ) : stations.length === 0 ? (
+
No stations configured.
) : (
stations.map((station) => {
- const status = station.status;
- const lastSeen = status ? new Date(status.last_seen_at) : null;
- const now = new Date();
- const diffMinutes = lastSeen ? Math.floor((now.getTime() - lastSeen.getTime()) / 60000) : null;
- const isStale = diffMinutes !== null && diffMinutes > 2;
- const isOffline = !status || !status.is_online || isStale;
-
- const lastSync = status?.last_sync_at ? new Date(status.last_sync_at) : null;
- const syncDiffMinutes = lastSync ? Math.floor((now.getTime() - lastSync.getTime()) / 60000) : null;
- const isSyncStale = syncDiffMinutes !== null && syncDiffMinutes > 10;
-
+ const isOnline = station.status?.is_online ?? false;
+ const lastSeen = station.status?.last_seen_at ? new Date(station.status.last_seen_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null;
+ const pendingCount = station.status?.pending_queue_count ?? 0;
return (
-
- {isOffline && status &&
}
- {!status &&
}
-
+
-
-
- {!isOffline ? : }
-
-
-
{station.name}
-
{status?.device_label || 'No device connected'}
-
-
-
- {!status ? 'Not Active' : !isOffline ? 'Online' : 'Offline'}
-
-
-
-
-
Outbox
-
50 ? 'text-red-600' : status?.pending_queue_count > 10 ? 'text-amber-600' : 'text-zinc-900'}`}>
- {status?.pending_queue_count ?? '--'}
-
+
{station.name}
+
{station.drill_type}
-
-
Last Sync
-
- {lastSync ? lastSync.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--'}
-
+
+ {isOnline ? : }
+ {isOnline ? 'Online' : 'Offline'}
-
-
- {status?.pending_queue_count > 50 && (
-
-
- Critical: Large pending queue
-
- )}
- {isSyncStale && (
-
-
- Warning: Stale sync ({syncDiffMinutes}m ago)
-
- )}
- {isStale && status && !status.is_online && (
-
-
- Device heartbeat lost
-
- )}
- {!status && (
-
-
- Waiting for first heartbeat
-
+
+ {lastSeen &&
Last seen {lastSeen}}
+ {pendingCount > 0 && (
+
+ {pendingCount} pending
+
)}
@@ -321,23 +369,35 @@ export default function AdminDashboard() {
);
}
-function StatCard({ icon, label, value, color }: { icon: React.ReactNode, label: string, value: number, color: string }) {
- const colors: Record
= {
- blue: 'bg-blue-50 text-blue-600',
- purple: 'bg-purple-50 text-purple-600',
- emerald: 'bg-emerald-50 text-emerald-600',
- amber: 'bg-amber-50 text-amber-600'
- };
+interface StatCardProps {
+ icon: React.ReactNode;
+ label: string;
+ value: number;
+ color: 'blue' | 'purple' | 'emerald' | 'amber';
+ loading?: boolean;
+}
+const COLOR_MAP: Record = {
+ blue: 'bg-blue-50 text-blue-600',
+ purple: 'bg-purple-50 text-purple-600',
+ emerald: 'bg-emerald-50 text-emerald-600',
+ amber: 'bg-amber-50 text-amber-600',
+};
+
+function StatCard({ icon, label, value, color, loading }: StatCardProps) {
return (
-
-
- {React.cloneElement(icon as any, { className: 'w-5 h-5' })}
+
+
+ {React.cloneElement(icon as React.ReactElement, { className: 'w-5 h-5' })}
-
{value}
-
{label}
+ {loading ? (
+
+ ) : (
+
{value.toLocaleString()}
+ )}
+
{label}
-
+
);
-}
+ }
diff --git a/src/pages/AdminLogin.tsx b/src/pages/AdminLogin.tsx
index 2c2e56b..b3e16db 100644
--- a/src/pages/AdminLogin.tsx
+++ b/src/pages/AdminLogin.tsx
@@ -76,7 +76,7 @@ export default function AdminLogin() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 bg-zinc-50 border border-zinc-200 rounded-xl outline-none focus:ring-2 focus:ring-zinc-900"
- placeholder="admin@coreelite.com"
+ placeholder="your@email.com"
required
/>
@@ -105,4 +105,4 @@ export default function AdminLogin() {
);
-}
+ }
diff --git a/src/pages/ClaimBand.tsx b/src/pages/ClaimBand.tsx
index e3b6f80..a9f98c0 100644
--- a/src/pages/ClaimBand.tsx
+++ b/src/pages/ClaimBand.tsx
@@ -5,39 +5,110 @@ import { QRScanner } from '../components/QRScanner';
import { motion } from 'motion/react';
import { QrCode, CheckCircle2, AlertCircle, User, ArrowLeft } from 'lucide-react';
+// ---------------------------------------------------------------------------
+// Public-safe error constants.
+// Technical error details (DB messages, constraint names, table names) are
+// intentionally never rendered in the UI. They are logged to the console in
+// development mode only.
+// ---------------------------------------------------------------------------
+const PUBLIC_ERROR_BODY =
+ 'Please try again in a moment. If the problem continues, ask event staff for help.';
+
+function logDevError(context: string, err: unknown) {
+ if (import.meta.env.DEV) {
+ console.error(`[ClaimBand] ${context}`, err);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// RPC error code -> public-safe UI message
+// Maps the structured codes returned by claim_band_atomic() to friendly text.
+// Internal codes are never rendered directly.
+// ---------------------------------------------------------------------------
+const RPC_ERROR_MESSAGES: Record
= {
+ invalid_token: 'This claim link is invalid. Please return to registration and try again.',
+ expired_token: 'This claim link has expired. Please return to registration to receive a new one.',
+ token_already_used: 'This registration link has already been used. If you need help, ask event staff.',
+ band_not_found: 'That wristband number was not found for this event.',
+ band_unavailable: 'This wristband is already assigned. Please scan a different one.',
+ athlete_not_found: "We couldn't locate your athlete record. Please ask event staff for help.",
+ claim_failed: "We're having trouble right now.",
+};
+
+// Token-level errors that should show the "invalid link" full-screen state
+// rather than an inline banner (because there is no valid athlete loaded yet).
+const LINK_LEVEL_CODES = new Set([
+ 'invalid_token',
+ 'expired_token',
+ 'token_already_used',
+]);
+
+// ---------------------------------------------------------------------------
+// Link error full-screen UI map
+// ---------------------------------------------------------------------------
+const LINK_ERROR_UI: Record = {
+ invalid_token: {
+ title: 'Invalid or missing link',
+ body: 'This claim link is invalid. Please return to registration and try again.',
+ },
+ expired_token: {
+ title: 'Link has expired',
+ body: 'This claim link has expired. Please return to registration to receive a new one.',
+ },
+ token_already_used: {
+ title: 'Wristband already claimed',
+ body: 'This registration link has already been used. If you need help, please ask event staff.',
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
export default function ClaimBand() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('athleteToken');
const [athlete, setAthlete] = useState(null);
+ // linkError holds a code from LINK_ERROR_UI -- shown before athlete is loaded
+ const [linkError, setLinkError] = useState(null);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ // claimError is an inline public-safe string shown after a failed claim attempt
+ const [claimError, setClaimError] = useState(null);
const [success, setSuccess] = useState(false);
- const [assignedBand, setAssignedBand] = useState(null);
+ const [assignedBand, setAssignedBand] = useState<{ display_number: number; band_id: string } | null>(null);
const [manualEntry, setManualEntry] = useState(false);
const [bandNumber, setBandNumber] = useState('');
+ // -------------------------------------------------------------------------
+ // On mount: validate the token and load the athlete.
+ // This is still a client-side read -- we only need to confirm the token is
+ // valid and load the athlete's name for display. The actual mutation is
+ // handled by the RPC.
+ // -------------------------------------------------------------------------
useEffect(() => {
if (!token) {
- setError('Invalid or missing claim token.');
+ setLinkError('invalid_token');
setLoading(false);
return;
}
async function fetchClaim() {
- const { data: claim, error: claimError } = await supabase
+ const { data: claim, error: claimErr } = await supabase
.from('token_claims')
.select('*, athletes(*)')
.eq('token_hash', token)
.single();
- if (claimError || !claim) {
- setError('Claim session not found.');
+ if (claimErr) {
+ logDevError('fetchClaim token lookup', claimErr);
+ setLinkError('invalid_token');
+ } else if (!claim) {
+ setLinkError('invalid_token');
} else if (claim.used_at) {
- setError('This wristband has already been claimed.');
+ setLinkError('token_already_used');
} else if (new Date(claim.expires_at) < new Date()) {
- setError('Claim session has expired.');
+ setLinkError('expired_token');
} else {
setAthlete(claim.athletes);
}
@@ -46,89 +117,109 @@ export default function ClaimBand() {
fetchClaim();
}, [token]);
+ // -------------------------------------------------------------------------
+ // handleClaim -- the single entry point for all claim attempts.
+ // Calls claim_band_atomic() which executes all DB mutations in one
+ // server-side transaction. If any step fails, the DB rolls back automatically.
+ // -------------------------------------------------------------------------
const handleClaim = async (bandId: string) => {
if (success || loading) return;
setLoading(true);
- setError(null);
+ setClaimError(null);
try {
- // Atomic Claim Logic
- // 1. Check if band is available
- const { data: band, error: bandError } = await supabase
- .from('bands')
- .select('*')
- .eq('band_id', bandId)
- .single();
+ const { data: result, error: rpcError } = await supabase.rpc('claim_band_atomic', {
+ p_token: token,
+ p_band_id: bandId,
+ });
- if (bandError || !band) throw new Error('Invalid wristband ID.');
- if (band.status !== 'available') throw new Error('This wristband is already assigned or void.');
-
- // 2. Update Band
- const { error: updateBandError } = await supabase
- .from('bands')
- .update({
- status: 'assigned',
- athlete_id: athlete.id,
- assigned_at: new Date().toISOString(),
- })
- .eq('band_id', bandId);
-
- if (updateBandError) throw updateBandError;
-
- // 3. Update Athlete
- const { error: updateAthleteError } = await supabase
- .from('athletes')
- .update({ band_id: bandId })
- .eq('id', athlete.id);
+ if (rpcError) {
+ // Network-level or Postgres-level error (not an application error)
+ logDevError('handleClaim rpc call', rpcError);
+ setClaimError(RPC_ERROR_MESSAGES['claim_failed']);
+ return;
+ }
- if (updateAthleteError) throw updateAthleteError;
+ // result is the jsonb payload returned by the function
+ if (!result?.success) {
+ const code: string = result?.code ?? 'claim_failed';
+ logDevError('handleClaim rpc returned failure', result);
- // 4. Mark Token Used
- await supabase
- .from('token_claims')
- .update({ used_at: new Date().toISOString() })
- .eq('token_hash', token);
+ if (LINK_LEVEL_CODES.has(code)) {
+ // Token became invalid between page load and claim attempt
+ // (e.g., another tab used it). Show the full-screen link error.
+ setLinkError(code);
+ setAthlete(null);
+ } else {
+ setClaimError(RPC_ERROR_MESSAGES[code] ?? RPC_ERROR_MESSAGES['claim_failed']);
+ }
+ return;
+ }
- setAssignedBand(band);
+ // Success
+ setAssignedBand({
+ band_id: result.band_id,
+ display_number: result.display_number,
+ });
setSuccess(true);
- } catch (err: any) {
- setError(err.message);
} finally {
setLoading(false);
}
};
+ // -------------------------------------------------------------------------
+ // handleManualSubmit -- resolves a display_number to a band_id, then
+ // delegates to handleClaim. The band lookup is a read-only SELECT; the
+ // mutation still goes through the RPC.
+ // -------------------------------------------------------------------------
const handleManualSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!bandNumber) return;
+ if (!bandNumber || !athlete) return;
setLoading(true);
- setError(null);
+ setClaimError(null);
try {
- // Lookup by display_number
const { data: band, error: bandError } = await supabase
.from('bands')
- .select('*')
+ .select('band_id, display_number, status')
.eq('event_id', athlete.event_id)
.eq('display_number', parseInt(bandNumber))
.single();
- if (bandError || !band) throw new Error('Wristband number not found for this event.');
-
+ if (bandError || !band) {
+ logDevError('handleManualSubmit band lookup', bandError);
+ setClaimError(RPC_ERROR_MESSAGES['band_not_found']);
+ return;
+ }
+
+ // Delegate to handleClaim -- it will call the RPC
await handleClaim(band.band_id);
- } catch (err: any) {
- setError(err.message);
+ } catch (err: unknown) {
+ logDevError('handleManualSubmit unexpected', err);
+ setClaimError(RPC_ERROR_MESSAGES['claim_failed']);
+ } finally {
setLoading(false);
}
};
- if (loading && !athlete && !error) return Verifying session...
;
+ // -------------------------------------------------------------------------
+ // Loading state -- shown while token is being validated on mount
+ // -------------------------------------------------------------------------
+ if (loading && !athlete && !linkError) {
+ return Verifying session...
;
+ }
- if (error && !athlete) {
+ // -------------------------------------------------------------------------
+ // Invalid / expired / already-claimed link screen.
+ // Shown when the token is bad before any athlete is loaded.
+ // No DB errors, table names, or stack traces are ever rendered here.
+ // -------------------------------------------------------------------------
+ if (linkError && !athlete) {
+ const { title, body } = LINK_ERROR_UI[linkError] ?? LINK_ERROR_UI['invalid_token'];
return (
-
-
Invalid or missing link
-
- This claim link is invalid or expired. Please return to registration and try again.
-
+
{title}
+
{body}
-
+
-
-
+
) : (
-
Wristband Assigned!
-
Athlete #{assignedBand.display_number} is ready for testing.
+
Athlete #{assignedBand?.display_number} is ready for testing.
-
+
-
Athlete Number
-
{assignedBand.display_number}
+
+ Athlete Number
+
+
+ {String(assignedBand?.display_number ?? '').padStart(3, '0')}
+
- navigate('/')}
className="w-full py-4 bg-zinc-900 text-white rounded-2xl font-bold hover:bg-zinc-800 transition-all"
>
- Return Home
+ Done -- Return Home
)}
);
-}
+ }
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx
index 28fdb06..6e6b5b0 100644
--- a/src/pages/Register.tsx
+++ b/src/pages/Register.tsx
@@ -7,6 +7,21 @@ import { ChevronRight, CheckCircle2, AlertCircle, ArrowLeft } from 'lucide-react
import { v4 as uuidv4 } from 'uuid';
import { Link } from 'react-router-dom';
+// ---------------------------------------------------------------------------
+// Public-safe error message shown to unauthenticated users.
+// Technical details are intentionally omitted from the UI and sent to the
+// console only in development mode.
+// ---------------------------------------------------------------------------
+const PUBLIC_ERROR_MESSAGE = "We're having trouble right now.";
+const PUBLIC_ERROR_BODY =
+ 'Please try again in a moment. If the problem continues, ask event staff for help.';
+
+function logDevError(context: string, err: unknown) {
+ if (import.meta.env.DEV) {
+ console.error(`[Register] ${context}`, err);
+ }
+}
+
export default function Register() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -14,7 +29,8 @@ export default function Register() {
const [event, setEvent] = useState
(null);
const [availableEvents, setAvailableEvents] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState<{ message: string; details?: string } | null>(null);
+ // `message` is always public-safe. `details` is NEVER rendered in the UI.
+ const [error, setError] = useState<{ message: string } | null>(null);
const [formData, setFormData] = useState({
firstName: '',
@@ -41,10 +57,10 @@ export default function Register() {
async function resolveEvent() {
setLoading(true);
setError(null);
-
+
try {
const paramSlug = searchParams.get('event') || searchParams.get('slug');
-
+
// 1. Try to fetch by slug if provided
if (paramSlug) {
const { data: slugEvent, error: slugError } = await supabase
@@ -52,15 +68,10 @@ export default function Register() {
.select('*')
.eq('slug', paramSlug)
.maybeSingle();
-
+
if (slugError) {
- const isTableMissing = slugError.message.includes('Could not find the table') || slugError.code === '42P01';
- setError({
- message: isTableMissing ? 'Database not initialized.' : 'Database error while looking up event.',
- details: isTableMissing
- ? 'The "events" table is missing from the database. Please run the schema migrations in Supabase.'
- : `RLS or Connection Error: ${slugError.message}`
- });
+ logDevError('resolveEvent › slug lookup', slugError);
+ setError({ message: PUBLIC_ERROR_MESSAGE });
setLoading(false);
return;
}
@@ -81,13 +92,8 @@ export default function Register() {
.limit(1);
if (liveError) {
- const isTableMissing = liveError.message.includes('Could not find the table') || liveError.code === '42P01';
- setError({
- message: isTableMissing ? 'Database not initialized.' : 'Error fetching active events.',
- details: isTableMissing
- ? 'The "events" table is missing from the database. Please run the schema migrations in Supabase.'
- : `RLS or Connection Error: ${liveError.message}`
- });
+ logDevError('resolveEvent › live event lookup', liveError);
+ setError({ message: PUBLIC_ERROR_MESSAGE });
setLoading(false);
return;
}
@@ -106,23 +112,19 @@ export default function Register() {
.order('created_at', { ascending: true });
if (allError) {
- const isTableMissing = allError.message.includes('Could not find the table') || allError.code === '42P01';
- setError({
- message: isTableMissing ? 'Database not initialized.' : 'Error fetching event list.',
- details: isTableMissing
- ? 'The "events" table is missing from the database. Please run the schema migrations in Supabase.'
- : `RLS or Connection Error: ${allError.message}`
- });
+ logDevError('resolveEvent › all events lookup', allError);
+ setError({ message: PUBLIC_ERROR_MESSAGE });
} else if (allEvents && allEvents.length > 0) {
setAvailableEvents(allEvents);
} else {
- setError({
- message: 'No events are currently scheduled.',
- details: 'The events table is empty or no events are in "live" or "draft" status.'
+ // No events scheduled — friendly, non-technical message
+ setError({
+ message: 'Registration is not open yet. Please check back closer to the event date.',
});
}
- } catch (err: any) {
- setError({ message: 'An unexpected error occurred.', details: err.message });
+ } catch (err: unknown) {
+ logDevError('resolveEvent › unexpected', err);
+ setError({ message: PUBLIC_ERROR_MESSAGE });
} finally {
setLoading(false);
}
@@ -181,7 +183,7 @@ export default function Register() {
media_release: formData.mediaRelease,
data_consent: formData.dataConsent,
marketing_consent: formData.marketingConsent,
- waiver_version: '2026.1'
+ waiver_version: '2026.1',
});
if (waiverError) throw waiverError;
@@ -203,19 +205,22 @@ export default function Register() {
if (tokenError) throw tokenError;
// 4. Create Parent Portal Token
- const portalToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
- await supabase
- .from('parent_portals')
- .insert({
- athlete_id: athlete.id,
- event_id: event.id,
- portal_token: portalToken
- });
+ const portalToken =
+ Math.random().toString(36).substring(2, 15) +
+ Math.random().toString(36).substring(2, 15);
+ await supabase.from('parent_portals').insert({
+ athlete_id: athlete.id,
+ event_id: event.id,
+ portal_token: portalToken,
+ });
// Success!
navigate(`/claim-band?athleteToken=${token}`);
- } catch (err: any) {
- setError({ message: 'Registration failed.', details: err.message });
+ } catch (err: unknown) {
+ logDevError('handleSubmit', err);
+ setError({
+ message: 'Registration could not be completed. Please try again or ask event staff for help.',
+ });
setLoading(false);
}
};
@@ -236,12 +241,14 @@ export default function Register() {
return (
- {availableEvents.map(ev => (
+ {availableEvents.map((ev) => (
navigate(`/register?event=${ev.slug}`)}
@@ -249,15 +256,17 @@ export default function Register() {
>
{ev.name}
-
+
{ev.status}
-
-
{new Date(ev.created_at).toLocaleDateString()}
-
•
+
{ev.location}
@@ -267,317 +276,444 @@ export default function Register() {
);
}
+ // -------------------------------------------------------------------------
+ // Public-safe error screen.
+ // Shows a friendly message only — no DB errors, table names, or migration
+ // instructions are ever rendered here.
+ // -------------------------------------------------------------------------
if (error && !event) {
return (
-
-
+
+
-
{error.message}
-
{error.details}
+
{error.message}
+
{PUBLIC_ERROR_BODY}
-
-
+ window.location.reload()}
- className="w-full py-3 bg-zinc-900 text-white rounded-xl font-bold hover:bg-zinc-800 transition-all"
+ className="w-full py-3 bg-zinc-900 text-white rounded-2xl font-bold hover:bg-zinc-800 transition-all"
>
Try Again
- navigate('/')}
- className="w-full py-3 bg-white border border-zinc-200 text-zinc-600 rounded-xl font-bold hover:bg-zinc-50 transition-all"
+ className="w-full py-3 bg-white border border-zinc-200 text-zinc-600 rounded-2xl font-bold hover:bg-zinc-50 transition-all"
>
Return Home
- {import.meta.env.DEV && (
-
-
Admin Troubleshooting
-
- - Check if an event exists in the
events table.
- - Ensure the event
status is set to 'live' or 'draft'.
- - Verify RLS policies allow public SELECT on
events.
- - Visit navigate('/admin/diagnostics')} className="underline">Diagnostics
-
-
- )}
);
}
+ // -------------------------------------------------------------------------
+ // Inline submission error banner (shown mid-form after a failed handleSubmit)
+ // -------------------------------------------------------------------------
+ const InlineErrorBanner = () =>
+ error ? (
+
+
+
+
{error.message}
+
{PUBLIC_ERROR_BODY}
+
+
+ ) : null;
+
+ // -------------------------------------------------------------------------
+ // Main multi-step registration form (unchanged structure)
+ // -------------------------------------------------------------------------
return (
-
-
+
Back to Home
-
- = 1 ? "text-zinc-900 font-bold" : ""}>1. Profile
-
- = 2 ? "text-zinc-900 font-bold" : ""}>2. Waiver
-
- = 3 ? "text-zinc-900 font-bold" : ""}>3. Claim Band
-
-
-
- Athlete Registration
-
-
{event?.name} • {event?.location}
+ {event && (
+
+
+
+ {event.status}
+
+
+
+ {event.name}
+
+ {event.location}
+
+ )}
+
+ {/* Step progress indicator */}
+
+ {[1, 2, 3].map((s) => (
+
+ = s
+ ? 'bg-zinc-900 text-white'
+ : 'bg-zinc-100 text-zinc-400'
+ }`}
+ >
+ {step > s ? : s}
+
+ {s < 3 && (
+ s ? 'bg-zinc-900' : 'bg-zinc-200'
+ }`}
+ />
+ )}
+
+ ))}
+
+
+ {/* Step 1 — Athlete Info */}
{step === 1 && (
-
+ Athlete Information
+
+
+
+
+ {dateOfBirthError && (
+
{dateOfBirthError}
+ )}
+
+
-
-
+ Grade
+
+
- {dateOfBirthError &&
{dateOfBirthError}
}
-
-
-
-
-
-
-
-
Parent/Guardian Information
-
-
+ Position
+
+
-
-
-
-
-
-
-
-
-
-
+ className="w-full p-3 bg-zinc-50 border border-zinc-200 rounded-xl outline-none focus:border-zinc-900 transition-colors"
+ >
+
+ {['QB', 'RB', 'WR', 'TE', 'OL', 'DL', 'LB', 'DB', 'K/P', 'ATH'].map((p) => (
+
+ ))}
+
- {
- if (!formData.firstName || !formData.lastName || !formData.date_of_birth || !formData.parentEmail) {
- if (!formData.date_of_birth) {
- setDateOfBirthError('Date of birth is required.');
- }
- setError({ message: 'Missing Required Fields', details: 'Please complete all required fields including Date of Birth.' });
+ if (!formData.firstName || !formData.lastName || !formData.date_of_birth) {
+ setDateOfBirthError(!formData.date_of_birth ? 'Date of birth is required.' : null);
return;
}
- setError(null);
- setDateOfBirthError(null);
setStep(2);
}}
- className="w-full py-4 bg-zinc-900 text-white rounded-2xl font-bold text-lg shadow-lg hover:bg-zinc-800 transition-all flex items-center justify-center gap-2"
+ className="w-full py-4 bg-zinc-900 text-white rounded-2xl font-bold flex items-center justify-center gap-2 hover:bg-zinc-800 transition-all"
>
- Continue to Waiver
-
+ Continue
)}
+ {/* Step 2 — Parent / Guardian Info */}
{step === 2 && (
-
-
-
Release of Liability & Consent
-
I, the undersigned parent/guardian, hereby give permission for the athlete named above to participate in the Core Elite Combine 2026. I understand that athletic testing involves inherent risks of injury.
-
I release Core Elite and its staff from any and all liability for injuries sustained during the event. I also consent to the use of any photos or videos taken during the event for promotional purposes.
-
In case of emergency, I authorize the staff to seek medical attention for the athlete if I cannot be reached immediately.
-
+
+
setStep(1)}
+ className="p-2 rounded-xl hover:bg-zinc-100 transition-colors"
+ >
+
+
+
Parent / Guardian
-
-
-
{
- setSignature(url);
- setError(null);
- }} onClear={() => setSignature(null)} />
+
+
+
- {error && (
-
-
-
-
{error.message}
- {error.details &&
{error.details}
}
-
+
-
-
setStep(1)}
- className="flex-1 py-4 border border-zinc-200 rounded-2xl font-bold text-zinc-600 hover:bg-zinc-50 transition-all"
+
+
+
+
+
+
+
+ {
+ if (!formData.parentName || !formData.parentEmail || !formData.parentPhone) return;
+ setStep(3);
+ }}
+ className="w-full py-4 bg-zinc-900 text-white rounded-2xl font-bold flex items-center justify-center gap-2 hover:bg-zinc-800 transition-all"
+ >
+ Continue
+
+
+ )}
+
+ {/* Step 3 — Waiver & Signature */}
+ {step === 3 && (
+
+
+
setStep(2)}
+ className="p-2 rounded-xl hover:bg-zinc-100 transition-colors"
>
- {loading ? 'Processing...' : 'Complete Registration'}
- {!loading && }
+
+
Waiver & Consent
+
+
+
+
Participation Waiver — Core Elite Combine 2026
+
+ By signing below, I acknowledge that participation in athletic combine activities
+ involves inherent risks of injury. I voluntarily assume all risks associated with
+ participation and release Core Elite and its organizers from any liability.
+
+
+ I consent to emergency medical treatment if necessary. I grant permission for
+ photographs and video taken during the event to be used for promotional purposes
+ unless I opt out below.
+
+
+
+ {[
+ { name: 'injuryWaiverAck', label: 'I acknowledge the injury waiver and assume all risks *' },
+ { name: 'dataConsent', label: "I consent to my athlete's data being stored and used for combine results *" },
+ { name: 'mediaRelease', label: 'I grant media release permission for photos/video (optional)' },
+ { name: 'marketingConsent', label: 'I agree to receive event updates and marketing communications (optional)' },
+ ].map(({ name, label }) => (
+
+ ))}
+
+
+
+
+
+ {signature && (
+
+ Signature captured
+
+ )}
+
+
+
+ {loading ? (
+ <>
+
+ Registering...
+ >
+ ) : (
+ <>
+ Complete Registration
+ >
+ )}
+
)}
);
-}
+ }
diff --git a/src/pages/StaffLogin.tsx b/src/pages/StaffLogin.tsx
index b0d87df..7bfa9ab 100644
--- a/src/pages/StaffLogin.tsx
+++ b/src/pages/StaffLogin.tsx
@@ -21,16 +21,17 @@ export default function StaffLogin() {
setError(error.message);
setLoading(false);
} else {
- // In a real app, we'd fetch the station config or list
- navigate('/staff/station/SPEED-1');
+ // Navigate to station selection so staff choose their own station.
+ // The previous hardcoded redirect to /staff/station/SPEED-1 has been removed.
+ navigate('/staff/select-station');
}
};
return (
-
@@ -57,19 +58,19 @@ export default function StaffLogin() {
- setEmail(e.target.value)}
className="w-full p-3 bg-zinc-50 border border-zinc-200 rounded-xl outline-none focus:ring-2 focus:ring-zinc-900"
- placeholder="coach@coreelite.com"
+ placeholder="your@email.com"
required
/>
- setPassword(e.target.value)}
@@ -79,7 +80,7 @@ export default function StaffLogin() {
/>
-
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [lastStationId, setLastStationId] = useState(null);
+ const [lastStation, setLastStation] = useState(null);
+
+ useEffect(() => {
+ const saved = localStorage.getItem(LAST_STATION_KEY);
+ if (saved) setLastStationId(saved);
+ loadStations(saved);
+ }, []);
+
+ const loadStations = async (savedId?: string | null) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const { data: events, error: eventError } = await supabase
+ .from('events')
+ .select('id, name, status')
+ .in('status', ['live', 'draft'])
+ .order('created_at', { ascending: false })
+ .limit(1);
+
+ if (eventError) {
+ logDevError('loadStations event lookup', eventError);
+ setError('Could not load event information. Please try again.');
+ return;
+ }
+
+ if (!events || events.length === 0) {
+ setError('No active event found. Please contact the event director.');
+ return;
+ }
+
+ const activeEvent = events[0];
+
+ const { data: stationData, error: stationError } = await supabase
+ .from('stations')
+ .select('id, name, drill_type, status, event_id')
+ .eq('event_id', activeEvent.id)
+ .order('name', { ascending: true });
+
+ if (stationError) {
+ logDevError('loadStations station lookup', stationError);
+ setError('Could not load stations. Please try again.');
+ return;
+ }
+
+ const list: Station[] = stationData ?? [];
+ setStations(list);
+
+ if (savedId) {
+ const match = list.find((s) => s.id === savedId);
+ setLastStation(match ?? null);
+ if (!match) {
+ localStorage.removeItem(LAST_STATION_KEY);
+ setLastStationId(null);
+ }
+ }
+ } catch (err: unknown) {
+ logDevError('loadStations unexpected', err);
+ setError('Something went wrong. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSelect = (station: Station) => {
+ localStorage.setItem(LAST_STATION_KEY, station.id);
+ navigate(`/staff/station/${station.id}`);
+ };
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ navigate('/staff/login');
+ };
+
+ const statusBadge = (status: string) => {
+ const map: Record = {
+ active: { label: 'Active', className: 'bg-emerald-100 text-emerald-700' },
+ inactive: { label: 'Inactive', className: 'bg-zinc-100 text-zinc-500' },
+ paused: { label: 'Paused', className: 'bg-amber-100 text-amber-700' },
+ };
+ const { label, className } = map[status] ?? { label: status, className: 'bg-zinc-100 text-zinc-500' };
+ return (
+
+ {label}
+
+ );
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+
+
Stations unavailable
+
{error}
+
+
loadStations(lastStationId)}
+ className="w-full py-3 bg-zinc-900 text-white rounded-2xl font-bold hover:bg-zinc-800 transition-all flex items-center justify-center gap-2"
+ >
+
+ Try Again
+
+
+ Sign Out
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Select Station
+
+
+ Choose the testing station you are operating today.
+
+
+
+
+ Sign out
+
+
+
+
+ {lastStation && (
+
+
+
+
+
+
+
+
+ Last used
+
+
{lastStation.name}
+
{lastStation.drill_type}
+
+
+
handleSelect(lastStation)}
+ className="shrink-0 px-5 py-2.5 bg-white text-zinc-900 rounded-2xl font-bold text-sm hover:bg-zinc-100 transition-all"
+ >
+ Rejoin
+
+
+
+ )}
+
+
+ {stations.length === 0 ? (
+
+
+
No stations found for this event.
+
Please contact the event director to set up stations.
+
+ ) : (
+
+
+ All Stations ({stations.length})
+
+ {stations.map((station) => (
+
handleSelect(station)}
+ whileTap={{ scale: 0.98 }}
+ className={`w-full bg-white p-5 rounded-2xl border text-left flex items-center justify-between gap-4 hover:border-zinc-400 hover:shadow-md transition-all ${
+ station.id === lastStationId
+ ? 'border-zinc-900 shadow-md'
+ : 'border-zinc-200'
+ }`}
+ >
+
+
+
+
+
+
{station.name}
+
{station.drill_type}
+
+
+
+ {statusBadge(station.status)}
+
+
+
+ ))}
+
+ )}
+
+
+ loadStations(lastStationId)}
+ className="text-zinc-400 hover:text-zinc-700 text-sm font-bold flex items-center gap-1.5 mx-auto transition-colors"
+ >
+
+ Refresh stations
+
+
+
+
+ );
+ }