// ROCA GOLF — API client, Toast, Modal, exportCSV const API_PREFIX = 'api/'; // ── Token CSRF de sesión (no es secreto: solo prueba que la petición viene de // esta página, no de un sitio externo aprovechando la cookie de auth) ──────── // Se guarda en memoria, no en almacenamiento persistente — se repone en cada // carga vía api/session.php o justo tras el login (ver index.php). let _csrfToken = null; function setCsrfToken(t) { _csrfToken = t; } function getCsrfToken() { return _csrfToken; } // Pide un CSRF fresco sin depender de apiFetch (evita recursión) — se usa para // autorecuperarse cuando el guardado de la sesión PHP (donde vive el CSRF) se // desincroniza de la sesión de la app (cookie rg_token, hasta 8h) — a veces // expira/se recicla antes, dejando al usuario con sesión iniciada pero el CSRF // ya inválido, sin que un simple recargar de página lo garantice arreglar. async function _refrescarCsrf() { try { const r = await fetch(API_PREFIX + 'session.php', { credentials: 'same-origin' }); const j = await r.json(); if (j.ok && j.data && j.data.csrf) { setCsrfToken(j.data.csrf); return true; } } catch { /* sin conexión u otra falla — se deja que falle normal */ } return false; } // ── Core fetch ───────────────────────────────────────────────────────────── // El token de sesión ya no se maneja aquí: viaja en una cookie HttpOnly que el // navegador adjunta solo ('credentials'). Las escrituras llevan el CSRF. async function apiFetch(path, method = 'GET', body = null, params = {}) { let url = API_PREFIX + path; const qs = Object.entries(params) .filter(([, v]) => v != null && v !== '') .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join('&'); if (qs) url += (url.includes('?') ? '&' : '?') + qs; function doFetch() { const headers = { 'Content-Type': 'application/json' }; if (method !== 'GET' && _csrfToken) headers['X-CSRF-Token'] = _csrfToken; const opts = { method, headers, credentials: 'same-origin' }; if (body && method !== 'GET') opts.body = JSON.stringify(body); return fetch(url, opts); } let res = await doFetch(); // 403 en una escritura: probablemente el CSRF quedó desincronizado (ver nota // arriba), no necesariamente que el usuario no tenga permiso. Se refresca UNA // vez y se reintenta — así no hace falta recargar la página a mano. if (res.status === 403 && method !== 'GET') { if (await _refrescarCsrf()) res = await doFetch(); } if (res.status === 401) { sessionStorage.clear(); window.location.reload(); throw new Error('Sesión expirada'); } const json = await res.json(); if (!json.ok) throw new Error(json.error || 'Error del servidor'); return json.data; } // ── Data hook ────────────────────────────────────────────────────────────── function useApiData(path, params = {}, skip = false) { const [data, setData] = useState(null); const [loading, setLoading] = useState(!skip); const [error, setError] = useState(null); const [rev, setRev] = useState(0); const paramsKey = JSON.stringify(params); useEffect(() => { if (skip) { setLoading(false); return; } let cancelled = false; setLoading(true); apiFetch(path, 'GET', null, params) .then(d => { if (!cancelled) { setData(d); setLoading(false); setError(null); } }) .catch(e => { if (!cancelled) { setError(e.message); setLoading(false); } }); return () => { cancelled = true; }; }, [path, paramsKey, rev, skip]); return { data, loading, error, reload: () => setRev(r => r + 1) }; } // ── Debounce hook ────────────────────────────────────────────────────────── function useDebounce(value, delay = 420) { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(t); }, [value, delay]); return debounced; } // ── Toast ────────────────────────────────────────────────────────────────── let _toastId = 0; let _toasts = []; const _toastSubs = new Set(); function _notifyToast() { _toastSubs.forEach(fn => fn([..._toasts])); } function toast(msg, type = 'success') { const id = ++_toastId; _toasts = [..._toasts, { id, msg, type }]; _notifyToast(); setTimeout(() => { _toasts = _toasts.filter(t => t.id !== id); _notifyToast(); }, 4500); } toast.error = msg => toast(msg, 'error'); toast.success = msg => toast(msg, 'success'); toast.info = msg => toast(msg, 'info'); function ToastContainer() { const [list, setList] = useState([]); useEffect(() => { _toastSubs.add(setList); return () => _toastSubs.delete(setList); }, []); if (!list.length) return null; return (