// form.jsx — Multi-step registration wizard // // HARDENING v2 (pre-Ethical Hacking): // 1. Token compartido con Apps Script (TOKEN_API). // 2. Honeypot anti-bot (campo "website" oculto fuera de pantalla). // 3. Validación cliente: RUT (módulo 11), email robusto, longitudes, // sanitización contra inyección HTML/scripts. // 4. Rate limit cliente: máx 3 envíos por sesión, mín 60s entre envíos. // 5. Checkbox de consentimiento Ley 19.628 obligatorio. // 6. SessionId aleatorio por pestaña para rate limit servidor. const { useState, useEffect, useRef, useMemo } = React; // ───────────────────────────────────────────────────────────────────────────── // Google Sheets webhook (Apps Script Web App URL) // Paste here the deployment URL from your Google Apps Script. // While empty the form falls back to local "success" mode — no data leaves the // browser. See README-google-sheets.md for setup instructions. const SHEETS_WEBHOOK_URL = "https://script.google.com/macros/s/AKfycbzjO5D6IK9Mjl81jnTC8BBb9dMRUn9lriZyDZ_DwNeXxMWfIyi8rBIme9SOxfCkoNVuLw/exec"; // Apps Script publicado desde copaculinariacarozzi@gmail.com // Token compartido con el Apps Script. Debe coincidir con TOKEN_API // definido en el servidor. Si no coincide, el servidor rechaza la petición. const TOKEN_API = "cck2026_jh0MX_alRMQ4Nbjh"; // ───────────────────────────────────────────────────────────────────────────── // ── SessionId único por pestaña (para rate limit en servidor) ── const SESSION_ID = (function(){ try { let s = sessionStorage.getItem('cck_sid'); if (!s) { s = 'sid_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); sessionStorage.setItem('cck_sid', s); } return s; } catch(e) { // sessionStorage puede fallar en modo privado de algunos navegadores return 'sid_' + Math.random().toString(36).slice(2, 10); } })(); // ── Validadores cliente ── // Validación de RUT chileno con dígito verificador real (módulo 11). // Tolera formato con/sin puntos, con/sin guión. function validarRut(rut){ if(!rut) return false; const limpio = String(rut).replace(/\./g,'').replace(/\s/g,'').toUpperCase(); const m = limpio.match(/^(\d{1,8})-?([\dK])$/); if(!m) return false; const cuerpo = m[1]; const dv = m[2]; let suma = 0, mult = 2; for(let i = cuerpo.length - 1; i >= 0; i--){ suma += parseInt(cuerpo.charAt(i), 10) * mult; mult = mult === 7 ? 2 : mult + 1; } const resto = suma % 11; const calc = 11 - resto; const esperado = calc === 11 ? '0' : (calc === 10 ? 'K' : String(calc)); return dv === esperado; } // Email: regex razonable, longitud máxima, sin caracteres de control function validarEmail(email){ if(!email) return false; const e = String(email).trim(); if(e.length === 0 || e.length > 254) return false; if(/[\x00-\x1F\x7F]/.test(e)) return false; return /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(e); } // Teléfono chileno: 8-12 dígitos (acepta +56, espacios, guiones; valida solo dígitos) function validarTelefono(tel){ if(!tel) return false; const solo = String(tel).replace(/[\s\-\+\(\)]/g, ''); return /^\d{8,12}$/.test(solo); } // Sanitizar texto: escapar HTML básico, eliminar caracteres de control, // limitar longitud. NO se permite < > " ' & sin escapar. function sanitizarTexto(valor, maxLen){ if(valor === null || valor === undefined) return ''; let s = String(valor); // Quitar caracteres de control (excepto \n y \t) s = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); // Escape básico de HTML s = s.replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Limitar longitud if(typeof maxLen === 'number' && s.length > maxLen){ s = s.slice(0, maxLen); } return s.trim(); } // Sanitización ligera para campos cortos (nombre, etc.): solo escapa HTML // sin reemplazar entidades, manteniendo el texto legible. Más permisiva. function limpiarCampo(valor, maxLen){ if(valor === null || valor === undefined) return ''; let s = String(valor); s = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); // Bloquear cualquier intento de inyectar etiquetas s = s.replace(/<[^>]*>/g, ''); if(typeof maxLen === 'number' && s.length > maxLen){ s = s.slice(0, maxLen); } return s.trim(); } // ── Rate limit cliente: máx 3 envíos por sesión, mín 60s entre envíos ── const RATE_MAX_POR_SESION = 3; const RATE_MIN_SEGUNDOS = 60; function chequearRateLimit(){ try { const raw = sessionStorage.getItem('cck_envios') || '[]'; const envios = JSON.parse(raw); const ahora = Date.now(); if (envios.length >= RATE_MAX_POR_SESION) { return { ok: false, motivo: 'maximo' }; } if (envios.length > 0) { const ultimo = envios[envios.length - 1]; const segs = (ahora - ultimo) / 1000; if (segs < RATE_MIN_SEGUNDOS) { const esperar = Math.ceil(RATE_MIN_SEGUNDOS - segs); return { ok: false, motivo: 'espera', segundos: esperar }; } } return { ok: true }; } catch(e){ return { ok: true }; // si sessionStorage falla, dejar pasar } } function registrarEnvioCliente(){ try { const raw = sessionStorage.getItem('cck_envios') || '[]'; const envios = JSON.parse(raw); envios.push(Date.now()); sessionStorage.setItem('cck_envios', JSON.stringify(envios)); } catch(e){} } // ───────────────────────────────────────────────────────────────────────────── const REGIONES = [ 'Arica y Parinacota','Tarapacá','Antofagasta','Atacama','Coquimbo','Valparaíso', 'Metropolitana de Santiago','Lib. Bernardo O\'Higgins','Maule','Ñuble','Biobío', 'La Araucanía','Los Ríos','Los Lagos','Aysén','Magallanes y Antártica' ]; const CIUDADES_CLASIF = ['Antofagasta','La Serena','Viña del Mar','Santiago','Puerto Montt','Concepción']; const TIPO_INST = ['Hotel','Restaurant','Catering','Casino','Otro']; const TALLAS = ['S','M','L','XL','XXL']; // ── Field helpers ────────────────────────────────────────── const Field = ({label, required, error, children, span=1, hint}) => ( ); const inputBase = { width:'100%', padding:'9px 12px', background:'rgba(46,42,107,.18)', border:'1px solid rgba(255,255,255,.12)', borderRadius:6, color:'#fff', fontSize:13.5, fontFamily:'inherit', outline:'none', transition:'border-color .2s, background .2s', height:38 }; const Input = ({value, onChange, type='text', placeholder, error, ...rest}) => ( onChange(e.target.value)} placeholder={placeholder} style={{...inputBase, borderColor: error ? 'var(--c-red)' : inputBase.border}} onFocus={e=>{if(!error)e.target.style.borderColor='rgba(255,255,255,.4)'}} onBlur={e=>{if(!error)e.target.style.borderColor='rgba(255,255,255,.12)'}} {...rest}/> ); const Select = ({value, onChange, options, placeholder, error}) => (
); const Radio = ({value, onChange, options}) => (
{options.map(o => ( ))}
); const Textarea = ({value, onChange, placeholder, rows=5, error}) => (