diff --git a/migrations/007_atomic_band_claim.sql b/migrations/007_atomic_band_claim.sql new file mode 100644 index 0000000..ffe8208 --- /dev/null +++ b/migrations/007_atomic_band_claim.sql @@ -0,0 +1,113 @@ +-- Migration: 007_atomic_band_claim.sql +-- Description: Replaces the multi-step client-side band claim flow with a single atomic RPC. +-- This eliminates race conditions where two users might claim the same band simultaneously, +-- and prevents partial failures (e.g., band assigned but token not consumed). + +CREATE OR REPLACE FUNCTION public.claim_band_atomic( + p_token text, + p_band_id text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_claim record; + v_band record; + v_now timestamptz := now(); +BEGIN + -- 1. Lock and validate the token claim + SELECT * INTO v_claim + FROM token_claims + WHERE token_hash = p_token + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'invalid_token', + 'message', 'Claim session not found.' + ); + END IF; + + IF v_claim.used_at IS NOT NULL THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'token_already_used', + 'message', 'This registration link has already been used.' + ); + END IF; + + IF v_claim.expires_at < v_now THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'expired_token', + 'message', 'This claim link has expired.' + ); + END IF; + + -- 2. Lock and validate the band + SELECT * INTO v_band + FROM bands + WHERE band_id = p_band_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'band_not_found', + 'message', 'That wristband number was not found.' + ); + END IF; + + IF v_band.status != 'available' THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'band_unavailable', + 'message', 'This wristband is already assigned.' + ); + END IF; + + -- 3. Execute the atomic updates + -- Note: We update bands first, then athletes, to satisfy the circular FK if deferred, + -- or just to maintain the same order the client used. + + UPDATE bands + SET + status = 'assigned', + athlete_id = v_claim.athlete_id, + assigned_at = v_now + WHERE band_id = p_band_id; + + UPDATE athletes + SET band_id = p_band_id + WHERE id = v_claim.athlete_id; + + UPDATE token_claims + SET used_at = v_now + WHERE token_hash = p_token; + + -- 4. Return success payload + RETURN jsonb_build_object( + 'success', true, + 'code', 'success', + 'athlete_id', v_claim.athlete_id, + 'band_id', p_band_id, + 'display_number', v_band.display_number + ); + +EXCEPTION WHEN OTHERS THEN + -- Catch any unexpected DB errors (e.g., constraint violations) and return a safe payload + -- The transaction will automatically roll back. + RETURN jsonb_build_object( + 'success', false, + 'code', 'claim_failed', + 'message', SQLERRM + ); +END; +$$; + +-- Grant execute permission to authenticated and anon roles +GRANT EXECUTE ON FUNCTION public.claim_band_atomic(text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.claim_band_atomic(text, text) TO anon; diff --git a/migrations/008_security_and_schema_alignment.sql b/migrations/008_security_and_schema_alignment.sql new file mode 100644 index 0000000..f0c814e --- /dev/null +++ b/migrations/008_security_and_schema_alignment.sql @@ -0,0 +1,88 @@ +-- Migration: 008_security_and_schema_alignment.sql +-- Description: Hardens RLS policies across core tables and fixes schema drift + +-- ============================================================================ +-- 1. SCHEMA DRIFT FIXES +-- ============================================================================ + +-- Fix 1.1: Add missing assigned_at column to bands table +-- This column is used by the claim_band_atomic RPC but was missing from migration 002 +ALTER TABLE bands ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMPTZ; + +-- Fix 1.2: Fix incidents.station_id foreign key type mismatch +-- stations.id is TEXT, but incidents.station_id was created as UUID in migration 005 +-- We must drop the constraint, change the type, and re-add the constraint +ALTER TABLE incidents DROP CONSTRAINT IF EXISTS incidents_station_id_fkey; +ALTER TABLE incidents ALTER COLUMN station_id TYPE TEXT USING station_id::TEXT; +ALTER TABLE incidents ADD CONSTRAINT incidents_station_id_fkey FOREIGN KEY (station_id) REFERENCES stations(id); + +-- Fix 1.3: Add missing columns to report_jobs table +-- These columns exist in supabase_schema.sql but were missing from migration 004 +ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS requested_by UUID REFERENCES auth.users(id); +ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS format TEXT DEFAULT 'pdf'; +ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS error_message TEXT; + +-- ============================================================================ +-- 2. RLS POLICY HARDENING +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- Table: token_claims +-- Issue: "Public Token Claims" FOR ALL USING (true) allowed full public read/write +-- Fix: Restrict to SELECT only. Mutations are handled exclusively by the RPC. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Token Claims" ON token_claims; + +CREATE POLICY "Public Read Own Token" ON token_claims + FOR SELECT USING (true); + +CREATE POLICY "Admin Full Access Tokens" ON token_claims + FOR ALL TO authenticated USING ( + EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin') + ); + +-- ---------------------------------------------------------------------------- +-- Table: athletes +-- Issue: "Public Update Athlete via ID" FOR UPDATE USING (true) allowed public mutation +-- Fix: Remove public UPDATE. The claim_band_atomic RPC handles band_id update securely. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Update Athlete via ID" ON athletes; + +-- ---------------------------------------------------------------------------- +-- Table: bands +-- Issue: "Public Update Band Claim" FOR UPDATE USING (true) allowed public mutation +-- Fix: Remove public UPDATE. The claim_band_atomic RPC handles band assignment securely. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Update Band Claim" ON bands; + +-- ---------------------------------------------------------------------------- +-- Table: results +-- Issue: "Admin Update Results" FOR UPDATE TO authenticated USING (true) allowed any staff +-- Fix: Restrict UPDATE to admins only. Staff should only INSERT (append-only). +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Admin Update Results" ON results; + +CREATE POLICY "Admin Update Results" ON results + FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin') + ); + +-- ---------------------------------------------------------------------------- +-- Table: profiles +-- Issue: "Public profiles are viewable by everyone." leaked staff names/roles +-- Fix: Restrict profile visibility to authenticated users only. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON profiles; + +CREATE POLICY "Authenticated Read Profiles" ON profiles + FOR SELECT TO authenticated USING (true); + +-- ---------------------------------------------------------------------------- +-- Table: device_status +-- Issue: "Public Device Status Update" FOR UPDATE USING (true) allowed public mutation +-- Fix: Restrict UPDATE to authenticated staff/admins. Stations can only INSERT (upsert). +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Device Status Update" ON device_status; + +CREATE POLICY "Staff Update Device Status" ON device_status + FOR UPDATE TO authenticated USING (true); diff --git a/src/App.tsx b/src/App.tsx index d18c58b..3a8060c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ const Home = lazy(() => import('./pages/Home')); const Register = lazy(() => import('./pages/Register')); const ClaimBand = lazy(() => import('./pages/ClaimBand')); const StaffLogin = lazy(() => import('./pages/StaffLogin')); +const StaffStationSelect = lazy(() => import('./pages/StaffStationSelect')); const StationMode = lazy(() => import('./pages/StationMode')); const AdminLogin = lazy(() => import('./pages/AdminLogin')); const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); @@ -37,6 +38,11 @@ export default function App() { } /> } /> } /> + + + + } /> diff --git a/src/hooks/useOfflineSync.ts b/src/hooks/useOfflineSync.ts index 6d5fbd6..dc1b39c 100644 --- a/src/hooks/useOfflineSync.ts +++ b/src/hooks/useOfflineSync.ts @@ -1,73 +1,192 @@ import { useEffect, useState, useCallback } from 'react'; -import { getOutboxItems, removeFromOutbox, OutboxItem } from '../lib/offline'; +import { + getOutboxItems, + getAllOutboxItems, + removeFromOutbox, + updateOutboxItem, + OutboxItem, + MAX_RETRIES, + computeNextRetryAt, +} from '../lib/offline'; import { supabase } from '../lib/supabase'; +// --------------------------------------------------------------------------- +// Dev-only logger -- never surfaces to public UI +// --------------------------------------------------------------------------- +function logDev(message: string, ...args: unknown[]): void { + if (import.meta.env.DEV) { + console.log(`[OfflineSync] ${message}`, ...args); + } +} +function logDevError(message: string, ...args: unknown[]): void { + if (import.meta.env.DEV) { + console.error(`[OfflineSync] ${message}`, ...args); + } +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- export function useOfflineSync() { const [isOnline, setIsOnline] = useState(navigator.onLine); const [pendingCount, setPendingCount] = useState(0); + const [retryingCount, setRetryingCount] = useState(0); + const [deadLetterCount, setDeadLetterCount] = useState(0); const [lastSyncTime, setLastSyncTime] = useState(null); - const updatePendingCount = useCallback(async () => { - const items = await getOutboxItems(); - setPendingCount(items.length); + // ------------------------------------------------------------------------- + // Refresh diagnostic counts from the full outbox (all statuses) + // ------------------------------------------------------------------------- + const updateCounts = useCallback(async () => { + const all = await getAllOutboxItems(); + setPendingCount(all.filter((i) => i.status === 'pending').length); + setRetryingCount(all.filter((i) => i.status === 'retrying').length); + setDeadLetterCount(all.filter((i) => i.status === 'dead_letter').length); + }, []); + + // ------------------------------------------------------------------------- + // Attempt to sync a single 'result' item + // ------------------------------------------------------------------------- + const syncResultItem = useCallback(async (item: OutboxItem): Promise => { + const { error } = await supabase.from('results').insert(item.payload); + + if (!error || error.code === '23505') { + // Success or idempotent duplicate -- remove from outbox + await removeFromOutbox(item.id); + + // Trigger report-completion check (best-effort; failure is non-fatal) + try { + const [{ data: athleteResults }, { data: eventData }] = await Promise.all([ + supabase + .from('results') + .select('drill_type') + .eq('athlete_id', item.payload.athlete_id), + supabase + .from('events') + .select('required_drills') + .eq('id', item.payload.event_id) + .single(), + ]); + + if (athleteResults && eventData?.required_drills) { + const completedDrills = new Set(athleteResults.map((r: { drill_type: string }) => r.drill_type)); + const allDone = eventData.required_drills.every((d: string) => completedDrills.has(d)); + if (allDone) { + await supabase.from('report_jobs').upsert( + { + athlete_id: item.payload.athlete_id, + event_id: item.payload.event_id, + status: 'pending', + }, + { onConflict: 'athlete_id' }, + ); + } + } + } catch (e) { + logDevError('Report trigger check failed for item', item.id, e); + } + + return true; + } + + // Transient failure -- caller will handle retry bookkeeping + logDevError('Sync error for result item', item.id, error); + return false; }, []); + // ------------------------------------------------------------------------- + // Attempt to sync a single 'device_status' item + // ------------------------------------------------------------------------- + const syncDeviceStatusItem = useCallback(async (item: OutboxItem): Promise => { + const { error } = await supabase.from('device_status').upsert(item.payload); + if (!error) { + await removeFromOutbox(item.id); + return true; + } + logDevError('Sync error for device_status item', item.id, error); + return false; + }, []); + + // ------------------------------------------------------------------------- + // Main sync loop -- processes all eligible items with retry governance + // ------------------------------------------------------------------------- const syncOutbox = useCallback(async () => { if (!navigator.onLine) return; + // getOutboxItems() already filters out dead_letter and not-yet-due items const items = await getOutboxItems(); if (items.length === 0) return; - console.log(`Syncing ${items.length} items...`); + logDev(`Syncing ${items.length} eligible item(s)...`); for (const item of items) { + // Mark as 'retrying' if this is not the first attempt + if (item.retry_count > 0 && item.status !== 'retrying') { + await updateOutboxItem(item.id, { status: 'retrying' }); + } + + let succeeded = false; + let errorMessage: string | null = null; + try { if (item.type === 'result') { - const { error } = await supabase.from('results').insert(item.payload); - // If error is duplicate (409/23505), we still remove it from outbox - if (!error || error.code === '23505') { - await removeFromOutbox(item.id); - - // Check if athlete has completed all drills to trigger report - try { - const [{ data: athleteResults }, { data: eventData }] = await Promise.all([ - supabase.from('results').select('drill_type').eq('athlete_id', item.payload.athlete_id), - supabase.from('events').select('required_drills').eq('id', item.payload.event_id).single() - ]); - - if (athleteResults && eventData?.required_drills) { - const completedDrills = new Set(athleteResults.map(r => r.drill_type)); - const allDone = eventData.required_drills.every((d: string) => completedDrills.has(d)); - - if (allDone) { - await supabase.from('report_jobs').upsert({ - athlete_id: item.payload.athlete_id, - event_id: item.payload.event_id, - status: 'pending' - }, { onConflict: 'athlete_id' }); - } - } - } catch (e) { - console.error('Report trigger check failed', e); - } - } else { - console.error('Sync error for item', item.id, error); - } + succeeded = await syncResultItem(item); } else if (item.type === 'device_status') { - const { error } = await supabase.from('device_status').upsert(item.payload); - if (!error) { - await removeFromOutbox(item.id); - } + succeeded = await syncDeviceStatusItem(item); + } else { + // Unknown item type -- treat as unrecoverable + logDevError('Unknown outbox item type, moving to dead_letter', item); + await updateOutboxItem(item.id, { + status: 'dead_letter', + error_message: `Unknown item type: ${(item as any).type}`, + last_attempt_at: Date.now(), + }); + continue; } } catch (err) { - console.error('Failed to sync item', item.id, err); + errorMessage = err instanceof Error ? err.message : String(err); + logDevError('Unexpected exception syncing item', item.id, err); + succeeded = false; + } + + if (!succeeded) { + const newRetryCount = item.retry_count + 1; + + if (newRetryCount >= MAX_RETRIES) { + // Exhausted all retries -- move to dead letter + logDev(`Item ${item.id} reached max retries (${MAX_RETRIES}). Moving to dead_letter.`); + await updateOutboxItem(item.id, { + retry_count: newRetryCount, + last_attempt_at: Date.now(), + next_retry_at: null, + error_message: errorMessage ?? item.error_message, + status: 'dead_letter', + }); + } else { + // Schedule next retry with exponential backoff + const nextRetryAt = computeNextRetryAt(newRetryCount); + logDev( + `Item ${item.id} failed (attempt ${newRetryCount}/${MAX_RETRIES}). ` + + `Next retry at ${new Date(nextRetryAt).toISOString()}.`, + ); + await updateOutboxItem(item.id, { + retry_count: newRetryCount, + last_attempt_at: Date.now(), + next_retry_at: nextRetryAt, + error_message: errorMessage ?? item.error_message, + status: 'retrying', + }); + } } } - await updatePendingCount(); + await updateCounts(); setLastSyncTime(new Date()); - }, [updatePendingCount]); + }, [syncResultItem, syncDeviceStatusItem, updateCounts]); + // ------------------------------------------------------------------------- + // Event listeners and polling interval + // ------------------------------------------------------------------------- useEffect(() => { const handleOnline = () => { setIsOnline(true); @@ -78,19 +197,36 @@ export function useOfflineSync() { window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); - updatePendingCount(); + updateCounts(); + // Poll every 30 s -- getOutboxItems() respects next_retry_at so this is safe const interval = setInterval(() => { if (navigator.onLine) syncOutbox(); - updatePendingCount(); - }, 30000); + updateCounts(); + }, 30_000); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); clearInterval(interval); }; - }, [syncOutbox, updatePendingCount]); + }, [syncOutbox, updateCounts]); - return { isOnline, pendingCount, lastSyncTime, syncOutbox, updatePendingCount }; + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + return { + isOnline, + /** Items in 'pending' status (never attempted). */ + pendingCount, + /** Items in 'retrying' status (failed at least once, still within retry budget). */ + retryingCount, + /** Items in 'dead_letter' status (exhausted all retries, requires manual review). */ + deadLetterCount, + /** Total active items = pendingCount + retryingCount. */ + activeCount: pendingCount + retryingCount, + lastSyncTime, + syncOutbox, + updateCounts, + }; } diff --git a/src/lib/offline.ts b/src/lib/offline.ts index 0bdde2d..fc8138b 100644 --- a/src/lib/offline.ts +++ b/src/lib/offline.ts @@ -1,14 +1,50 @@ -import { openDB, IDBPDatabase } from 'idb'; +import { openDB } from 'idb'; const DB_NAME = 'core_elite_combine_db'; -const DB_VERSION = 1; +// Bumped from 1 to 2 to add the 'status' index on the outbox store. +const DB_VERSION = 2; + +// --------------------------------------------------------------------------- +// Retry governance constants +// --------------------------------------------------------------------------- +export const MAX_RETRIES = 5; +export const BASE_DELAY_MS = 5_000; // 5 s +export const MAX_DELAY_MS = 300_000; // 5 min + +/** + * Compute the timestamp (ms since epoch) at which the next retry should be + * attempted, using capped exponential backoff. + * + * delay = min(BASE_DELAY_MS * 2^retryCount, MAX_DELAY_MS) + */ +export function computeNextRetryAt(retryCount: number): number { + const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount), MAX_DELAY_MS); + return Date.now() + delay; +} + +// --------------------------------------------------------------------------- +// OutboxItem -- extended schema (DB_VERSION 2) +// --------------------------------------------------------------------------- +export type OutboxStatus = 'pending' | 'retrying' | 'dead_letter' | 'synced'; export interface OutboxItem { - id: string; // client_result_id + /** Stable client-generated ID (client_result_id for results, station key for + * device_status). Used as the IDB keyPath -- put() is therefore idempotent. */ + id: string; type: 'result' | 'device_status'; payload: any; + /** Unix ms timestamp when the item was first created. */ timestamp: number; - attempts: number; + /** Total number of sync attempts made so far (including the current one). */ + retry_count: number; + /** Unix ms timestamp of the most recent sync attempt, or null if never tried. */ + last_attempt_at: number | null; + /** Unix ms timestamp before which the item must NOT be retried. */ + next_retry_at: number | null; + /** Most recent error message from a failed attempt. Dev-only display. */ + error_message: string | null; + /** Lifecycle status of this item. */ + status: OutboxStatus; } export interface AthleteCache { @@ -19,11 +55,21 @@ export interface AthleteCache { position?: string; } +// --------------------------------------------------------------------------- +// IndexedDB initialisation +// --------------------------------------------------------------------------- export async function initDB() { return openDB(DB_NAME, DB_VERSION, { - upgrade(db) { + upgrade(db, oldVersion) { if (!db.objectStoreNames.contains('outbox')) { - db.createObjectStore('outbox', { keyPath: 'id' }); + const store = db.createObjectStore('outbox', { keyPath: 'id' }); + store.createIndex('by_status', 'status'); + } else if (oldVersion < 2) { + const tx = db.transaction('outbox', 'readwrite'); + const existingStore = tx.objectStore('outbox'); + if (!existingStore.indexNames.contains('by_status')) { + existingStore.createIndex('by_status', 'status'); + } } if (!db.objectStoreNames.contains('athlete_cache')) { db.createObjectStore('athlete_cache', { keyPath: 'band_id' }); @@ -35,22 +81,103 @@ export async function initDB() { }); } -export async function addToOutbox(item: OutboxItem) { +// --------------------------------------------------------------------------- +// Outbox CRUD +// --------------------------------------------------------------------------- + +/** + * Add or replace an item in the outbox. Uses db.put() so calling this with + * the same id is safe -- it overwrites rather than duplicates. + * New items are created with sensible defaults for all v2 fields. + */ +export async function addToOutbox( + item: Omit + & Partial> +): Promise { const db = await initDB(); - await db.put('outbox', item); + const full: OutboxItem = { + retry_count: 0, + last_attempt_at: null, + next_retry_at: null, + error_message: null, + status: 'pending', + ...item, + }; + await db.put('outbox', full); } +/** + * Retrieve all items eligible for a sync attempt right now: + * status is 'pending' or 'retrying' AND next_retry_at is null or in the past. + */ export async function getOutboxItems(): Promise { + const db = await initDB(); + const all: OutboxItem[] = await db.getAll('outbox'); + const now = Date.now(); + return all.filter( + (item) => + (item.status === 'pending' || item.status === 'retrying') && + (item.next_retry_at === null || item.next_retry_at <= now), + ); +} + +/** + * Retrieve ALL items regardless of status -- used by diagnostics hooks. + */ +export async function getAllOutboxItems(): Promise { const db = await initDB(); return db.getAll('outbox'); } -export async function removeFromOutbox(id: string) { +/** + * Partially update a queued item (e.g. after a failed attempt). + */ +export async function updateOutboxItem( + id: string, + updates: Partial>, +): Promise { + const db = await initDB(); + const existing = await db.get('outbox', id); + if (!existing) return; + await db.put('outbox', { ...existing, ...updates }); +} + +export async function removeFromOutbox(id: string): Promise { const db = await initDB(); await db.delete('outbox', id); } -export async function cacheAthlete(athlete: AthleteCache) { +/** + * For device_status items: collapse any existing queued item for the same + * station/device key into a single entry with the latest payload. + * This prevents heartbeat spam from filling the outbox while offline. + * Callers must use a stable key such as device_status:${stationId}. + */ +export async function upsertDeviceStatusItem(stationId: string, payload: any): Promise { + const id = `device_status:${stationId}`; + const db = await initDB(); + const existing = await db.get('outbox', id); + + if (existing && (existing.status === 'pending' || existing.status === 'retrying')) { + await db.put('outbox', { + ...existing, + payload, + timestamp: Date.now(), + }); + } else { + await addToOutbox({ + id, + type: 'device_status', + payload, + timestamp: Date.now(), + }); + } +} + +// --------------------------------------------------------------------------- +// Athlete cache helpers (unchanged) +// --------------------------------------------------------------------------- +export async function cacheAthlete(athlete: AthleteCache): Promise { const db = await initDB(); await db.put('athlete_cache', athlete); } @@ -60,7 +187,7 @@ export async function getCachedAthlete(bandId: string): Promise { const db = await initDB(); await db.clear('athlete_cache'); -} + } diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index b9c37d5..195f407 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -1,130 +1,204 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { supabase } from '../lib/supabase'; import { motion } from 'motion/react'; -import { - Users, - CreditCard, - Activity, - CheckCircle, - Wifi, - WifiOff, +import { + Users, + CreditCard, + Activity, + CheckCircle, + Wifi, + WifiOff, Search, Download, - MoreVertical, AlertTriangle, Clock, - Home + Home, + ChevronLeft, + ChevronRight, + RefreshCw, } from 'lucide-react'; import { DRILL_CATALOG } from '../constants'; +const PAGE_SIZE = 25; +const COMPLETION_THRESHOLD = DRILL_CATALOG.length || 5; + +interface DashboardStats { + athletes: number; + bands: number; + results: number; + completed: number; +} + +interface AthleteRow { + id: string; + first_name: string; + last_name: string; + position: string | null; + bands: { display_number: number | null } | null; + results: { drill_type: string; value_num: number | null }[]; +} + +interface StationRow { + id: string; + name: string; + drill_type: string; + status: { is_online: boolean; last_seen_at: string; pending_queue_count: number } | null; +} + export default function AdminDashboard() { - const [stats, setStats] = useState({ - athletes: 0, - bands: 0, - results: 0, - completed: 0 - }); - const [stations, setStations] = useState([]); - const [athletes, setAthletes] = useState([]); - const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ athletes: 0, bands: 0, results: 0, completed: 0 }); + const [stations, setStations] = useState([]); + const [athletes, setAthletes] = useState([]); + const [totalAthletes, setTotalAthletes] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [statsLoading, setStatsLoading] = useState(true); + const [tableLoading, setTableLoading] = useState(true); + const [exportLoading, setExportLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const currentPageRef = useRef(currentPage); + currentPageRef.current = currentPage; - useEffect(() => { - async function fetchData() { - setLoading(true); - - // 1. Fetch Stats - const { count: athleteCount } = await supabase.from('athletes').select('*', { count: 'exact', head: true }); - const { count: bandCount } = await supabase.from('bands').select('*', { count: 'exact', head: true }).eq('status', 'assigned'); - const { count: resultCount } = await supabase.from('results').select('*', { count: 'exact', head: true }); - - // 2. Fetch Stations Health - const { data: stationList } = await supabase.from('stations').select('*'); - const { data: statusData } = await supabase.from('device_status').select('*'); - - const mergedStations = (stationList || []).map(s => { - const status = (statusData || []).find(st => st.station_id === s.id); + const fetchSummary = useCallback(async () => { + try { + const [ + { count: athleteCount }, + { count: bandCount }, + { count: resultCount }, + { data: resultAthleteIds }, + { data: stationList }, + { data: statusData }, + ] = await Promise.all([ + supabase.from('athletes').select('*', { count: 'exact', head: true }), + supabase.from('bands').select('*', { count: 'exact', head: true }).eq('status', 'assigned'), + supabase.from('results').select('*', { count: 'exact', head: true }), + supabase.from('results').select('athlete_id').limit(10000), + supabase.from('stations').select('id, name, drill_type'), + supabase.from('device_status').select('station_id, is_online, last_seen_at, pending_queue_count'), + ]); + + const countByAthlete: Record = {}; + for (const row of resultAthleteIds ?? []) { + countByAthlete[row.athlete_id] = (countByAthlete[row.athlete_id] ?? 0) + 1; + } + const completed = Object.values(countByAthlete).filter((n) => n >= COMPLETION_THRESHOLD).length; + + const mergedStations: StationRow[] = (stationList ?? []).map((s) => { + const ds = (statusData ?? []).find((st) => st.station_id === s.id); return { - ...s, - status: status || null + id: s.id, + name: s.name, + drill_type: s.drill_type, + status: ds ? { is_online: ds.is_online, last_seen_at: ds.last_seen_at, pending_queue_count: ds.pending_queue_count } : null, }; }); - - // 3. Fetch Athlete Progress - const { data: athleteData } = await supabase - .from('athletes') - .select('*, bands(display_number), results(drill_type)') - .order('created_at', { ascending: false }); - setStats({ - athletes: athleteCount || 0, - bands: bandCount || 0, - results: resultCount || 0, - completed: athleteData?.filter(a => { - // Dynamic completion check: has results for all required drills of the event - // For now, we'll assume a simple check if they have at least 5 results as a fallback - // or if they have results for all unique drill types in the catalog (if event required_drills is not available) - return a.results?.length >= 5; - }).length || 0 - }); - + setStats({ athletes: athleteCount ?? 0, bands: bandCount ?? 0, results: resultCount ?? 0, completed }); setStations(mergedStations); - setAthletes(athleteData || []); - setLoading(false); + } finally { + setStatsLoading(false); } + }, []); - fetchData(); - const interval = setInterval(fetchData, 30000); - return () => clearInterval(interval); + const fetchAthletePage = useCallback(async (page: number, search: string) => { + setTableLoading(true); + try { + const from = page * PAGE_SIZE; + const to = from + PAGE_SIZE - 1; + let query = supabase + .from('athletes') + .select('id, first_name, last_name, position, bands(display_number), results(drill_type, value_num)', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to); + + if (search.trim()) { + const isNumeric = /^\d+$/.test(search.trim()); + if (isNumeric) { + query = query.eq('bands.display_number', parseInt(search.trim(), 10)); + } else { + query = query.or(`first_name.ilike.%${search.trim()}%,last_name.ilike.%${search.trim()}%`); + } + } + + const { data, count, error } = await query; + if (error) { + if (import.meta.env.DEV) console.error('[AdminDashboard] fetchAthletePage error:', error); + return; + } + setAthletes((data as AthleteRow[]) ?? []); + setTotalAthletes(count ?? 0); + } finally { + setTableLoading(false); + } }, []); - const filteredAthletes = athletes.filter(a => - `${a.first_name} ${a.last_name}`.toLowerCase().includes(searchTerm.toLowerCase()) || - a.bands?.display_number?.toString().includes(searchTerm) - ); + useEffect(() => { + fetchSummary(); + fetchAthletePage(0, ''); + const interval = setInterval(fetchSummary, 30_000); + return () => clearInterval(interval); + }, [fetchSummary, fetchAthletePage]); - const exportCSV = () => { - const drillIds = DRILL_CATALOG.map(d => d.id); - const headers = ['ID', 'Number', 'Name', 'Position', ...drillIds.map(id => DRILL_CATALOG.find(d => d.id === id)?.label || id)]; - - const rows = athletes.map(a => { - const drillResults = drillIds.map(id => { - const res = a.results?.find((r: any) => r.drill_type === id); - return res ? res.value_num : ''; - }); + useEffect(() => { + fetchAthletePage(currentPage, searchTerm); + }, [currentPage, searchTerm, fetchAthletePage]); - return [ - a.id, - a.bands?.display_number || 'N/A', - `${a.first_name} ${a.last_name}`, - a.position || 'N/A', - ...drillResults - ]; - }); - - const csvContent = [headers, ...rows].map(e => e.join(",")).join("\n"); - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - link.setAttribute("download", `combine_results_${new Date().toISOString().split('T')[0]}.csv`); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + useEffect(() => { + const timer = setTimeout(() => { + setCurrentPage(0); + setSearchTerm(searchInput); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + const exportCSV = async () => { + setExportLoading(true); + try { + const drillIds = DRILL_CATALOG.map((d) => d.id); + const headers = ['ID', 'Number', 'Name', 'Position', ...drillIds.map((id) => DRILL_CATALOG.find((d) => d.id === id)?.label ?? id)]; + const { data: allAthletes, error } = await supabase + .from('athletes') + .select('id, first_name, last_name, position, bands(display_number), results(drill_type, value_num)') + .order('created_at', { ascending: false }) + .limit(2000); + if (error) { + if (import.meta.env.DEV) console.error('[AdminDashboard] exportCSV error:', error); + return; + } + const rows = (allAthletes as AthleteRow[]).map((a) => { + const drillResults = drillIds.map((id) => { + const res = a.results?.find((r) => r.drill_type === id); + return res?.value_num != null ? res.value_num : ''; + }); + return [a.id, a.bands?.display_number ?? 'N/A', `${a.first_name} ${a.last_name}`, a.position ?? 'N/A', ...drillResults]; + }); + const csvContent = [headers, ...rows].map((e) => e.join(',')).join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `combine_results_${new Date().toISOString().split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } finally { + setExportLoading(false); + } }; + const totalPages = Math.ceil(totalAthletes / PAGE_SIZE); + const canGoPrev = currentPage > 0; + const canGoNext = currentPage < totalPages - 1; + return (
- {/* Stats Grid */}
- } label="Athletes Registered" value={stats.athletes} color="blue" /> - } label="Bands Assigned" value={stats.bands} color="purple" /> - } label="Results Captured" value={stats.results} color="emerald" /> - } label="Completed Drills" value={stats.completed} color="amber" /> + } label="Athletes Registered" value={stats.athletes} color="blue" loading={statsLoading} /> + } label="Bands Assigned" value={stats.bands} color="purple" loading={statsLoading} /> + } label="Results Captured" value={stats.results} color="emerald" loading={statsLoading} /> + } label="Completed Drills" value={stats.completed} color="amber" loading={statsLoading} />
- {/* Athlete Table */}
-

Athlete Progress

+
+

Athlete Progress

+ {!tableLoading && {totalAthletes} total} +
- setSearchTerm(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} className="pl-10 pr-4 py-2 bg-white border border-zinc-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-zinc-900 w-64" />
@@ -184,129 +267,94 @@ export default function AdminDashboard() { - {filteredAthletes.map((athlete) => ( - - - {athlete.bands?.display_number || '--'} - - -
{athlete.first_name} {athlete.last_name}
-
{athlete.parent_email}
- - - {athlete.position || 'N/A'} - - -
-
-
-
- {athlete.results?.length || 0}/5 -
- - - {athlete.results?.length >= 5 ? ( - - Ready - - ) : ( - - Testing - - )} - - - ))} + {tableLoading ? ( + Loading... + ) : athletes.length === 0 ? ( + {searchTerm ? 'No athletes match your search.' : 'No athletes registered yet.'} + ) : ( + athletes.map((athlete) => { + const resultCount = athlete.results?.length ?? 0; + const progress = Math.min(Math.round((resultCount / COMPLETION_THRESHOLD) * 100), 100); + const isComplete = resultCount >= COMPLETION_THRESHOLD; + return ( + + {athlete.bands?.display_number ?? '—'} + +
{athlete.first_name} {athlete.last_name}
+ + {athlete.position ?? '—'} + +
+
+
+
+ {resultCount}/{COMPLETION_THRESHOLD} +
+ + + {isComplete ? ( + + Done + + ) : ( + + In Progress + + )} + + + ); + }) + )} + + {totalPages > 1 && ( +
+ Page {currentPage + 1} of {totalPages}  ·  {totalAthletes} athletes +
+ + +
+
+ )}
- {/* Station Health */}

Station Health

-
- {stations.length === 0 ? ( -
- No stations configured. -
+
+ {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}

- +
- -
- +
- {manualEntry && ( -
- - + Wristband Number (1-500) + + setBandNumber(e.target.value)} className="w-full p-4 text-2xl font-black bg-zinc-50 border border-zinc-100 rounded-2xl text-center outline-none focus:border-zinc-900" @@ -238,7 +345,7 @@ export default function ClaimBand() { autoFocus />
-
) : ( -

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')} +
-
)}
); -} + } 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 (
-

Choose Your Event

+

+ Choose Your Event +

Select an upcoming combine to begin your registration.

- {availableEvents.map(ev => ( + {availableEvents.map((ev) => ( @@ -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}

-
- -
- {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
  • -
-
- )}
); } + // ------------------------------------------------------------------------- + // 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

+
- - + First Name * + +
- - + Last Name * + +
+
+ + + {dateOfBirthError && ( +

{dateOfBirthError}

+ )} +
+
- - + Grade + + - - - - - - - - - + + {['6th', '7th', '8th', '9th', '10th', '11th', '12th'].map((g) => ( + + ))}
-
- -
-

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) => ( + + ))} +
- )} + {/* 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.

-
-
- - - - -
- -
- - -
-
- - -
-
+
+ +

Parent / Guardian

-
- - { - setSignature(url); - setError(null); - }} onClear={() => setSignature(null)} /> +
+ +
- {error && ( -
- -
-
{error.message}
- {error.details &&
{error.details}
} -
+
+
+ +
- )} +
+ + +
+
-
- + + )} + + {/* Step 3 — Waiver & Signature */} + {step === 3 && ( + +
+ +

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 +

+ )} +
+ +
)}
); -} + } 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() { />
- + +
+
+ ); + } + + return ( +
+
+
+
+

+ Select Station +

+

+ Choose the testing station you are operating today. +

+
+ +
+ + + {lastStation && ( + +
+
+
+ +
+
+

+ Last used +

+

{lastStation.name}

+

{lastStation.drill_type}

+
+
+ +
+
+ )} +
+ + {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)} + +
+
+ ))} +
+ )} + +
+ +
+
+
+ ); + }