Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 21 additions & 122 deletions invitacion/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#100b03">
<meta name="description" content="Invitación personal al encuentro virtual de Harmonic Beacon del domingo 2 de agosto de 2026.">
<title>Tu invitación personal · Harmonic Beacon</title>
<meta name="description" content="Archivo del encuentro virtual de Harmonic Beacon del domingo 2 de agosto de 2026 y acceso a próximos encuentros.">
<meta name="robots" content="noindex, follow">
<title>Encuentro finalizado · Harmonic Beacon</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -543,57 +544,37 @@
<script src="/assets/meta-pixel.js" defer></script>
</head>
<body>
<a class="skip-link" href="#acceso">Saltar al acceso</a>
<a class="skip-link" href="#acceso">Saltar al estado del encuentro</a>

<div class="page">
<header class="topbar">
<a class="brand" href="/" aria-label="Harmonic Beacon, inicio">
<span class="brand-mark" aria-hidden="true">✦</span>
<span>HARMONIC BEACON</span>
</a>
<span class="personal">Invitación personal · 001</span>
<span class="personal">Invitación archivada · 001</span>
</header>

<main>
<section class="section access" id="acceso" aria-labelledby="access-title">
<div>
<p class="eyebrow">Acceso al encuentro</p>
<h2 id="access-title">11:00 · Costa Rica</h2>
<p class="section-intro">El ingreso estará habilitado diez minutos antes del inicio.<br><span lang="en">Entry will be enabled ten minutes before the start.</span></p>
<p class="opening"><strong>Inicio:</strong> 11:00, hora local de Costa Rica.<br><strong>Apertura del acceso:</strong> 10:50, hora local de Costa Rica.</p>
<div class="countdown" id="countdown" aria-label="Cuenta regresiva para la apertura">
<div class="countdown-cell"><strong id="countHours">00</strong><span>horas</span></div>
<div class="countdown-cell"><strong id="countMinutes">00</strong><span>minutos</span></div>
<div class="countdown-cell"><strong id="countSeconds">00</strong><span>segundos</span></div>
</div>
<p class="eyebrow">Archivo del encuentro · Event archive</p>
<h2 id="access-title">Este encuentro finalizó.<br><span lang="en">This gathering has ended.</span></h2>
<p class="section-intro">Domingo 2 de agosto de 2026 · 11:00, hora de Costa Rica.<br><span lang="en">Sunday, August 2, 2026 · 11:00 AM Costa Rica time.</span></p>
<p class="opening">La invitación gratuita <strong>NICO100</strong> está cerrada y ya no genera accesos.<br><span lang="en">The free <strong>NICO100</strong> invitation is closed and no longer grants access.</span></p>
</div>

<form class="terms-card" id="invitationForm" method="post" action="https://live.harmonicbeacon.com/api/auth/ticket">
<div class="terms-card">
<span class="lock" aria-hidden="true">◇</span>
<h3>Antes de entrar</h3>
<p>Decinos cómo querés aparecer en la sala y aceptá los términos de inscripción, participación, grabación y privacidad.</p>
<div class="identity-fields">
<label class="identity-field" for="participantName">
<span>Nombre</span>
<input id="participantName" name="name" type="text" autocomplete="name" minlength="2" maxlength="80" required placeholder="Tu nombre">
</label>
<label class="identity-field" for="participantEmail">
<span>Correo electrónico</span>
<input id="participantEmail" name="email" type="email" autocomplete="email" inputmode="email" maxlength="320" required placeholder="vos@ejemplo.com">
</label>
</div>
<input type="hidden" name="code" value="NICO100">
<label class="acceptance">
<input type="checkbox" id="termsAccepted" name="termsAccepted" value="accepted" required>
<span>Leí y acepto los <a href="/politica/" target="_blank" rel="noopener">términos y condiciones de la experiencia</a>.</span>
</label>
<p class="status" id="accessStatus" role="status" aria-live="polite">Completá tu nombre, correo y aceptación para entrar.</p>
<button class="primary-button" id="eventButton" type="submit" disabled>Entrar a la sala</button>
</form>
<h3>Próximos encuentros<br><span lang="en">Upcoming gatherings</span></h3>
<p>Harmonic Beacon abre nuevos encuentros cada fin de semana. Consultá la agenda vigente para ver horarios, idiomas y disponibilidad.</p>
<p lang="en">Harmonic Beacon opens new gatherings every weekend. Check the current schedule for times, languages and availability.</p>
<a class="primary-button" href="/eventos/#virtuales">Ver próximos encuentros<br><span lang="en">See upcoming gatherings</span></a>
</div>
</section>

<section class="section" aria-labelledby="prepare-title">
<p class="eyebrow">Indicaciones</p>
<p class="eyebrow">Para un próximo encuentro</p>
<h2 id="prepare-title">Prepará el espacio.<br>Nosotros preparamos el campo.</h2>
<div class="instructions-grid">
<article class="instruction">
Expand All @@ -609,29 +590,29 @@ <h3>Un lugar tranquilo y cómodo</h3>
<article class="instruction">
<span class="instruction-number">03 · CONEXIÓN</span>
<h3>Computadora e internet estable</h3>
<p>Entrá desde un equipo cargado, comprobá tu conexión y mantené esta invitación abierta hasta ingresar.</p>
<p>Entrá desde un equipo cargado y comprobá tu conexión antes del horario publicado.</p>
</article>
<article class="instruction">
<span class="instruction-number">04 · LLEGADA</span>
<h3>Entrá con tiempo</h3>
<p>La puerta abre a las 10:50 para que puedas resolver el ingreso sin apuro. El encuentro comienza a las 11:00.</p>
<p>Consultá la agenda de cada encuentro y conectate con tiempo para resolver el ingreso sin apuro.</p>
</article>
</div>
</section>

<section class="section bot" aria-labelledby="bot-title">
<div class="voice-symbol" aria-hidden="true"><span>╱╲╱</span></div>
<div>
<p class="eyebrow">Al finalizar</p>
<p class="eyebrow">Si participaste</p>
<h2 id="bot-title">Tu voz abre<br>la amplificación.</h2>
<p class="bot-copy">Después del encuentro, entrá al bot por WhatsApp y envianos un audio contando tu mito: imágenes, escenas, sensaciones, personajes o palabras que recuerdes. <strong>No hace falta ordenarlo ni explicarlo perfectamente.</strong></p>
<a class="primary-button" href="https://wa.me/50663551803" target="_blank" rel="noopener">Abrir el bot de Harmonic Myth Projection</a>
</div>
</section>

<aside class="help">
<span><strong>¿Algo no funciona?</strong> Escribinos y te acompañamos con el acceso.</span>
<a href="https://wa.me/5493547469632?text=Hola%2C%20necesito%20ayuda%20para%20entrar%20al%20encuentro%20de%20hoy." target="_blank" rel="noopener">Pedir ayuda por WhatsApp ↗</a>
<span><strong>¿Necesitás ayuda?</strong> Escribinos para consultar por un próximo encuentro.</span>
<a href="https://wa.me/5493547469632?text=Hola%2C%20quiero%20consultar%20por%20los%20pr%C3%B3ximos%20encuentros%20de%20Harmonic%20Beacon." target="_blank" rel="noopener">Consultar por WhatsApp ↗</a>
</aside>
</main>

Expand All @@ -641,87 +622,5 @@ <h2 id="bot-title">Tu voz abre<br>la amplificación.</h2>
</footer>
</div>

<script>
const TERMS_KEY = 'hb-personal-invitation-terms-v2';
const ACCESS_OPENS_AT = Date.UTC(2026, 7, 2, 16, 50, 0);
const EVENT_STARTS_AT = Date.UTC(2026, 7, 2, 17, 0, 0);

const termsCheckbox = document.querySelector('#termsAccepted');
const invitationForm = document.querySelector('#invitationForm');
const participantName = document.querySelector('#participantName');
const participantEmail = document.querySelector('#participantEmail');
const eventButton = document.querySelector('#eventButton');
const status = document.querySelector('#accessStatus');
const countdown = document.querySelector('#countdown');
let entryError = new URLSearchParams(window.location.search).get('entry_error');

try { termsCheckbox.checked = localStorage.getItem(TERMS_KEY) === 'accepted'; } catch (_) {}

function formatPart(value) { return String(Math.max(0, value)).padStart(2, '0'); }

function updateAccess() {
const now = Date.now();
const termsAccepted = termsCheckbox.checked;
const identityComplete = participantName.value.trim().length >= 2 && participantEmail.validity.valid;
const accessIsOpen = now >= ACCESS_OPENS_AT;
const remaining = Math.max(0, ACCESS_OPENS_AT - now);

document.querySelector('#countHours').textContent = formatPart(Math.floor(remaining / 3600000));
document.querySelector('#countMinutes').textContent = formatPart(Math.floor((remaining % 3600000) / 60000));
document.querySelector('#countSeconds').textContent = formatPart(Math.floor((remaining % 60000) / 1000));
countdown.hidden = accessIsOpen;

eventButton.disabled = !(termsAccepted && identityComplete && accessIsOpen);
eventButton.setAttribute('aria-disabled', String(eventButton.disabled));

if (entryError) {
status.textContent = entryError === 'limited'
? 'Hubo demasiados intentos. Esperá un momento y volvé a probar.'
: 'No pudimos habilitar el acceso. Revisá tus datos o pedinos ayuda por WhatsApp.';
} else if (!identityComplete) {
status.textContent = 'Completá tu nombre y un correo electrónico válido.';
} else if (!termsAccepted) {
status.textContent = 'Aceptá los términos para entrar.';
} else if (!accessIsOpen) {
status.textContent = 'Todo listo. El ingreso se habilita a las 10:50 de Costa Rica.';
} else if (now < EVENT_STARTS_AT) {
status.textContent = 'La puerta está abierta. Ya podés entrar a la sala.';
} else {
status.textContent = 'El encuentro comenzó. Podés entrar ahora.';
}
}

termsCheckbox.addEventListener('change', () => {
clearEntryError();
try {
if (termsCheckbox.checked) localStorage.setItem(TERMS_KEY, 'accepted');
else localStorage.removeItem(TERMS_KEY);
} catch (_) {}
updateAccess();
});

function clearEntryError() {
if (!entryError) return;
entryError = null;
history.replaceState({}, '', window.location.pathname);
}

[participantName, participantEmail].forEach((field) => field.addEventListener('input', () => {
clearEntryError();
updateAccess();
}));

invitationForm.addEventListener('submit', (event) => {
updateAccess();
if (eventButton.disabled) {
event.preventDefault();
const firstInvalid = invitationForm.querySelector(':invalid');
if (firstInvalid) firstInvalid.focus();
}
});

updateAccess();
setInterval(updateAccess, 1000);
</script>
</body>
</html>
68 changes: 54 additions & 14 deletions tests/invitation-entry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,60 @@ import test from 'node:test';

const invitation = readFileSync(new URL('../invitacion/index.html', import.meta.url), 'utf8');

test('invitation collects identity and posts directly to the secure Beacon admission route', () => {
assert.match(invitation, /<form[^>]+method="post"[^>]+action="https:\/\/live\.harmonicbeacon\.com\/api\/auth\/ticket"/);
assert.match(invitation, /name="name"[^>]+autocomplete="name"[^>]+required/);
assert.match(invitation, /name="email"[^>]+type="email"[^>]+autocomplete="email"[^>]+required/);
assert.match(invitation, /name="code" value="NICO100"/);
assert.match(invitation, /name="termsAccepted" value="accepted" required/);
assert.match(invitation, />Entrar a la sala<\/button>/);
assert.match(invitation, /termsAccepted && identityComplete && accessIsOpen/);
function archivedSnapshotAt(now) {
const originalNow = Date.now;
Date.now = () => now;
try {
return {
timestamp: Date.now(),
ended: /Este encuentro finalizó[\s\S]+This gathering has ended/.test(invitation),
canSubmit: /<form\b|type="submit"|\/api\/auth\/ticket/.test(invitation),
hasSessionRedirect: /live\.harmonicbeacon\.com|\/session\//.test(invitation),
};
} finally {
Date.now = originalNow;
}
}

test('the August 2 invitation is a bilingual archive with a current-schedule CTA', () => {
assert.match(invitation, /<title>Encuentro finalizado · Harmonic Beacon<\/title>/);
assert.match(invitation, /Archivo del encuentro virtual de Harmonic Beacon del domingo 2 de agosto de 2026/);
assert.match(invitation, /<meta name="robots" content="noindex, follow">/);
assert.match(invitation, /Este encuentro finalizó/);
assert.match(invitation, /This gathering has ended/);
assert.match(invitation, /Domingo 2 de agosto de 2026/);
assert.match(invitation, /Sunday, August 2, 2026/);
assert.match(invitation, /href="\/eventos\/#virtuales"/);
assert.match(invitation, /Ver próximos encuentros/);
assert.match(invitation, /See upcoming gatherings/);
});

test('the archived NICO100 campaign cannot submit or redirect to an expired room', () => {
assert.match(invitation, /NICO100[\s\S]+está cerrada/);
assert.doesNotMatch(invitation, /<form\b/);
assert.doesNotMatch(invitation, /name="code"/);
assert.doesNotMatch(invitation, /type="submit"/);
assert.doesNotMatch(invitation, /\/api\/auth\/ticket/);
assert.doesNotMatch(invitation, /live\.harmonicbeacon\.com/);
assert.doesNotMatch(invitation, /\/session\//);
});

test('invitation no longer sends guests to the obsolete app or asks for an account', () => {
assert.doesNotMatch(invitation, /beacon\.altermundi\.net/);
assert.doesNotMatch(invitation, /iniciá sesión con tu cuenta/i);
assert.doesNotMatch(invitation, /cuenta de la app/i);
assert.match(invitation, /El ingreso estará habilitado diez minutos antes del inicio/);
assert.match(invitation, /Entry will be enabled ten minutes before the start/);
test('the permanent archive cannot reopen before, during, or after the old event', () => {
const checkpoints = [
Date.UTC(2026, 7, 2, 16, 49, 59),
Date.UTC(2026, 7, 2, 16, 50, 0),
Date.UTC(2026, 7, 2, 17, 0, 0),
Date.UTC(2026, 7, 3, 17, 0, 0),
];

for (const now of checkpoints) {
assert.deepEqual(archivedSnapshotAt(now), {
timestamp: now,
ended: true,
canSubmit: false,
hasSessionRedirect: false,
});
}

assert.doesNotMatch(invitation, /ACCESS_OPENS_AT|EVENT_STARTS_AT|setInterval\(updateAccess/);
});