Ficha por pasos, cita visible y recordatorio por correo al invitado
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
caecb951d8
commit
f5f16ac55b
+30
-18
@@ -28,11 +28,38 @@ def pregunta(q):
|
|||||||
d = f'<small>{esc(q["desc"])}</small>' if q['desc'] else ''
|
d = f'<small>{esc(q["desc"])}</small>' if q['desc'] else ''
|
||||||
return f' <label><span>{esc(q["titulo"])}{mark}</span>{d}\n {campo}\n </label>'
|
return f' <label><span>{esc(q["titulo"])}{mark}</span>{d}\n {campo}\n </label>'
|
||||||
|
|
||||||
|
EMAIL = ''' <label><span>Tu correo electrónico <span class="req" title="Obligatoria">*</span></span><small>Te enviaremos un recordatorio de tu cita y así podemos responderte.</small>
|
||||||
|
<input name="email" type="email" autocomplete="email" maxlength="200" required>
|
||||||
|
</label>'''
|
||||||
bloques = []
|
bloques = []
|
||||||
for s in secciones:
|
for s in secciones:
|
||||||
nota = f'\n <p class="note">{esc(s["nota"])}</p>' if s['nota'] else ''
|
nota = f'\n <p class="note">{esc(s["nota"])}</p>' if s['nota'] else ''
|
||||||
bloques.append(f' <fieldset class="sec">\n <legend>{esc(s["titulo"])}</legend>{nota}\n' +
|
bloques.append(f' <fieldset class="sec">\n <legend>{esc(s["titulo"])}</legend>{nota}\n' +
|
||||||
'\n'.join(pregunta(q) for q in s['preguntas']) + '\n </fieldset>')
|
'\n'.join(pregunta(q) for q in s['preguntas']) + '\n </fieldset>')
|
||||||
|
bloques[0] = bloques[0].replace('</fieldset>', EMAIL + '\n </fieldset>')
|
||||||
|
|
||||||
|
def nav(i, total):
|
||||||
|
ant = '<button type="button" class="btn ghost prev">← Anterior</button>' if i else ''
|
||||||
|
sig = ('<button class="btn primary" type="submit">Enviar mi ficha</button>' if i == total - 1
|
||||||
|
else '<button type="button" class="btn primary next">Siguiente →</button>')
|
||||||
|
return f' <div class="nav">{ant}{sig}</div>\n </fieldset>'
|
||||||
|
|
||||||
|
FINAL = ''' <fieldset class="sec">
|
||||||
|
<legend>Antes de enviar</legend>
|
||||||
|
<p class="note">Este podcast se realiza de forma altruista y colaborativa.</p>
|
||||||
|
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
|
||||||
|
<label class="check">
|
||||||
|
<input name="consent" type="checkbox" required>
|
||||||
|
<span>He leído y acepto las <a href="terminos.html#participacion" target="_blank" rel="noopener">condiciones de participación</a> descritas. <span class="req">*</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input name="privacy" type="checkbox" required>
|
||||||
|
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen estos datos para preparar el episodio. <span class="req">*</span></span>
|
||||||
|
</label>
|
||||||
|
</fieldset>'''
|
||||||
|
pasos = bloques + [FINAL]
|
||||||
|
pasos = [p[:p.rindex(' </fieldset>')] + nav(i, len(pasos)) for i, p in enumerate(pasos)]
|
||||||
|
CUERPO = chr(10).join(pasos)
|
||||||
|
|
||||||
page = f'''<!doctype html>
|
page = f'''<!doctype html>
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
@@ -54,27 +81,12 @@ page = f'''<!doctype html>
|
|||||||
<h1>Ficha del <em>invitado</em></h1>
|
<h1>Ficha del <em>invitado</em></h1>
|
||||||
<p id="hola" class="lead-c" hidden></p>
|
<p id="hola" class="lead-c" hidden></p>
|
||||||
<p id="malo" class="status err" hidden>Este enlace no es válido o ha caducado. Escríbenos a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a> y te mandamos uno nuevo.</p>
|
<p id="malo" class="status err" hidden>Este enlace no es válido o ha caducado. Escríbenos a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a> y te mandamos uno nuevo.</p>
|
||||||
|
<p id="cita" class="cita" hidden></p>
|
||||||
<p id="intro" class="lead-c" hidden>{esc(F['description'])}</p>
|
<p id="intro" class="lead-c" hidden>{esc(F['description'])}</p>
|
||||||
|
|
||||||
<form id="g" class="form" novalidate hidden data-ok="¡Gracias! Hemos recibido tu ficha. Te escribiremos pronto con los siguientes pasos.">
|
<form id="g" class="form" novalidate hidden data-ok="¡Gracias! Hemos recibido tu ficha. Te escribiremos pronto con los siguientes pasos.">
|
||||||
<label>Tu correo electrónico (opcional, para poder responderte)
|
{CUERPO}
|
||||||
<input name="email" type="email" autocomplete="email" maxlength="200">
|
<p id="paso" class="paso" aria-live="polite"></p>
|
||||||
</label>
|
|
||||||
{chr(10).join(bloques)}
|
|
||||||
<fieldset class="sec">
|
|
||||||
<legend>Antes de enviar</legend>
|
|
||||||
<p class="note">Este podcast se realiza de forma altruista y colaborativa.</p>
|
|
||||||
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
|
|
||||||
<label class="check">
|
|
||||||
<input name="consent" type="checkbox" required>
|
|
||||||
<span>He leído y acepto las <a href="terminos.html#participacion" target="_blank" rel="noopener">condiciones de participación</a> descritas. <span class="req">*</span></span>
|
|
||||||
</label>
|
|
||||||
<label class="check">
|
|
||||||
<input name="privacy" type="checkbox" required>
|
|
||||||
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen estos datos para preparar el episodio. <span class="req">*</span></span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<button class="btn primary" type="submit">Enviar mi ficha</button>
|
|
||||||
<p id="st" class="status" role="status" aria-live="polite"></p>
|
<p id="st" class="status" role="status" aria-live="polite"></p>
|
||||||
<p class="note">Tus respuestas se guardan en este navegador mientras escribes, por si cierras la página. Se borran al enviarlas.</p>
|
<p class="note">Tus respuestas se guardan en este navegador mientras escribes, por si cierras la página. Se borran al enviarlas.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+11
-4
@@ -18,12 +18,10 @@
|
|||||||
<h1>Ficha del <em>invitado</em></h1>
|
<h1>Ficha del <em>invitado</em></h1>
|
||||||
<p id="hola" class="lead-c" hidden></p>
|
<p id="hola" class="lead-c" hidden></p>
|
||||||
<p id="malo" class="status err" hidden>Este enlace no es válido o ha caducado. Escríbenos a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a> y te mandamos uno nuevo.</p>
|
<p id="malo" class="status err" hidden>Este enlace no es válido o ha caducado. Escríbenos a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a> y te mandamos uno nuevo.</p>
|
||||||
|
<p id="cita" class="cita" hidden></p>
|
||||||
<p id="intro" class="lead-c" hidden>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.</p>
|
<p id="intro" class="lead-c" hidden>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.</p>
|
||||||
|
|
||||||
<form id="g" class="form" novalidate hidden data-ok="¡Gracias! Hemos recibido tu ficha. Te escribiremos pronto con los siguientes pasos.">
|
<form id="g" class="form" novalidate hidden data-ok="¡Gracias! Hemos recibido tu ficha. Te escribiremos pronto con los siguientes pasos.">
|
||||||
<label>Tu correo electrónico (opcional, para poder responderte)
|
|
||||||
<input name="email" type="email" autocomplete="email" maxlength="200">
|
|
||||||
</label>
|
|
||||||
<fieldset class="sec">
|
<fieldset class="sec">
|
||||||
<legend>👤 Tú</legend>
|
<legend>👤 Tú</legend>
|
||||||
<label><span>Nombre y cómo prefieres que te presentemos <span class="req" title="Obligatoria">*</span></span>
|
<label><span>Nombre y cómo prefieres que te presentemos <span class="req" title="Obligatoria">*</span></span>
|
||||||
@@ -41,6 +39,10 @@
|
|||||||
<label><span>Tus redes sociales que quieras que mencionemos o utilicemos para promocionar el episodio</span>
|
<label><span>Tus redes sociales que quieras que mencionemos o utilicemos para promocionar el episodio</span>
|
||||||
<textarea name="q05" rows="4" maxlength="3000"></textarea>
|
<textarea name="q05" rows="4" maxlength="3000"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label><span>Tu correo electrónico <span class="req" title="Obligatoria">*</span></span><small>Te enviaremos un recordatorio de tu cita y así podemos responderte.</small>
|
||||||
|
<input name="email" type="email" autocomplete="email" maxlength="200" required>
|
||||||
|
</label>
|
||||||
|
<div class="nav"><button type="button" class="btn primary next">Siguiente →</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="sec">
|
<fieldset class="sec">
|
||||||
<legend>🎙️ Para conocerte un poco mejor</legend>
|
<legend>🎙️ Para conocerte un poco mejor</legend>
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
<label><span>¿Qué idea, mito o tópico de tu sector te gustaría desmontar?</span>
|
<label><span>¿Qué idea, mito o tópico de tu sector te gustaría desmontar?</span>
|
||||||
<textarea name="q09" rows="4" maxlength="3000"></textarea>
|
<textarea name="q09" rows="4" maxlength="3000"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="sec">
|
<fieldset class="sec">
|
||||||
<legend>🔥 Vamos a meternos en harina</legend>
|
<legend>🔥 Vamos a meternos en harina</legend>
|
||||||
@@ -72,6 +75,7 @@
|
|||||||
<label><span>Si pudiéramos conseguir que después de escucharte alguien cambiara de opinión sobre una cosa, ¿qué te gustaría que fuera?</span>
|
<label><span>Si pudiéramos conseguir que después de escucharte alguien cambiara de opinión sobre una cosa, ¿qué te gustaría que fuera?</span>
|
||||||
<textarea name="q13" rows="4" maxlength="3000"></textarea>
|
<textarea name="q13" rows="4" maxlength="3000"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="sec">
|
<fieldset class="sec">
|
||||||
<legend>🚧 Territorio delicado</legend>
|
<legend>🚧 Territorio delicado</legend>
|
||||||
@@ -85,6 +89,7 @@
|
|||||||
<label><span>Y... ¿Hay algún otro tema de tu vida que quieras contarnos?</span>
|
<label><span>Y... ¿Hay algún otro tema de tu vida que quieras contarnos?</span>
|
||||||
<textarea name="q16" rows="4" maxlength="3000"></textarea>
|
<textarea name="q16" rows="4" maxlength="3000"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="sec">
|
<fieldset class="sec">
|
||||||
<legend>📣 Y para terminar...</legend>
|
<legend>📣 Y para terminar...</legend>
|
||||||
@@ -98,6 +103,7 @@
|
|||||||
<label><span>¿Hay alguna historia que te gustaría contar sí o sí?</span>
|
<label><span>¿Hay alguna historia que te gustaría contar sí o sí?</span>
|
||||||
<textarea name="q19" rows="4" maxlength="3000"></textarea>
|
<textarea name="q19" rows="4" maxlength="3000"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button type="button" class="btn primary next">Siguiente →</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="sec">
|
<fieldset class="sec">
|
||||||
<legend>Antes de enviar</legend>
|
<legend>Antes de enviar</legend>
|
||||||
@@ -111,8 +117,9 @@
|
|||||||
<input name="privacy" type="checkbox" required>
|
<input name="privacy" type="checkbox" required>
|
||||||
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen estos datos para preparar el episodio. <span class="req">*</span></span>
|
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen estos datos para preparar el episodio. <span class="req">*</span></span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="nav"><button type="button" class="btn ghost prev">← Anterior</button><button class="btn primary" type="submit">Enviar mi ficha</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button class="btn primary" type="submit">Enviar mi ficha</button>
|
<p id="paso" class="paso" aria-live="polite"></p>
|
||||||
<p id="st" class="status" role="status" aria-live="polite"></p>
|
<p id="st" class="status" role="status" aria-live="polite"></p>
|
||||||
<p class="note">Tus respuestas se guardan en este navegador mientras escribes, por si cierras la página. Se borran al enviarlas.</p>
|
<p class="note">Tus respuestas se guardan en este navegador mientras escribes, por si cierras la página. Se borran al enviarlas.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+47
-14
@@ -1,19 +1,57 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
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 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 f = $('g'), st = $('st'), hola = $('hola'), malo = $('malo'), intro = $('intro'), cita = $('cita'), paso = $('paso');
|
||||||
var t0 = Date.now(), busy = false, KEY = 'aqnsn-ficha-' + k.slice(0, 40);
|
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 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; } }
|
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; }
|
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' })
|
fetch('/api/invitados/check?n=' + encodeURIComponent(n) + '&f=' + encodeURIComponent(fe), { cache: 'no-store' })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (j) {
|
.then(function (j) {
|
||||||
if (!j.ok) { malo.hidden = false; return; }
|
if (!j.ok) { malo.hidden = false; return; }
|
||||||
hola.textContent = 'Hola, ' + j.nombre + '. ¡Gracias por querer venir al programa!';
|
hola.textContent = 'Hola, ' + j.nombre + '. ¡Gracias por querer venir al programa!';
|
||||||
hola.hidden = false; intro.hidden = false; f.hidden = false;
|
var d = new Date(j.cita * 1000), o = { timeZone: 'Europe/Madrid' };
|
||||||
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 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; });
|
.catch(function () { malo.hidden = false; });
|
||||||
|
|
||||||
@@ -30,20 +68,15 @@
|
|||||||
f.addEventListener('submit', function (e) {
|
f.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
var resp = {}, falta = null;
|
for (var i = 0; i < steps.length; i++) { if (!validar(i)) { if (i !== cur) show(i); return; } }
|
||||||
Array.prototype.forEach.call(f.elements, function (el) {
|
var resp = {};
|
||||||
if (/^q\d+$/.test(el.name)) { resp[el.name] = el.value.trim(); if (el.required && !resp[el.name] && !falta) falta = el; }
|
Array.prototype.forEach.call(f.elements, function (el) { if (/^q\d+$/.test(el.name)) resp[el.name] = el.value.trim(); });
|
||||||
});
|
|
||||||
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; }
|
|
||||||
busy = true; say('Enviando…', true);
|
busy = true; say('Enviando…', true);
|
||||||
fetch('/api/invitados', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
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 (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok && j.ok, j: j }; }); })
|
||||||
.then(function (x) {
|
.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);
|
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); })
|
.catch(function () { say('No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); })
|
||||||
|
|||||||
@@ -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, .note { margin: 0; color: var(--muted); font-size: .92rem; }
|
||||||
.guest .note a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
|
.guest .note a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.req { color: var(--amber); font-weight: 700; }
|
.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); }
|
||||||
|
|||||||
@@ -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.
|
No guarda los mensajes en disco: solo los reenvía por correo.
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
@@ -50,14 +51,14 @@ def rate_ok(ip):
|
|||||||
return True
|
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')
|
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')):
|
if not (host and user and pwd and os.environ.get('MAIL_TO')):
|
||||||
raise RuntimeError('SMTP no configurado')
|
raise RuntimeError('SMTP no configurado')
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['From'] = os.environ.get('MAIL_FROM', user)
|
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:
|
if reply_to:
|
||||||
msg['Reply-To'] = reply_to
|
msg['Reply-To'] = reply_to
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
@@ -109,6 +110,16 @@ def limited(bucket, ip, maxn, window=3600):
|
|||||||
return False
|
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)
|
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):
|
if not NAME_RE.match(n):
|
||||||
return None
|
return None
|
||||||
try:
|
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):
|
except (ValueError, OverflowError, OSError):
|
||||||
return None
|
return None
|
||||||
delta = (dia - datetime.date.today()).days
|
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):
|
class H(BaseHTTPRequestHandler):
|
||||||
@@ -149,8 +161,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if limited(check_hits, self.client_ip(), 40):
|
if limited(check_hits, self.client_ip(), 40):
|
||||||
return self.reply(429, {'ok': False})
|
return self.reply(429, {'ok': False})
|
||||||
q = parse_qs(urlparse(self.path).query)
|
q = parse_qs(urlparse(self.path).query)
|
||||||
nombre = invitado(q.get('n', [''])[0], q.get('f', [''])[0])
|
inv = invitado(q.get('n', [''])[0], q.get('f', [''])[0])
|
||||||
return self.reply(200, {'ok': True, 'nombre': nombre} if nombre else {'ok': False})
|
return self.reply(200, {'ok': True, 'nombre': inv[0], 'cita': inv[1]} if inv else {'ok': False})
|
||||||
|
|
||||||
def invitados(self):
|
def invitados(self):
|
||||||
try:
|
try:
|
||||||
@@ -164,14 +176,15 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.reply(200, {'ok': True}) # trampa o demasiado rápido: no se envía
|
return self.reply(200, {'ok': True}) # trampa o demasiado rápido: no se envía
|
||||||
if limited(guest_hits, self.client_ip(), 8):
|
if limited(guest_hits, self.client_ip(), 8):
|
||||||
return self.reply(429, {'ok': False, 'error': 'Demasiados envíos. Inténtalo más tarde.'})
|
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:
|
if not nombre:
|
||||||
return self.reply(403, {'ok': False, 'error': 'Enlace no válido o caducado.'})
|
return self.reply(403, {'ok': False, 'error': 'Enlace no válido o caducado.'})
|
||||||
if d.get('consent') is not True:
|
if d.get('consent') is not True:
|
||||||
return self.reply(400, {'ok': False, 'error': 'Debes aceptar las condiciones de participación.'})
|
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 {}
|
resp = d.get('resp') if isinstance(d.get('resp'), dict) else {}
|
||||||
email = clean(d.get('email'), 200)
|
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.'})
|
return self.reply(400, {'ok': False, 'error': 'El correo no parece válido.'})
|
||||||
lineas, seccion = [], None
|
lineas, seccion = [], None
|
||||||
for q in GUEST_Q:
|
for q in GUEST_Q:
|
||||||
@@ -183,7 +196,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
lineas += ['', f'== {seccion} ==']
|
lineas += ['', f'== {seccion} ==']
|
||||||
lineas += ['', f"• {q['t']}", ' ' + (ans.replace('\n', '\n ') if ans else '(sin respuesta)')]
|
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()),
|
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í']
|
'Aceptó las condiciones de participación y la política de privacidad: sí']
|
||||||
try:
|
try:
|
||||||
send(f'[Invitado] {clean(nombre, 80)} · ficha del podcast', email, '\n'.join(cabecera + lineas) + '\n')
|
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:
|
except Exception as exc:
|
||||||
print('error enviando ficha:', type(exc).__name__, file=sys.stderr, flush=True)
|
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.'})
|
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})
|
self.reply(200, {'ok': True})
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user