// ===== RIVAMAR Group — Store central + contenido editable (v4) ===== // Fuente única de verdad: persiste en localStorage y alimenta tanto el sitio // público como el panel admin. Todo el contenido es editable desde el CMS. const { useSyncExternalStore } = React; // ---------- Reglas de negocio (fijas, Sec. 5.6) ---------- const CAPACIDAD = { costanera: 60, inca: 40, rivamar: 100 }; const MINIMO = { costanera: 1, inca: 1, rivamar: 10 }; const HORARIOS = ['12:30', '13:00', '13:30', '14:00', '20:00', '20:30', '21:00', '21:30']; const EXPONOR_CODE = 'EXPONOR15'; const EXPONOR_DCTO = 0.15; const EXPONOR_INICIO = '2026-06-08'; const CAT_LABEL = { plato: 'Plato', ambiente: 'Ambiente', evento: 'Evento', bebida: 'Bebida', buffet: 'Buffet' }; // Helper de fotos reales (stock libre de derechos · Unsplash) const IMG = (id, w = 1200) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&q=80`; // ---------- Contenido por defecto (editable) ---------- const DEFAULT_LOCALES = [ { id: 'costanera', slug: 'aromas-costanera', nombre: 'Aromas Costanera', tagline: 'Alta cocina de autor', especialidad: 'Alta cocina de autor · Breakfast & Lunch · Bar · Experiencia gourmet frente al Pacífico.', descripcion: 'En el sector costanero de Antofagasta, Aromas Costanera ofrece una experiencia gourmet de autor: desayunos, almuerzos y bar. Cocina contemporánea, productos frescos del norte y coctelería premium en un ambiente elegante con vista al mar — el punto de encuentro de ejecutivos y delegaciones del sector minero.', web: 'www.aromascostanera.cl', ubicacion: 'Av. Costanera 1420, Antofagasta', horario: 'Lun a Sáb · 12:30 – 23:00 hrs', telefono: '+56 55 245 1420', perfil: 'Ejecutivos · delegaciones · público gourmet', destacados: ['Cocina de autor', 'Breakfast & Lunch', 'Bar & coctelería'], menu: [ { plato: 'Ceviche Clásico Costanera', desc: 'Pescado del día, leche de tigre, camote, choclo peruano', precio: '$14.900' }, { plato: 'Tiradito Nikkei', desc: 'Láminas de pescado, crema de ají amarillo, leche de tigre nikkei', precio: '$13.500' }, { plato: 'Arroz con Mariscos', desc: 'Arroz meloso, mariscos de la zona, culantro y ají panca', precio: '$16.900' }, { plato: 'Pisco Sour Premium', desc: 'Pisco quebranta, limón de pica, jarabe de goma', precio: '$6.500' }, ], capacidad: 60, capacidadLabel: 'Hasta 60 pax', tono: '#C9A24B', foto: 'Salón Costanera — ambiente gourmet con vista al mar', foto_img: IMG('1467003909585-2f8a72700288', 1400), mapa: 'Av. Costanera 1420, Antofagasta, Chile', }, { id: 'inca', slug: 'aromas-del-inca', nombre: 'Aromas del Inca', tagline: 'Carnes a la piedra volcánica', especialidad: 'Especialidad en carnes a la piedra volcánica, cocina natural y fusión ancestral.', descripcion: 'Aromas del Inca es santuario de la carne premium cocida sobre piedra volcánica al centro de la mesa. Cocina natural y fusión ancestral andina, cortes Angus Premium y guisos de altura en un espacio cálido de piedra y barro — ideal para almuerzos de negocios y celebraciones.', web: 'www.aromasdelinca.cl', ubicacion: 'Calle Prat 765, Antofagasta', horario: 'Lun a Dom · 12:00 – 22:30 hrs', telefono: '+56 55 245 0765', perfil: 'Almuerzos de negocios · celebraciones · familias', destacados: ['Carnes a la piedra', 'Angus Premium', 'Fusión ancestral'], menu: [ { plato: 'Angus Premium a la Piedra Volcánica', desc: 'Corte premium sellado sobre piedra al centro de la mesa, trío de salsas', precio: '$18.900' }, { plato: 'Lomo Saltado Andino', desc: 'Lomo de res al wok, cebolla morada, tomate, papas, arroz', precio: '$13.900' }, { plato: 'Picarones de la Casa', desc: 'Buñuelos de zapallo y camote, miel de chancaca', precio: '$5.900' }, { plato: 'Arroz con Leche', desc: 'Receta tradicional, canela y cáscara de naranja', precio: '$4.900' }, ], capacidad: 40, capacidadLabel: 'Hasta 40 pax', tono: '#6E8CAE', foto: 'Comedor del Inca — carnes a la piedra volcánica', foto_img: IMG('1432139555190-58524dae6a55', 1400), mapa: 'Calle Prat 765, Antofagasta, Chile', }, { id: 'rivamar', slug: 'rivamar', nombre: 'Rivamar', tagline: 'Sazón donde te encuentres', especialidad: 'Alimentación masiva e industrial para faenas, campamentos y zonas mineras de la región.', descripcion: 'El local matriz e histórico de la marca. Rivamar es la casa de la alimentación masiva e industrial: servicio para faenas, campamentos y zonas mineras de la región, además de banquetes y catering corporativo. Sazón donde te encuentres — capacidad para delegaciones de 10 a más de 50 personas.', web: 'www.rivamar.cl', ubicacion: 'Av. Pedro Aguirre Cerda 5200, Antofagasta', horario: 'Lun a Dom · 12:00 – 23:30 hrs', telefono: '+56 55 245 5200', perfil: 'Faenas · campamentos · banquetes corporativos', destacados: ['Alimentación industrial', 'Catering de faena', 'Buffet masivo'], menu: [ { plato: 'Causa Limeña Estructurada', desc: 'Papa amarilla prensada, palta, pollo o atún, salsa golf', precio: '$10.900' }, { plato: 'Parrillada Marina Rivamar', desc: 'Pescados y mariscos a la parrilla, para compartir', precio: '$32.900' }, { plato: 'Buffet Corporativo Minero', desc: 'Estación completa fría y caliente · por persona (min. 20 pax)', precio: '$18.500' }, { plato: 'Chupe de Camarones', desc: 'Crema marina norteña, camarones, queso, huevo pochado', precio: '$15.900' }, ], capacidad: 100, capacidadLabel: 'Hasta 100 pax', tono: '#3E8C84', foto: 'Salón matriz Rivamar — servicio masivo e industrial', foto_img: IMG('1414235077428-338989a2e8c0', 1400), mapa: 'Av. Pedro Aguirre Cerda 5200, Antofagasta, Chile', }, ]; const DEFAULT_GALERIA = [ { id: 1, local: 'costanera', cat: 'plato', destacada: true, label: 'Ceviche clásico emplatado', img: IMG('1467003909585-2f8a72700288') }, { id: 2, local: 'costanera', cat: 'ambiente', destacada: false, label: 'Salón con vista al mar', img: IMG('1517248135467-4c7edcad34c4') }, { id: 3, local: 'costanera', cat: 'bebida', destacada: false, label: 'Coctelería de autor', img: IMG('1424847651672-bf20a4b0982b') }, { id: 4, local: 'inca', cat: 'plato', destacada: true, label: 'Carne a la piedra volcánica', img: IMG('1432139555190-58524dae6a55') }, { id: 5, local: 'inca', cat: 'ambiente', destacada: false, label: 'Mesa servida tradición andina', img: IMG('1414235077428-338989a2e8c0') }, { id: 6, local: 'inca', cat: 'plato', destacada: false, label: 'Corte Angus Premium', img: IMG('1481931098730-318b6f776db0') }, { id: 7, local: 'rivamar', cat: 'buffet', destacada: true, label: 'Estación de buffet corporativo', img: IMG('1504674900247-0877df9cc836') }, { id: 8, local: 'rivamar', cat: 'evento', destacada: true, label: 'Banquete de cierre minero', img: IMG('1600891964599-f61ba0e24092') }, { id: 9, local: 'rivamar', cat: 'plato', destacada: false, label: 'Parrillada para compartir', img: IMG('1555939594-58d7cb561ad1') }, { id: 10, local: 'costanera', cat: 'plato', destacada: false, label: 'Plato de autor', img: IMG('1559737558-2f5a35f4523b') }, { id: 11, local: 'rivamar', cat: 'ambiente', destacada: false, label: 'Montaje de salón amplio', img: IMG('1466637574441-749b8f19452f') }, { id: 12, local: 'inca', cat: 'bebida', destacada: false, label: 'Bebida de la casa', img: IMG('1559054663-e8d23213f55c') }, ]; const DEFAULT_STATS = [ { n: 19, suf: '+', label: 'años de trayectoria' }, { n: 3, suf: '', label: 'locales de autor' }, { n: 120, suf: '+', label: 'empresas atendidas' }, { n: 850, suf: 'K', label: 'platos servidos' }, ]; const DEFAULT_SITE = { hero_titulo: 'Gastronomía de alto estándar para la industria minera', hero_subtitulo: '+19 años alimentando al corazón minero del país', hero_cuerpo: 'Calidad, tradición andina y servicio masivo para el corazón minero del país. Tres experiencias, un mismo estándar de excelencia.', hero_img: IMG('1414235077428-338989a2e8c0', 1900), hero_img2: IMG('1517248135467-4c7edcad34c4', 1900), hero_img3: IMG('1600891964599-f61ba0e24092', 1900), hero_blur: 1, hero_overlay: 60, banner_exponor: 'Presente su credencial de asistente, visitante, expositor, proveedor o trabajador de EXPONOR CHILE 2026 (8 al 11 de junio) y obtenga un 15% de descuento durante todo junio en cualquiera de nuestros locales.', contacto_correo: 'contacto@rivamar.cl', contacto_telefono: '+56 9 6497 9087', whatsapp: '56964979087', theme: 'midnight', local_blur: 1, local_overlay: 60, footer_desc: '+19 años de gastronomía de alto estándar para la industria minera. Tradición andina, cocina marina y servicio B2B integral para el corazón industrial del país.', footer_copy: '© 2026 RIVAMAR Group — Antofagasta, Chile · MAEDY GROUP', social_ig: 'https://www.rivamar.cl', social_fb: 'https://www.rivamar.cl', social_in: 'https://www.rivamar.cl', email_auto: false, email_modo: 'php', emailjs_public: '', emailjs_service: '', emailjs_template: '', email_destino: 'contacto@rivamar.cl', notify_wa: false, callmebot_phone: '+56964979087', callmebot_apikey: '', brand_nombre: 'RIVAMAR', brand_sub: 'GROUP', brand_inicial: 'R', logo_img: '', mch_url: 'https://www.mch.cl/', mch_titulo: 'Aparecemos en la Revista Oficial de la Minería Chilena', mch_eyebrow: 'Prensa minera oficial', mch_texto: 'Conoce la cobertura de EXPONOR 2026 y descubre por qué las principales empresas mineras del país eligen a RIVAMAR Group. Lee la edición oficial en Minería Chilena (MCH).', mch_boton: 'Ver revista oficial', pillars: [ ['+19 años', 'de experiencia'], ['Servicios B2B', 'especializados'], ['360° servicio', 'gastronómico integral'], ], valores_footer: [ ['Seguridad y cumplimiento', 'Estándares reconocidos'], ['Productos locales y sostenibles', 'Apoyo a la economía regional'], ['Flexibilidad y escalabilidad', 'Soluciones a la medida'], ], }; const DEFAULT_NOSOTROS = { intro: 'RIVAMAR Group nació en Antofagasta con una convicción: la industria minera merece gastronomía de alto estándar. Hoy operamos tres experiencias de autor bajo un mismo compromiso de calidad, tradición y servicio.', blur: 1, overlay: 60, img: IMG('1559339352-11d035aa65de', 1900), equipo_img: IMG('1577219491135-ce391730fb2c', 1200), valores: [ ['Alto estándar', 'Cocina premium y servicio impecable, sin importar el tamaño del grupo.'], ['Tradición', 'Recetas andinas y marinas del norte de Chile, mantenidas por +19 años.'], ['Servicio B2B', 'Operación masiva y eficiente diseñada para el ritmo de la gran minería.'], ['Cercanía', 'Atención personalizada para ejecutivos, delegaciones y familias.'], ], hitos: [ ['2007', 'Nace Rivamar (Gran Chimú), local matriz de cocina marina norteña.'], ['2013', 'Apertura de Aromas del Inca, cocina andina en el interior de la ciudad.'], ['2018', 'Aromas Costanera suma la cocina peruana de autor frente al mar.'], ['2026', 'Plataforma digital unificada y convenios para EXPONOR 2026.'], ], equipo: [ ['Dirección General', 'MAEDY GROUP', 'Estrategia y operación del holding gastronómico.', IMG('1577219491135-ce391730fb2c', 1000)], ['Chef Ejecutivo', 'Cocina de autor', 'Lidera la propuesta culinaria de los tres locales.', IMG('1583394293214-28a5b42c1c8a', 1000)], ['Gerencia de Convenios', 'Ventas B2B', 'Atiende contratos corporativos y delegaciones mineras.', IMG('1573497019940-1c28c88b4f3e', 1000)], ], }; const DEFAULT_CONVENIOS = { intro: 'Servicio B2B masivo y eficiente diseñado para el corazón industrial del país. Convenios de alimentación con precios especiales, facturación mensual y atención personalizada.', img: IMG('1414235077428-338989a2e8c0', 1400), img2: IMG('1504674900247-0877df9cc836', 1600), beneficios: [ ['Facturación mensual', 'Convenios corporativos con cobro consolidado.'], ['Reservas masivas', 'Delegaciones de 20 a 50+ personas para ferias industriales.'], ['Salones privados', 'Buffet ejecutivo y catering industrial de alto estándar.'], ], }; const DEFAULT_RESERVAS = [ { id: 'RV-2026-2102', nombre: 'Ignacio Tapia', empresa: 'Codelco Norte', correo: 'itapia@codelco.cl', telefono: '+56 9 7712 0098', local: 'costanera', fecha: '2026-06-09', hora: '20:30', pax: 8, exponor: true, credencial: 'EXP-38104', comentarios: 'Cena ejecutiva', estado: 'confirmada' }, { id: 'RV-2026-2101', nombre: 'María José Rivas', empresa: '', correo: 'mj.rivas@gmail.com', telefono: '+56 9 6650 1187', local: 'inca', fecha: '2026-06-10', hora: '13:30', pax: 4, exponor: false, credencial: '', comentarios: 'Una persona celíaca', estado: 'pendiente' }, { id: 'RV-2026-2100', nombre: 'Rodrigo Salazar', empresa: 'Antofagasta Minerals', correo: 'rsalazar@aminerals.cl', telefono: '+56 9 5540 8821', local: 'rivamar', fecha: '2026-06-11', hora: '21:00', pax: 24, exponor: true, credencial: 'EXP-50271', comentarios: 'Cena de cierre de faena', estado: 'pendiente' }, ]; const RESERVA_SEQ = 2103; // próximo correlativo (las nuevas reservas continúan desde aquí) const DEFAULT_USERS_LEGACY_REMOVED = null; // Módulos del panel y roles (asignación de permisos) const ADMIN_MODULOS = [ ['dashboard', 'Dashboard'], ['reservas', 'Gestión de Reservas'], ['cotizaciones', 'Cotizaciones B2B'], ['galeria', 'Galería Admin'], ['cms', 'CMS Lite'], ['correos', 'Correos'], ['usuarios', 'Usuarios'], ['visitas', 'Visitas'], ['reportes', 'Reportes'], ]; const ROLES = { 'Administrador': { modulos: ADMIN_MODULOS.map((m) => m[0]), desc: 'Acceso total al sistema' }, 'Encargado Local': { modulos: ['dashboard', 'reservas', 'cotizaciones', 'galeria', 'correos', 'reportes'], desc: 'Opera reservas y contenido de su local' }, 'Usuario básico': { modulos: ['dashboard', 'reservas'], desc: 'Solo consulta y gestión de reservas' }, }; const DEFAULT_USERS = [ { id: 1, nombre: 'Admin Rivamar', correo: 'admin@rivamar.cl', pass: 'rivamar2026', rol: 'Administrador', ultima: '2026-05-29 09:14', estado: 'activo', modulos: ROLES['Administrador'].modulos.slice() }, { id: 2, nombre: 'Lorena Vega', correo: 'l.vega@rivamar.cl', pass: 'lorena2026', rol: 'Encargado Local', ultima: '2026-05-28 18:40', estado: 'activo', modulos: ROLES['Encargado Local'].modulos.slice() }, { id: 3, nombre: 'Marco Pizarro', correo: 'm.pizarro@rivamar.cl', pass: 'marco2026', rol: 'Usuario básico', ultima: '2026-05-27 12:05', estado: 'activo', modulos: ROLES['Usuario básico'].modulos.slice() }, { id: 4, nombre: 'Invitado Ventas', correo: 'ventas@rivamar.cl', pass: '', rol: 'Usuario básico', ultima: '—', estado: 'pendiente', modulos: ROLES['Usuario básico'].modulos.slice() }, ]; const DEFAULT_EXPONOR = { img: IMG('1600891964599-f61ba0e24092', 1900), img2: IMG('1517248135467-4c7edcad34c4', 1900), img3: IMG('1414235077428-338989a2e8c0', 1900), blur: 1, overlay: 60, titulo: 'EXPONOR CHILE 2026', subtitulo: '8 al 11 de junio · Antofagasta, Chile', cuerpo: 'Durante la feria minera más importante de Latinoamérica, RIVAMAR Group ofrece un 15% de descuento a toda la comunidad de EXPONOR — durante todo junio 2026.', fecha_evento: '2026-06-08', }; function buildDefaults() { return { locales: DEFAULT_LOCALES, galeria: DEFAULT_GALERIA, stats: DEFAULT_STATS, site: DEFAULT_SITE, nosotros: DEFAULT_NOSOTROS, convenios: DEFAULT_CONVENIOS, exponor: DEFAULT_EXPONOR, reservas: DEFAULT_RESERVAS, reservaSeq: RESERVA_SEQ, cotizaciones: [], users: DEFAULT_USERS, }; } // ---------- Folio de reserva ---------- // IMPORTANTE (concurrencia): el número correlativo NO debe generarse en el // dispositivo. Cada navegador tenía su propio contador local y dos teléfonos // distintos producían el MISMO id (RV-2026-2103), que luego la bandeja del // servidor descartaba como "duplicado" → la reserva se perdía. Por eso el // folio ahora se pide al servidor (api/reserva-id.php), que lo entrega de // forma atómica y estrictamente ascendente. // Respaldo SOLO si no hay servidor (preview/sandbox u offline). Genera un folio // provisional ÚNICO por dispositivo (prefijo "P") que jamás colisiona con otro // ni con la serie central; el panel lo recibe igual y queda trazable. function localFallbackReservaId() { const t = Date.now().toString(36).toUpperCase().slice(-5); const r = Math.random().toString(36).toUpperCase().slice(2, 5); return 'RV-2026-P' + t + r; } // Folio correlativo CENTRAL y atómico (async). Resuelve a { id, source }. // source: 'server' = número correlativo real · 'local' = provisional offline. function nextReservaIdAsync() { return fetch('api/reserva-id.php?t=' + Date.now(), { cache: 'no-store' }) .then((r) => { if (!r.ok) throw new Error('http ' + r.status); return r.json(); }) .then((j) => { if (j && j.ok && j.id) { // Mantener el contador local alineado (referencia/diagnóstico). if (j.seq) storeSetKey('reservaSeq', j.seq + 1); return { id: j.id, source: 'server' }; } throw new Error('respuesta-invalida'); }) .catch(() => ({ id: localFallbackReservaId(), source: 'local' })); } // Folio síncrono SOLO de respaldo (no garantiza correlatividad entre dispositivos). // Se conserva por compatibilidad; el flujo real usa nextReservaIdAsync(). function nextReservaId() { const s = storeGet(); const seq = (s.reservaSeq && s.reservaSeq > 2099) ? s.reservaSeq : 2103; storeSetKey('reservaSeq', seq + 1); return 'RV-2026-' + seq; } // ---------- Store con persistencia (localStorage) ---------- const STORE_KEY = 'rivamar_v24'; function clone(x) { return JSON.parse(JSON.stringify(x)); } function mergeDefaults(saved) { const base = clone(buildDefaults()); const out = { ...base, ...(saved || {}) }; ['site', 'nosotros', 'convenios', 'exponor'].forEach((k) => { if (saved && saved[k] && typeof saved[k] === 'object' && !Array.isArray(saved[k])) { out[k] = { ...base[k], ...saved[k] }; } }); return out; } function hydrate() { try { const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); return mergeDefaults(saved); } catch (e) { return clone(buildDefaults()); } } let STATE = hydrate(); const listeners = new Set(); function syncGlobals() { window.LOCALES = STATE.locales; window.LOCAL_MAP = Object.fromEntries(STATE.locales.map((l) => [l.id, l])); window.SLUG_MAP = Object.fromEntries(STATE.locales.map((l) => [l.slug, l])); window.GALERIA = STATE.galeria; window.STATS = STATE.stats; window.SITE = STATE.site; // Aplicar tema (paleta) al documento try { const root = document.documentElement; root.setAttribute('data-theme', (STATE.site && STATE.site.theme) || 'midnight'); const blur = STATE.site && STATE.site.hero_blur != null ? STATE.site.hero_blur : 1.5; const ov = STATE.site && STATE.site.hero_overlay != null ? STATE.site.hero_overlay : 60; root.style.setProperty('--hero-blur', blur + 'px'); root.style.setProperty('--hero-overlay', (ov / 100)); // Locales (global) const lb = STATE.site && STATE.site.local_blur != null ? STATE.site.local_blur : 1.5; const lo = STATE.site && STATE.site.local_overlay != null ? STATE.site.local_overlay : 55; root.style.setProperty('--local-blur', lb + 'px'); root.style.setProperty('--local-overlay', (lo / 100)); // Nosotros const nb = STATE.nosotros && STATE.nosotros.blur != null ? STATE.nosotros.blur : 1.5; const no = STATE.nosotros && STATE.nosotros.overlay != null ? STATE.nosotros.overlay : 70; root.style.setProperty('--nos-blur', nb + 'px'); root.style.setProperty('--nos-overlay', (no / 100)); // EXPONOR const eb = STATE.exponor && STATE.exponor.blur != null ? STATE.exponor.blur : 1.5; const eo = STATE.exponor && STATE.exponor.overlay != null ? STATE.exponor.overlay : 75; root.style.setProperty('--exponor-blur', eb + 'px'); root.style.setProperty('--exponor-overlay', (eo / 100)); } catch (e) {} } function persist() { try { localStorage.setItem(STORE_KEY, JSON.stringify(STATE)); } catch (e) {} } function emit() { listeners.forEach((fn) => fn()); } function storeGet() { return STATE; } function storeSet(patch) { STATE = { ...STATE, ...(typeof patch === 'function' ? patch(STATE) : patch) }; syncGlobals(); persist(); pendingChanges = true; emit(); } function storeSetKey(key, value) { storeSet((s) => ({ [key]: typeof value === 'function' ? value(s[key]) : value })); } function storeReset() { STATE = clone(buildDefaults()); syncGlobals(); persist(); emit(); } function storeSubscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } function useStore() { return useSyncExternalStore(storeSubscribe, storeGet); } syncGlobals(); // hidratar window.* antes de que rendericen los demás scripts // ---------- Sincronización con el servidor (PHP + JSON en Hostinger) ---------- // Permite que TODOS los dispositivos lean la misma versión publicada. const API_URL = 'api/data.php'; const PUBLISH_KEY = 'rivamar2026'; // misma clave que en api/data.php let serverStatus = 'local'; // local | synced | empty | error let pendingChanges = false; // hay ediciones locales sin publicar function getServerStatus() { return serverStatus; } function hasPendingChanges() { return pendingChanges; } function loadFromServer() { return fetch(API_URL + '?t=' + Date.now(), { cache: 'no-store' }) .then((res) => { if (!res.ok) throw new Error('http'); return res.json(); }) .then((data) => { if (data && data.site && data.locales) { STATE = mergeDefaults(data); syncGlobals(); persist(); serverStatus = 'synced'; pendingChanges = false; } else { serverStatus = 'empty'; } emit(); }) .catch(() => { serverStatus = 'error'; emit(); }); } function publishToServer() { return fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clave: PUBLISH_KEY, payload: STATE }), }) .then((res) => res.json().then((j) => ({ res, j })).catch(() => ({ res, j: {} }))) .then(({ res, j }) => { if (res.ok && j && j.ok) { serverStatus = 'synced'; pendingChanges = false; emit(); return { ok: true }; } return { ok: false, error: (j && j.error) || ('HTTP ' + res.status) }; }) .catch(() => ({ ok: false, error: 'sin-conexion' })); } // Marca cambios pendientes en cada edición del store function markPending() { pendingChanges = true; } // ---------- Bandeja de entrada: solicitudes desde cualquier dispositivo ---------- const INBOX_URL = 'api/inbox.php'; // Envía una reserva/cotización al servidor (cualquier dispositivo). Fire-and-forget. function pushToInbox(tipo, item) { try { return fetch(INBOX_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tipo, item }), }) .then((r) => r.json().then((j) => ({ status: r.status, j })).catch(() => ({ status: r.status, j: {} }))) .then(({ status, j }) => { // 'dup' cuenta como entregada: ya estaba en el servidor. const ok = status >= 200 && status < 300 && j && (j.ok || j.dup); return { ok: !!ok, status, error: j && j.error }; }) .catch(() => ({ ok: false, error: 'sin-conexion' })); } catch (e) { return Promise.resolve({ ok: false, error: 'excepcion' }); } } // El panel admin lee la bandeja y agrega las solicitudes nuevas (por id) al store. function loadInbox() { return fetch(INBOX_URL + '?t=' + Date.now(), { cache: 'no-store' }) .then((r) => r.json()) .then((d) => { if (!d) return { nuevas: 0 }; let nuevas = 0; const idsR = new Set((STATE.reservas || []).map((x) => x.id)); const nuevasR = (d.reservas || []).filter((x) => x && x.id && !idsR.has(x.id)); const idsC = new Set((STATE.cotizaciones || []).map((x) => x.id)); const nuevasC = (d.cotizaciones || []).filter((x) => x && x.id && !idsC.has(x.id)); nuevas = nuevasR.length + nuevasC.length; if (nuevas > 0) { STATE = { ...STATE, reservas: [...nuevasR.reverse(), ...(STATE.reservas || [])], cotizaciones: [...nuevasC.reverse(), ...(STATE.cotizaciones || [])], }; syncGlobals(); persist(); emit(); } return { nuevas }; }) .catch(() => ({ nuevas: 0, error: true })); } // Intentar cargar la versión del servidor al iniciar (si no hay PHP, queda en localStorage) try { loadFromServer(); } catch (e) {} // ---------- Registro de visitas (clave separada, persiste entre versiones) ---------- const VISITS_KEY = 'rivamar_visits'; function getVisits() { try { return JSON.parse(localStorage.getItem(VISITS_KEY) || '[]'); } catch (e) { return []; } } function saveVisits(arr) { try { localStorage.setItem(VISITS_KEY, JSON.stringify(arr.slice(-3000))); } catch (e) {} } function clearVisits() { try { localStorage.removeItem(VISITS_KEY); } catch (e) {} } function seedVisitsIfEmpty() { if (getVisits().length) return; const sampleIps = ['190.95.112.40', '200.27.88.15', '152.172.44.9', '190.44.120.77', '181.42.200.13', '200.83.55.201', '190.95.112.40', '152.172.44.9']; const uas = [ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile Safari', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17 Safari', 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/124 Mobile Safari', ]; const now = Date.now(); const arr = []; for (let i = 0; i < 34; i++) { const daysAgo = Math.floor(Math.random() * 7); const ts = new Date(now - daysAgo * 86400000 - Math.floor(Math.random() * 80000000)); arr.push({ id: 's' + i, ts: ts.toISOString(), ip: sampleIps[Math.floor(Math.random() * sampleIps.length)], ua: uas[Math.floor(Math.random() * uas.length)], ref: ['', 'https://www.google.com/', 'https://exponor.cl/', 'https://instagram.com/'][Math.floor(Math.random() * 4)], }); } arr.sort((a, b) => new Date(a.ts) - new Date(b.ts)); saveVisits(arr); } // ---------- Envío automático de correo de reserva ---------- // Modo 1 (recomendado, SIN límites): PHP nativo en Hostinger (api/reserva-mail.php). // Modo 2 (respaldo): EmailJS. Se intenta PHP primero; si no está, cae a EmailJS. // ---------- Aviso al teléfono del admin (CallMeBot — WhatsApp gratis, sin límites) ---------- // El admin registra su teléfono una vez en callmebot.com y pega su apikey en el CMS. function avisarTelefono(data) { const site = (window.SITE) || {}; if (!site.notify_wa || !site.callmebot_phone || !site.callmebot_apikey) return Promise.resolve({ ok: false, reason: 'no-config' }); const localNom = (window.LOCAL_MAP && window.LOCAL_MAP[data.local]) ? window.LOCAL_MAP[data.local].nombre : data.local; const txt = `RIVAMAR — Nueva reserva ${data.id}: ${data.nombre}, ${localNom}, ${data.fecha} ${data.hora}, ${data.pax} pax. Tel: ${data.telefono}`; const url = 'https://api.callmebot.com/whatsapp.php?phone=' + encodeURIComponent(site.callmebot_phone) + '&text=' + encodeURIComponent(txt) + '&apikey=' + encodeURIComponent(site.callmebot_apikey); try { // beacon por evita problemas de CORS const img = new Image(); img.src = url; return Promise.resolve({ ok: true }); } catch (e) { return Promise.resolve({ ok: false }); } } function enviarCorreoReserva(data) { const site = (window.SITE) || {}; const localNom = (window.LOCAL_MAP && window.LOCAL_MAP[data.local]) ? window.LOCAL_MAP[data.local].nombre : data.local; const payload = { reserva_id: data.id, nombre: data.nombre, empresa: data.empresa || 'Particular', correo: data.correo, telefono: data.telefono, local: localNom, fecha: data.fecha, hora: data.hora, personas: data.pax, exponor: data.exponor ? 'Sí — 15%' : 'No', comentarios: data.comentarios || '—', email_destino: site.email_destino || site.contacto_correo || '', }; if (!site.email_auto) return Promise.resolve({ ok: false, reason: 'desactivado' }); // Modo PHP nativo (sin límites) if (site.email_modo !== 'emailjs') { return fetch('api/reserva-mail.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then((r) => r.json()).then((j) => { if (j && j.ok) return { ok: true, via: 'php' }; return enviarPorEmailJS(site, payload); }).catch(() => enviarPorEmailJS(site, payload)); } return enviarPorEmailJS(site, payload); } function enviarPorEmailJS(site, params) { if (!window.emailjs || !site.emailjs_public || !site.emailjs_service || !site.emailjs_template) { return Promise.resolve({ ok: false, reason: 'no-config' }); } try { emailjs.init({ publicKey: site.emailjs_public }); return emailjs.send(site.emailjs_service, site.emailjs_template, params) .then(() => ({ ok: true, via: 'emailjs' })) .catch((e) => ({ ok: false, reason: String(e && e.text || e) })); } catch (e) { return Promise.resolve({ ok: false, reason: String(e) }); } } let __visitTracked = false; function trackVisit() { if (__visitTracked) return; __visitTracked = true; seedVisitsIfEmpty(); const visit = { id: String(Date.now()) + '-' + Math.random().toString(36).slice(2, 7), ts: new Date().toISOString(), ip: null, ua: navigator.userAgent, ref: document.referrer || '' }; const arr = getVisits(); arr.push(visit); saveVisits(arr); // Intentar obtener IP pública (funciona en el sitio publicado; puede no estar disponible en vista previa local) try { fetch('https://api.ipify.org?format=json').then((r) => r.json()).then((d) => { const a = getVisits(); const idx = a.findIndex((v) => v.id === visit.id); if (idx >= 0) { a[idx].ip = d.ip; saveVisits(a); } }).catch(() => {}); } catch (e) {} } Object.assign(window, { CAPACIDAD, MINIMO, HORARIOS, EXPONOR_CODE, EXPONOR_DCTO, EXPONOR_INICIO, CAT_LABEL, ROLES, ADMIN_MODULOS, buildDefaults, useStore, storeGet, storeSet, storeSetKey, storeReset, nextReservaId, nextReservaIdAsync, getVisits, clearVisits, trackVisit, enviarCorreoReserva, avisarTelefono, loadFromServer, publishToServer, getServerStatus, hasPendingChanges, pushToInbox, loadInbox, // compatibilidad con código existente: SEED_RESERVAS: STATE.reservas, SEED_USERS: STATE.users, GALERIA: STATE.galeria, CMS_DEFAULT: DEFAULT_SITE, });