/* ============================================================ STORE · estado global + persistencia (localStorage) ============================================================ */ const { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } = React; const LS_KEY = 'granchimu_data_v1'; const LS_SNAP = 'granchimu_snaps_v1'; const LS_SESSION = 'granchimu_admin_session'; const LS_USER = 'granchimu_user'; const LS_KEYAUTH = 'granchimu_key'; const API = 'data.php'; // backend en el mismo directorio (Hostinger) /* ---- roles y permisos del panel ---- */ const ROLES = { admin: { label: 'Administrador', perms: ['contenido','reservas','dashboard','restaurante','usuarios'] }, editor: { label: 'Editor de carta', perms: ['contenido','dashboard'] }, reservas: { label: 'Recepción (reservas)', perms: ['reservas'] }, caja: { label: 'Caja / Consumos', perms: ['dashboard'] }, }; const StoreCtx = createContext(null); const useStore = () => useContext(StoreCtx); /* ---- helpers ---- */ const money = (n) => { if (n === null || n === undefined || n === '' || isNaN(n)) return '—'; return '$' + Math.round(n).toLocaleString('es-CL'); }; const todayISO = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }; const uid = (base='id') => base.toString().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'') .replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,28) + '-' + Math.random().toString(36).slice(2,7); // downscale + compress image to keep storage light function fileToDataURL(file, maxDim = 900, quality = 0.72) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const img = new Image(); img.onload = () => { let { width, height } = img; const scale = Math.min(1, maxDim / Math.max(width, height)); width = Math.round(width * scale); height = Math.round(height * scale); const c = document.createElement('canvas'); c.width = width; c.height = height; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); resolve(c.toDataURL('image/jpeg', quality)); }; img.onerror = reject; img.src = reader.result; }; reader.onerror = reject; reader.readAsDataURL(file); }); } // asegura campos nuevos (usuarios, etc.) en datos antiguos function normalize(d) { if (!d) return d; if (!d.users || !d.users.length) { d.users = [{ id:'u-admin', name:'Administrador', username:'admin', password:(d.brand && d.brand.adminPass) || 'chimu2027', role:'admin', active:true }]; } if (d.brand && d.brand.serverKey === undefined) d.brand.serverKey = (d.brand.adminPass) || 'chimu2027'; if (!d.reservations) d.reservations = []; if (!d.sales) d.sales = {}; return d; } function loadData() { try { const raw = localStorage.getItem(LS_KEY); if (raw) return normalize(JSON.parse(raw)); } catch (e) { console.warn('load failed', e); } return normalize(JSON.parse(JSON.stringify(window.SEED_DATA))); } /* ---- API servidor ---- */ async function apiGet() { try { const r = await fetch(API + '?action=get&t=' + Date.now(), { cache:'no-store' }); if (!r.ok) return null; const j = await r.json(); return (j && j.ok) ? j.data : null; // data puede ser null si aún no se ha guardado } catch (e) { return null; } } async function apiSave(data, key) { try { const r = await fetch(API + '?action=save', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ key, data }) }); const j = await r.json(); return j && j.ok; } catch (e) { return false; } } async function apiAddReservation(reservation) { try { const r = await fetch(API + '?action=reservation', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ reservation }) }); const j = await r.json(); return j && j.ok; } catch (e) { return false; } } function StoreProvider({ children }) { const [data, setData] = useState(loadData); const [toasts, setToasts] = useState([]); const [currentUser, setCurrentUser] = useState(() => { try { return JSON.parse(sessionStorage.getItem(LS_USER)||'null'); } catch(e){ return null; } }); const [admin, setAdmin] = useState(() => { try { return !!JSON.parse(sessionStorage.getItem(LS_USER)||'null'); } catch(e){ return false; } }); const [online, setOnline] = useState(null); // null=probando, true=servidor ok, false=solo local const [syncing, setSyncing] = useState(false); const adminRef = useRef(admin); const dataRef = useRef(data); const saveTimer = useRef(null); const syncingRef = useRef(false); const lastEditRef = useRef(0); useEffect(() => { dataRef.current = data; }, [data]); useEffect(() => { adminRef.current = admin; }, [admin]); const setSync = (v) => { syncingRef.current = v; setSyncing(v); }; // persist (cache local) useEffect(() => { try { localStorage.setItem(LS_KEY, JSON.stringify(data)); } catch (e) { toast('No se pudo guardar localmente (almacenamiento lleno). Reduce el peso de las fotos.', 'err'); } }, [data]); // ---- carga inicial desde el servidor ---- useEffect(() => { (async () => { try { const r = await fetch(API + '?action=get&t=' + Date.now(), { cache:'no-store' }); const txt = await r.text(); let j = null; try { j = JSON.parse(txt); } catch (e) { j = null; } if (r.ok && j && j.ok === true) { // backend PHP respondió correctamente setOnline(true); if (j.data) setData(normalize(j.data)); } else { setOnline(false); // sin backend válido -> modo local } } catch (e) { setOnline(false); } })(); }, []); // ---- sincronización automática entre dispositivos ---- useEffect(() => { if (online !== true) return; const tick = async () => { if (document.hidden) return; if (syncingRef.current) return; if (Date.now() - lastEditRef.current < 8000) return; // proteger edición en curso const server = await apiGet(); if (server && JSON.stringify(server) !== JSON.stringify(dataRef.current)) setData(normalize(server)); }; const id = setInterval(tick, 12000); const onFocus = () => { if (Date.now() - lastEditRef.current >= 4000) tick(); }; window.addEventListener('focus', onFocus); document.addEventListener('visibilitychange', onFocus); return () => { clearInterval(id); window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onFocus); }; }, [online]); // ---- guardar en el servidor (debounced) ---- const scheduleServerSave = useCallback((next) => { lastEditRef.current = Date.now(); if (online !== true || !adminRef.current) return; // solo admin con backend disponible if (saveTimer.current) clearTimeout(saveTimer.current); setSync(true); saveTimer.current = setTimeout(async () => { const ok = await apiSave(next, (next.brand && next.brand.serverKey) || ''); setSync(false); if (!ok) toast('No se pudo guardar en el servidor. Revisa la "clave del servidor" o los permisos de la carpeta.', 'err'); }, 500); }, [online]); // ---- publicar manualmente (botón) ---- const publish = async () => { if (online !== true) { toast('Sin conexión al servidor: los datos quedan solo en este equipo. Sube data.php a tu hosting.', 'err'); return; } setSync(true); const ok = await apiSave(dataRef.current, (dataRef.current.brand && dataRef.current.brand.serverKey) || ''); setSync(false); toast(ok ? 'Cambios publicados ✓ (visibles en todos los dispositivos)' : 'No se pudo publicar. Revisa la clave del servidor o los permisos.', ok ? 'ok' : 'err'); }; const toast = useCallback((msg, kind='ok') => { const id = Math.random().toString(36).slice(2); setToasts(t => [...t, { id, msg, kind }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 2600); }, []); // commit: clone-mutate pattern with snapshot history (keep last 3) const commit = useCallback((mutator, msg) => { setData(prev => { const snaps = JSON.parse(localStorage.getItem(LS_SNAP) || '[]'); snaps.push(prev); while (snaps.length > 3) snaps.shift(); localStorage.setItem(LS_SNAP, JSON.stringify(snaps)); const next = structuredClone(prev); mutator(next); scheduleServerSave(next); return next; }); if (msg) toast(msg); }, [toast, scheduleServerSave]); const undo = useCallback(() => { const snaps = JSON.parse(localStorage.getItem(LS_SNAP) || '[]'); if (!snaps.length) { toast('No hay cambios para deshacer', 'err'); return; } const last = snaps.pop(); localStorage.setItem(LS_SNAP, JSON.stringify(snaps)); setData(last); scheduleServerSave(last); toast('Se restauró la versión anterior'); }, [toast, scheduleServerSave]); /* ---- finders ---- */ const find = { carta: (d, cid) => d.cartas.find(c => c.id === cid), cat: (carta, catId) => carta.categories.find(c => c.id === catId), sub: (cat, subId) => cat.subcats.find(s => s.id === subId), }; /* ---- brand ---- */ const updateBrand = (patch) => commit(d => { Object.assign(d.brand, patch); }, 'Información guardada'); /* ---- generic move within array ---- */ const moveInArr = (arr, id, dir) => { const i = arr.findIndex(x => x.id === id); const j = i + dir; if (i < 0 || j < 0 || j >= arr.length) return; [arr[i], arr[j]] = [arr[j], arr[i]]; }; /* ---- categories ---- */ const addCategory = (cid, payload) => commit(d => { find.carta(d, cid).categories.push({ id: uid(payload.name), name: payload.name, subtitle: payload.subtitle||'', subcats: [] }); }, 'Categoría agregada'); const updateCategory = (cid, catId, patch) => commit(d => { Object.assign(find.cat(find.carta(d, cid), catId), patch); }, 'Categoría actualizada'); const deleteCategory = (cid, catId) => commit(d => { const carta = find.carta(d, cid); carta.categories = carta.categories.filter(c => c.id !== catId); }, 'Categoría eliminada'); const moveCategory = (cid, catId, dir) => commit(d => { moveInArr(find.carta(d, cid).categories, catId, dir); }); /* ---- subcategories ---- */ const addSub = (cid, catId, payload) => commit(d => { find.cat(find.carta(d, cid), catId).subcats.push({ id: uid(payload.name||'grupo'), name: payload.name||'', products: [] }); }, 'Subcategoría agregada'); const updateSub = (cid, catId, subId, patch) => commit(d => { Object.assign(find.sub(find.cat(find.carta(d, cid), catId), subId), patch); }, 'Subcategoría actualizada'); const deleteSub = (cid, catId, subId) => commit(d => { const cat = find.cat(find.carta(d, cid), catId); cat.subcats = cat.subcats.filter(s => s.id !== subId); }, 'Subcategoría eliminada'); const moveSub = (cid, catId, subId, dir) => commit(d => { moveInArr(find.cat(find.carta(d, cid), catId).subcats, subId, dir); }); /* ---- products ---- */ const blankProduct = () => ({ id: uid('prod'), name:'', desc:'', price:null, priceText:null, cost:null, recipe:'', photo:null, featured:false, active:true, variants:[] }); const addProduct = (cid, catId, subId, payload) => commit(d => { find.sub(find.cat(find.carta(d, cid), catId), subId).products.push({ ...blankProduct(), ...payload }); }, 'Producto agregado'); const updateProduct = (cid, catId, subId, prodId, patch) => commit(d => { const sub = find.sub(find.cat(find.carta(d, cid), catId), subId); Object.assign(sub.products.find(p => p.id === prodId), patch); }, 'Producto guardado'); const deleteProduct = (cid, catId, subId, prodId) => commit(d => { const sub = find.sub(find.cat(find.carta(d, cid), catId), subId); sub.products = sub.products.filter(p => p.id !== prodId); }, 'Producto eliminado'); const moveProduct = (cid, catId, subId, prodId, dir) => commit(d => { moveInArr(find.sub(find.cat(find.carta(d, cid), catId), subId).products, prodId, dir); }); // move product to another subcategory const reassignProduct = (cid, catId, subId, prodId, newCatId, newSubId) => commit(d => { const carta = find.carta(d, cid); const sub = find.sub(find.cat(carta, catId), subId); const idx = sub.products.findIndex(p => p.id === prodId); const [p] = sub.products.splice(idx, 1); find.sub(find.cat(carta, newCatId), newSubId).products.push(p); }, 'Producto movido'); /* ---- sales / consumos ---- */ const setSale = (date, prodId, qty) => commit(d => { if (!d.sales[date]) d.sales[date] = {}; if (qty > 0) d.sales[date][prodId] = qty; else delete d.sales[date][prodId]; if (Object.keys(d.sales[date]).length === 0) delete d.sales[date]; }); const saveSalesBatch = (date, entries) => commit(d => { const clean = {}; Object.entries(entries).forEach(([pid, q]) => { if (q > 0) clean[pid] = q; }); if (Object.keys(clean).length) d.sales[date] = clean; else delete d.sales[date]; }, 'Consumos guardados'); /* ---- flat product index (for dashboard / search) ---- */ const productIndex = useMemo(() => { const map = {}; data.cartas.forEach(carta => carta.categories.forEach(cat => cat.subcats.forEach(sub => sub.products.forEach(p => { map[p.id] = { ...p, cartaId: carta.id, cartaName: carta.name, catName: cat.name, subName: sub.name }; })))); return map; }, [data]); /* ---- reservations ---- */ const addReservation = (r) => { const full = { id: uid('rsv'), createdAt: new Date().toISOString(), status:'nueva', ...r }; commit(d => { if (!d.reservations) d.reservations = []; d.reservations.unshift(full); }); if (online === true) apiAddReservation(full); // persistir en el servidor (cliente sin clave) }; const updateReservation = (id, patch) => commit(d => { const r = (d.reservations||[]).find(x => x.id === id); if (r) Object.assign(r, patch); }); const deleteReservation = (id) => commit(d => { d.reservations = (d.reservations||[]).filter(x => x.id !== id); }, 'Reserva eliminada'); /* ---- export / import ---- */ const exportData = () => { const blob = new Blob([JSON.stringify(data, null, 2)], { type:'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `granchimu-datos-${todayISO()}.json`; a.click(); URL.revokeObjectURL(url); toast('Datos exportados'); }; const importData = (json) => { try { const parsed = typeof json === 'string' ? JSON.parse(json) : json; if (!parsed.cartas || !parsed.brand) throw new Error('formato'); commit(d => { Object.assign(d, parsed); }, 'Datos importados'); } catch (e) { toast('Archivo inválido', 'err'); } }; const resetData = () => { commit(d => { const s = JSON.parse(JSON.stringify(window.SEED_DATA)); Object.keys(d).forEach(k=>delete d[k]); Object.assign(d, s); }, 'Datos restablecidos'); }; /* ---- email (EmailJS · sin servidor) ---- */ const sendMail = async ({ to, subject, message }) => { const cfg = data.brand.emailjs; if (!cfg || !cfg.enabled || !cfg.publicKey || !cfg.serviceId || !cfg.templateId || !to) return false; try { if (!window.emailjs) { await new Promise((res, rej) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js'; s.onload = res; s.onerror = rej; document.head.appendChild(s); }); } window.emailjs.init({ publicKey: cfg.publicKey }); await window.emailjs.send(cfg.serviceId, cfg.templateId, { to_email: to, subject, message, from_name: data.brand.name }); return true; } catch (e) { console.warn('EmailJS error', e); return false; } }; /* ---- usuarios ---- */ const addUser = (u) => commit(d => { if (!d.users) d.users = []; d.users.push({ id: uid('user'), name:u.name||'', username:(u.username||'').trim().toLowerCase(), password:u.password||'', role:u.role||'editor', active:true }); }, 'Usuario creado'); const updateUser = (id, patch) => commit(d => { const u = (d.users||[]).find(x => x.id === id); if (u) { if (patch.username) patch.username = patch.username.trim().toLowerCase(); Object.assign(u, patch); } }, 'Usuario actualizado'); const deleteUser = (id) => commit(d => { d.users = (d.users||[]).filter(x => x.id !== id); }, 'Usuario eliminado'); /* ---- refrescar desde el servidor ---- */ const refreshFromServer = async () => { const server = await apiGet(); if (server) { setData(normalize(server)); setOnline(true); toast('Datos actualizados desde el servidor'); } else toast('No se pudo conectar al servidor', 'err'); }; /* ---- auth (usuarios + roles) ---- */ const login = (username, pass) => { const u = (data.users||[]).find(x => x.active && x.username === (username||'').trim().toLowerCase() && x.password === pass); const legacy = (!username && pass === data.brand.adminPass); if (u || legacy) { const user = u || { id:'u-admin', name:'Administrador', username:'admin', role:'admin' }; setAdmin(true); setCurrentUser(user); adminRef.current = true; sessionStorage.setItem(LS_SESSION,'1'); sessionStorage.setItem(LS_USER, JSON.stringify(user)); return true; } return false; }; const logout = () => { setAdmin(false); setCurrentUser(null); adminRef.current = false; sessionStorage.removeItem(LS_SESSION); sessionStorage.removeItem(LS_USER); }; const can = (perm) => { if (!currentUser) return false; const role = ROLES[currentUser.role] || ROLES.admin; return role.perms.includes(perm); }; const store = { data, find, money, todayISO, fileToDataURL, uid, toast, undo, admin, currentUser, login, logout, can, ROLES, online, syncing, refreshFromServer, publish, updateBrand, addCategory, updateCategory, deleteCategory, moveCategory, addSub, updateSub, deleteSub, moveSub, blankProduct, addProduct, updateProduct, deleteProduct, moveProduct, reassignProduct, setSale, saveSalesBatch, productIndex, addReservation, updateReservation, deleteReservation, addUser, updateUser, deleteUser, exportData, importData, resetData, sendMail, }; return ( {children}
{toasts.map(t => (
{t.msg}
))}
); } window.StoreProvider = StoreProvider; window.useStore = useStore; window.gcMoney = money; window.gcTodayISO = todayISO;