diff --git a/make_guest_form.py b/make_guest_form.py index 6a934ea..c4354b3 100644 --- a/make_guest_form.py +++ b/make_guest_form.py @@ -28,11 +28,38 @@ def pregunta(q): d = f'{esc(q["desc"])}' if q['desc'] else '' return f' ' +EMAIL = ''' ''' bloques = [] for s in secciones: nota = f'\n
{esc(s["nota"])}
' if s['nota'] else '' bloques.append(f' ') +bloques[0] = bloques[0].replace('', EMAIL + '\n ') + +def nav(i, total): + ant = '' if i else '' + sig = ('' if i == total - 1 + else '') + return f' \n ' + +FINAL = ''' ''' +pasos = bloques + [FINAL] +pasos = [p[:p.rindex(' ')] + nav(i, len(pasos)) for i, p in enumerate(pasos)] +CUERPO = chr(10).join(pasos) page = f''' @@ -54,27 +81,12 @@ page = f'''Este enlace no es válido o ha caducado. Escríbenos a andaquenosabesnada@gmail.com y te mandamos uno nuevo.
+{esc(F['description'])}
diff --git a/public/invitados.html b/public/invitados.html index 54f01f8..498eff8 100644 --- a/public/invitados.html +++ b/public/invitados.html @@ -18,12 +18,10 @@Este enlace no es válido o ha caducado. Escríbenos a andaquenosabesnada@gmail.com y te mandamos uno nuevo.
+Queremos conocerte un poco antes de darle al REC. No hace falta que prepares respuestas perfectas: esto nos sirve para encontrar buenas historias, preguntas interesantes y, sobre todo, temas sobre los que merezca la pena hablar.
diff --git a/public/invitados.js b/public/invitados.js index 7271ce1..28315bf 100644 --- a/public/invitados.js +++ b/public/invitados.js @@ -1,19 +1,57 @@ (function () { var $ = function (id) { return document.getElementById(id); }; var P = new URLSearchParams(location.search), n = (P.get('n') || '').trim(), fe = (P.get('f') || '').trim(), k = n && fe ? n + fe : ''; - var f = $('g'), st = $('st'), hola = $('hola'), malo = $('malo'), intro = $('intro'); - var t0 = Date.now(), busy = false, KEY = 'aqnsn-ficha-' + k.slice(0, 40); + var f = $('g'), st = $('st'), hola = $('hola'), malo = $('malo'), intro = $('intro'), cita = $('cita'), paso = $('paso'); + var steps = Array.prototype.slice.call(f.querySelectorAll('fieldset.sec')); + var cur = 0, t0 = Date.now(), busy = false, KEY = 'aqnsn-ficha-' + k.slice(0, 40); function say(m, ok) { st.textContent = m; st.className = 'status ' + (ok ? 'ok' : 'err'); } function store(op, v) { try { return op === 'get' ? localStorage.getItem(KEY) : op === 'set' ? localStorage.setItem(KEY, v) : localStorage.removeItem(KEY); } catch (e) { return null; } } + var okMail = function (v) { return /^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(v); }; if (!k) { malo.hidden = false; return; } + function show(i) { + cur = i; + steps.forEach(function (s, j) { s.hidden = j !== i; }); + paso.textContent = 'Paso ' + (i + 1) + ' de ' + steps.length; + say('', true); + var top = hola.getBoundingClientRect().top + window.scrollY - 12; + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + } + // valida los campos obligatorios del paso actual; devuelve true si todo está bien + function validar(i) { + var s = steps[i], els = s.querySelectorAll('input[required],textarea[required]'); + for (var j = 0; j < els.length; j++) { + var el = els[j], bad = el.type === 'checkbox' ? !el.checked : !el.value.trim(); + if (bad) { el.focus(); say(el.type === 'checkbox' ? 'Marca las dos casillas finales para poder enviar la ficha.' : 'Faltan respuestas obligatorias (las marcadas con *).', false); return false; } + } + var m = s.querySelector('input[type=email]'); + if (m && !okMail(m.value.trim())) { m.focus(); say('El correo no parece válido.', false); return false; } + return true; + } + f.addEventListener('click', function (e) { + var b = e.target.closest && e.target.closest('button.next,button.prev'); + if (!b) return; + if (b.classList.contains('prev')) show(cur - 1); + else if (validar(cur)) show(cur + 1); + }); + // Intro/Enter dentro de un campo de una sola línea avanza en vez de enviar a medias + f.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && cur < steps.length - 1) { e.preventDefault(); if (validar(cur)) show(cur + 1); } + }); + fetch('/api/invitados/check?n=' + encodeURIComponent(n) + '&f=' + encodeURIComponent(fe), { cache: 'no-store' }) .then(function (r) { return r.json(); }) .then(function (j) { if (!j.ok) { malo.hidden = false; return; } hola.textContent = 'Hola, ' + j.nombre + '. ¡Gracias por querer venir al programa!'; - hola.hidden = false; intro.hidden = false; f.hidden = false; - try { var d = JSON.parse(store('get') || '{}'); Object.keys(d).forEach(function (n) { if (f.elements[n] && f.elements[n].type !== 'checkbox') f.elements[n].value = d[n]; }); } catch (e) {} + var d = new Date(j.cita * 1000), o = { timeZone: 'Europe/Madrid' }; + var dia = d.toLocaleDateString('es-ES', Object.assign({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }, o)); + var hora = d.toLocaleTimeString('es-ES', Object.assign({ hour: '2-digit', minute: '2-digit' }, o)); + cita.textContent = '📅 Tu cita: ' + dia + ', a las ' + hora + ' (hora de Madrid).'; + hola.hidden = false; cita.hidden = false; intro.hidden = false; f.hidden = false; + try { var s = JSON.parse(store('get') || '{}'); Object.keys(s).forEach(function (nm) { if (f.elements[nm] && f.elements[nm].type !== 'checkbox') f.elements[nm].value = s[nm]; }); } catch (e) {} + show(0); + window.scrollTo(0, 0); }) .catch(function () { malo.hidden = false; }); @@ -30,20 +68,15 @@ f.addEventListener('submit', function (e) { e.preventDefault(); if (busy) return; - var resp = {}, falta = null; - Array.prototype.forEach.call(f.elements, function (el) { - if (/^q\d+$/.test(el.name)) { resp[el.name] = el.value.trim(); if (el.required && !resp[el.name] && !falta) falta = el; } - }); - if (falta) { falta.focus(); say('Faltan respuestas obligatorias (las marcadas con *).', false); return; } - if (!f.consent.checked || !f.privacy.checked) { say('Marca las dos casillas finales para poder enviar la ficha.', false); return; } - var email = (f.email.value || '').trim(); - if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(email)) { say('El correo no parece válido.', false); return; } + for (var i = 0; i < steps.length; i++) { if (!validar(i)) { if (i !== cur) show(i); return; } } + var resp = {}; + Array.prototype.forEach.call(f.elements, function (el) { if (/^q\d+$/.test(el.name)) resp[el.name] = el.value.trim(); }); busy = true; say('Enviando…', true); fetch('/api/invitados', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ n: n, f: fe, email: email, resp: resp, consent: true, web: f.web.value || '', t: Date.now() - t0 }) }) + body: JSON.stringify({ n: n, f: fe, email: f.email.value.trim(), resp: resp, consent: true, web: f.web.value || '', t: Date.now() - t0 }) }) .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok && j.ok, j: j }; }); }) .then(function (x) { - if (x.ok) { store('del'); f.reset(); Array.prototype.forEach.call(f.querySelectorAll('fieldset,label,button,.note'), function (n) { n.hidden = true; }); say(f.getAttribute('data-ok'), true); } + if (x.ok) { store('del'); f.reset(); steps.forEach(function (s) { s.hidden = true; }); paso.hidden = true; f.querySelector('.note:last-of-type').hidden = true; say(f.getAttribute('data-ok'), true); } else say((x.j && x.j.error) || 'No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); }) .catch(function () { say('No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); }) diff --git a/public/styles.css b/public/styles.css index 1e31879..6200e0c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -248,3 +248,8 @@ footer { margin-top: 40px; border-top: 1px solid var(--line); background: var(-- .guest .note, .note { margin: 0; color: var(--muted); font-size: .92rem; } .guest .note a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; } .req { color: var(--amber); font-weight: 700; } +.cita { margin: 0 0 14px; padding: 12px 16px; border: 1px solid var(--line); border-left: 3px solid var(--amber); border-radius: 12px; background: var(--card); font-weight: 600; } +.paso { margin: 0; color: var(--muted); font-size: .9rem; } +.guest .nav { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; } +.guest .nav .next, .guest .nav button[type=submit] { margin-left: auto; } +.btn.ghost { background: transparent; color: var(--muted); } diff --git a/server.py b/server.py index 8b4f3c5..5aedab4 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,7 @@ Configuración solo por variables de entorno (nunca en el repo): No guarda los mensajes en disco: solo los reenvía por correo. """ import datetime +from zoneinfo import ZoneInfo import json import os from urllib.parse import urlparse, parse_qs @@ -50,14 +51,14 @@ def rate_ok(ip): return True -def send(subject, reply_to, body): +def send(subject, reply_to, body, to=None): host, user, pwd = os.environ.get('SMTP_HOST'), os.environ.get('SMTP_USER'), os.environ.get('SMTP_PASS') if not (host and user and pwd and os.environ.get('MAIL_TO')): raise RuntimeError('SMTP no configurado') msg = EmailMessage() msg['Subject'] = subject msg['From'] = os.environ.get('MAIL_FROM', user) - msg['To'] = os.environ['MAIL_TO'] + msg['To'] = to or os.environ['MAIL_TO'] if reply_to: msg['Reply-To'] = reply_to msg.set_content(body) @@ -109,6 +110,16 @@ def limited(bucket, ip, maxn, window=3600): return False +confirmados = set() +DIAS = ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo'] +MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'] + + +def cita_txt(ts): + d = datetime.datetime.fromtimestamp(ts, ZoneInfo('Europe/Madrid')) + return f'{DIAS[d.weekday()]} {d.day} de {MESES[d.month - 1]} de {d.year}, a las {d:%H:%M}' + + NAME_RE = re.compile(r"^[^\W\d_][\w .'\-]{1,59}$", re.UNICODE) @@ -118,11 +129,12 @@ def invitado(n, f): if not NAME_RE.match(n): return None try: - dia = datetime.datetime.fromtimestamp(int(str(f).strip()), datetime.timezone.utc).date() + ts = int(str(f).strip()) + dia = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).date() except (ValueError, OverflowError, OSError): return None delta = (dia - datetime.date.today()).days - return n if -10 <= delta <= 14 else None + return (n, ts) if -10 <= delta <= 14 else None class H(BaseHTTPRequestHandler): @@ -149,8 +161,8 @@ class H(BaseHTTPRequestHandler): if limited(check_hits, self.client_ip(), 40): return self.reply(429, {'ok': False}) q = parse_qs(urlparse(self.path).query) - nombre = invitado(q.get('n', [''])[0], q.get('f', [''])[0]) - return self.reply(200, {'ok': True, 'nombre': nombre} if nombre else {'ok': False}) + inv = invitado(q.get('n', [''])[0], q.get('f', [''])[0]) + return self.reply(200, {'ok': True, 'nombre': inv[0], 'cita': inv[1]} if inv else {'ok': False}) def invitados(self): try: @@ -164,14 +176,15 @@ class H(BaseHTTPRequestHandler): return self.reply(200, {'ok': True}) # trampa o demasiado rápido: no se envía if limited(guest_hits, self.client_ip(), 8): return self.reply(429, {'ok': False, 'error': 'Demasiados envíos. Inténtalo más tarde.'}) - nombre = invitado(d.get('n'), d.get('f')) + inv = invitado(d.get('n'), d.get('f')) + nombre, ts = inv if inv else (None, None) if not nombre: return self.reply(403, {'ok': False, 'error': 'Enlace no válido o caducado.'}) if d.get('consent') is not True: return self.reply(400, {'ok': False, 'error': 'Debes aceptar las condiciones de participación.'}) resp = d.get('resp') if isinstance(d.get('resp'), dict) else {} email = clean(d.get('email'), 200) - if email and not EMAIL_RE.match(email): + if not email or not EMAIL_RE.match(email): return self.reply(400, {'ok': False, 'error': 'El correo no parece válido.'}) lineas, seccion = [], None for q in GUEST_Q: @@ -183,7 +196,7 @@ class H(BaseHTTPRequestHandler): lineas += ['', f'== {seccion} =='] lineas += ['', f"• {q['t']}", ' ' + (ans.replace('\n', '\n ') if ans else '(sin respuesta)')] cabecera = [f'Ficha del invitado: {nombre}', 'Recibida: ' + time.strftime('%d/%m/%Y %H:%M', time.localtime()), - f'Correo (opcional): {email or "—"}', + f'Cita: {cita_txt(ts)}', f'Correo del invitado: {email}', 'Aceptó las condiciones de participación y la política de privacidad: sí'] try: send(f'[Invitado] {clean(nombre, 80)} · ficha del podcast', email, '\n'.join(cabecera + lineas) + '\n') @@ -192,6 +205,18 @@ class H(BaseHTTPRequestHandler): except Exception as exc: print('error enviando ficha:', type(exc).__name__, file=sys.stderr, flush=True) return self.reply(502, {'ok': False, 'error': 'No se pudo enviar. Inténtalo más tarde.'}) + key = (nombre, ts) + if key not in confirmados: # un solo recordatorio por invitado y cita + confirmados.add(key) + try: + send('Tu cita en Anda Que No Sabes Ná', None, + f'Hola, {nombre}:\n\nGracias por rellenar tu ficha. Te recordamos tu cita en el podcast:\n\n' + f' {cita_txt(ts)} (hora de Madrid)\n\n' + 'Si no puedes venir o necesitas cambiar algo, responde a este correo.\n\n' + 'Un abrazo,\nÁlex y Juan\nAnda Que No Sabes Ná\nhttps://andaquenosabesna.com\n', to=email) + except Exception as exc: + confirmados.discard(key) + print('error enviando recordatorio:', type(exc).__name__, file=sys.stderr, flush=True) self.reply(200, {'ok': True}) def do_POST(self):