← AE Lab
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
3:20La reserva que se imprime como un ticket
La reserva que se imprime como un ticket
Un formulario de reserva de restaurante que, al enviarlo, se mete por la ranura de una impresora y sale convertido en un ticket de papel con código de barras y el sello «CONFIRMADA» estampado de un golpe.
// CÓMO ESTÁ HECHO
- Formulario y ticket se apilan en la misma celda de un grid con overflow:hidden que empieza justo en la ranura, así lo que sube «entra» en la impresora y lo que baja «sale» de ella.
- Al enviar (preventDefault, sin enviar nada), la Web Animations API inclina el formulario con perspective y rotateX y lo sube hasta que la ranura se lo traga.
- El ticket baja a tirones con fotogramas generados por código (cada salto se mueve el 45 % de su tramo y se queda quieto el resto), como una impresora térmica.
- El borde dentado es una máscara con conic-gradient repetido; el código de barras es un SVG de rectángulos calculado a partir del código de reserva.
- El sello cae de escala 2,6 a 1 con mix-blend-mode multiply y una máscara de puntos que imita la tinta, y el papel acusa el golpe con un pequeño rebote.
// EL CÓDIGO
<main class="escena">
<header class="intro">
<small>Reserva de mesa</small>
<h1>La reserva que se <em>imprime</em> como un ticket</h1>
<p>Rellena la reserva y envíala: el formulario entra en la impresora y sale un ticket con tus datos, su código de barras y el sello de confirmada.</p>
</header>
<section class="maquina" aria-label="Reserva en Brasa y Sal">
<div class="impresora" id="impresora" aria-hidden="true">
<span class="marca">BRASA & SAL · MESAS</span>
<span class="led"></span>
<span class="ranura"></span>
</div>
<div class="boca">
<!-- 1. El formulario (no se envía a ningún sitio: lo frena preventDefault) -->
<form class="formulario" id="formulario" novalidate>
<div class="campo" id="campo-nombre">
<label class="etiqueta" for="nombre">Nombre</label>
<input type="text" id="nombre" name="nombre" placeholder="¿A nombre de quién?" autocomplete="off" maxlength="22" aria-describedby="aviso-nombre">
<span class="aviso" id="aviso-nombre">Dinos un nombre para apuntar la mesa (mínimo 2 letras).</span>
</div>
<div class="campo">
<span class="etiqueta" id="et-personas">Personas</span>
<div class="contador" role="group" aria-labelledby="et-personas">
<button type="button" id="menos" aria-label="Una persona menos">−</button>
<output id="personas" aria-live="polite">2</output>
<button type="button" id="mas" aria-label="Una persona más">+</button>
<span class="unidad" id="unidad">comensales</span>
</div>
</div>
<fieldset class="campo" id="campo-dia">
<legend class="etiqueta">Día</legend>
<div class="fichas dias" id="dias"></div>
<span class="aviso">Elige qué día venís.</span>
</fieldset>
<fieldset class="campo" id="campo-hora">
<legend class="etiqueta">Hora</legend>
<div class="fichas horas" id="horas"></div>
<span class="aviso">Elige una hora y os guardamos la mesa.</span>
</fieldset>
<button class="enviar" id="enviar" type="submit">Reservar mesa
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M8 14h8v7H8z"/></svg>
</button>
</form>
<!-- 2. El ticket que sale por la ranura -->
<article class="ticket" id="ticket" aria-hidden="true">
<div class="cabecera">
<strong>BRASA & SAL</strong>
<span>cocina de fuego · reserva de mesa</span>
</div>
<hr>
<dl>
<dt>Nombre</dt><dd id="t-nombre">—</dd>
<dt>Personas</dt><dd id="t-personas">—</dd>
<dt>Día</dt><dd id="t-dia">—</dd>
<dt>Hora</dt><dd id="t-hora">—</dd>
<dt>Mesa</dt><dd id="t-mesa">—</dd>
</dl>
<div class="hueco-sello" id="hueco-sello"></div>
<hr>
<div class="codigo">
<svg id="barras" viewBox="0 0 200 50" preserveAspectRatio="none" aria-hidden="true"></svg>
<span id="t-codigo">R-0000</span>
</div>
<p class="pie">Te esperamos. Si no podéis venir, avisadnos.</p>
<div class="sello" id="sello">CONFIRMADA</div>
</article>
</div>
<button class="otra" id="otra" type="button">Hacer otra reserva</button>
<p class="sr" id="anuncio" aria-live="polite"></p>
</section>
</main>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
--papel: #F1F0E8;
--tinta: #1A1D1B;
--sello: #0B8F4D;
--ancho-papel: min(340px, calc(100vw - 48px));
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background:
radial-gradient(700px 420px at 70% 0%, rgba(0, 255, 127, .07), transparent 70%),
var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
button, input { font: inherit; color: inherit; }
/* Escena: en el móvil, una columna; en el ordenador, texto a la izquierda y máquina a la derecha */
.escena {
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
padding: 18px 16px 12px;
gap: 10px;
}
.intro { text-align: center; }
.intro small { font: 600 11px/1 ui-monospace, monospace; letter-spacing: .18em; color: var(--verde); text-transform: uppercase; }
.intro h1 { font: 800 22px/1.15 system-ui, sans-serif; letter-spacing: -.02em; margin-top: 8px; }
.intro p { display: none; }
.intro h1 em { font-style: normal; color: var(--verde); }
.maquina { width: var(--ancho-papel); display: flex; flex-direction: column; align-items: center; align-self: center; }
/* ---------- La impresora ---------- */
.impresora {
position: relative;
width: calc(var(--ancho-papel) + 28px);
height: 62px;
border-radius: 18px 18px 10px 10px;
background: linear-gradient(180deg, #18241D, #0E1611);
border: 1px solid var(--borde);
box-shadow: 0 18px 40px rgba(0, 0, 0, .55), inset 0 1px 0 rgba(255, 255, 255, .06);
z-index: 2;
}
.impresora .marca { position: absolute; left: 18px; top: 14px; font: 600 10px/1 ui-monospace, monospace; letter-spacing: .2em; color: var(--gris); }
.impresora .led {
position: absolute; right: 18px; top: 13px; width: 8px; height: 8px; border-radius: 50%;
background: var(--verde); box-shadow: 0 0 10px var(--verde); opacity: .35;
}
.impresora.imprimiendo .led { animation: parpadeo .25s steps(2) infinite; }
@keyframes parpadeo { to { opacity: 1; } }
/* La ranura: una línea negra con sombra interior por la que entra y sale el papel */
.ranura {
position: absolute; left: 8px; right: 8px; bottom: 9px; height: 8px; border-radius: 4px;
background: #000; box-shadow: inset 0 2px 3px rgba(0, 0, 0, .9), 0 1px 0 rgba(255, 255, 255, .05);
}
/* ---------- La boca: todo lo que está debajo de la ranura ----------
overflow:hidden recorta lo que está «dentro» de la impresora: así el
formulario desaparece hacia arriba y el ticket aparece desde arriba. */
.boca {
width: var(--ancho-papel);
margin-top: -12px; /* empieza justo en la ranura */
overflow: hidden;
display: grid; /* formulario y ticket apilados en la misma celda */
padding-bottom: 8px;
}
.boca > * { grid-area: 1 / 1; }
/* ---------- El formulario ---------- */
.formulario {
align-self: start;
margin-top: 14px;
background: var(--superficie);
border: 1px solid var(--borde);
border-radius: 16px;
padding: 16px 16px 14px;
display: grid;
gap: 13px;
transform-origin: 50% 0;
will-change: transform, opacity;
}
.campo { display: grid; gap: 7px; border: 0; min-width: 0; }
.etiqueta { font-size: 12px; font-weight: 600; color: var(--gris); letter-spacing: .02em; }
.campo input[type="text"] {
width: 100%; height: 44px; padding: 0 14px;
background: var(--fondo); border: 1px solid var(--borde); border-radius: 10px;
font-size: 16px; outline: none;
transition: border-color .2s;
}
.campo input[type="text"]::placeholder { color: #4A5650; }
.campo input[type="text"]:focus-visible, .campo.escribiendo input { border-color: var(--verde); }
/* Contador de personas: − 2 + */
.contador { display: flex; align-items: center; gap: 10px; }
.contador button {
width: 44px; height: 40px; border-radius: 10px; border: 1px solid var(--borde);
background: var(--fondo); font-size: 20px; cursor: pointer; transition: border-color .2s, transform .12s;
}
.contador button:active, .contador button.pulsado { transform: scale(.92); border-color: var(--verde); }
.contador output { min-width: 3ch; text-align: center; font: 700 22px/1 ui-monospace, monospace; }
.contador .unidad { color: var(--gris); font-size: 14px; }
/* Fichas (días y horas): son radios de verdad, así funcionan con flechas del teclado */
.fichas { display: grid; gap: 6px; }
.fichas.dias { grid-template-columns: repeat(5, 1fr); }
.fichas.horas { grid-template-columns: repeat(3, 1fr); }
.ficha { position: relative; }
.ficha input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.ficha span {
display: grid; place-items: center; height: 40px; border-radius: 10px;
background: var(--fondo); border: 1px solid var(--borde);
font-size: 13px; line-height: 1.05; text-align: center;
transition: background .2s, color .2s, border-color .2s, transform .15s;
}
.fichas.dias .ficha span { height: 46px; }
.ficha span b { display: block; font: 700 15px/1 ui-monospace, monospace; }
.ficha span i { font-style: normal; font-size: 10px; color: var(--gris); text-transform: uppercase; letter-spacing: .06em; }
.ficha input:checked + span { background: var(--verde); color: var(--fondo); border-color: var(--verde); }
.ficha input:checked + span i { color: rgba(5, 7, 10, .7); }
.ficha input:focus-visible + span { outline: 2px solid var(--verde); outline-offset: 2px; }
/* Validación amable: el mensaje aparece debajo del campo y el campo se balancea un poco */
.aviso { min-height: 0; font-size: 12px; color: #FFB27A; display: none; }
.campo.mal .aviso { display: block; }
.campo.mal input[type="text"] { border-color: #FFB27A; }
.campo.mal .fichas span { border-color: rgba(255, 178, 122, .5); }
.campo.balanceo { animation: balanceo .4s ease; }
@keyframes balanceo { 25% { transform: translateX(-5px); } 50% { transform: translateX(4px); } 75% { transform: translateX(-2px); } }
.enviar {
height: 50px; border: 0; border-radius: 12px; cursor: pointer;
background: var(--verde); color: var(--fondo); font-weight: 800; font-size: 16px; letter-spacing: .01em;
display: flex; align-items: center; justify-content: center; gap: 10px;
transition: transform .12s, filter .2s;
}
.enviar:hover { filter: brightness(1.08); }
.enviar:active, .enviar.pulsado { transform: scale(.97); }
.enviar:focus-visible, .contador button:focus-visible, .otra:focus-visible { outline: 2px solid var(--blanco); outline-offset: 3px; }
/* ---------- El ticket ---------- */
.ticket {
align-self: start;
position: relative;
margin: 0 10px;
padding: 20px 20px 34px;
background:
repeating-linear-gradient(0deg, rgba(0, 0, 0, .018) 0 2px, transparent 2px 4px),
var(--papel);
color: var(--tinta);
font: 13px/1.5 ui-monospace, "Cascadia Mono", Consolas, monospace;
/* Borde dentado abajo: un degradado cónico repetido hace de «dientes» y la máscara los recorta */
-webkit-mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50% / 16px 100%;
mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50% / 16px 100%;
transform: translateY(-101%); /* empieza escondido dentro de la impresora */
will-change: transform;
}
.ticket .cabecera { text-align: center; }
.ticket .cabecera strong { display: block; font-size: 18px; letter-spacing: .14em; }
.ticket .cabecera span { font-size: 11px; color: #5B615D; }
.ticket hr { border: 0; border-top: 1px dashed #9AA19C; margin: 10px 0; }
.ticket dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; }
.ticket dt { color: #5B615D; }
.ticket dd { text-align: right; font-weight: 700; overflow-wrap: anywhere; }
.ticket .codigo { text-align: center; margin-top: 10px; }
.ticket svg { display: block; width: 100%; height: 50px; }
.ticket .codigo span { font-size: 11px; letter-spacing: .3em; }
/* Hueco reservado para que el sello no tape ningún dato */
.ticket .hueco-sello { height: 62px; }
.ticket .pie { text-align: center; font-size: 11px; color: #5B615D; margin-top: 8px; }
/* El sello: tinta verde, girado, con textura de tinta irregular (máscara de puntos) */
.sello {
position: absolute; left: 0; right: 0; top: var(--sello-y, 220px); width: max-content; margin: 0 auto; white-space: nowrap;
padding: 6px 12px; border: 3px solid var(--sello); border-radius: 8px;
color: var(--sello); font: 900 22px/1 system-ui, sans-serif; letter-spacing: .12em;
transform: rotate(-14deg); opacity: 0;
mix-blend-mode: multiply;
-webkit-mask: radial-gradient(circle at 30% 40%, #000 60%, rgba(0,0,0,.55) 61%) 0 0 / 7px 5px;
mask: radial-gradient(circle at 30% 40%, #000 60%, rgba(0,0,0,.55) 61%) 0 0 / 7px 5px;
pointer-events: none;
}
.otra {
margin-top: 6px; height: 44px; padding: 0 20px; border-radius: 22px;
background: transparent; border: 1px solid var(--borde); color: var(--blanco); cursor: pointer;
font-weight: 600; font-size: 14px;
opacity: 0; transform: translateY(8px); pointer-events: none;
transition: opacity .35s, transform .35s, border-color .2s;
}
.otra.visible { opacity: 1; transform: none; pointer-events: auto; }
.otra:hover { border-color: var(--verde); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* Pantallas bajitas: compactamos un poco */
@media (max-height: 760px) {
.formulario { gap: 9px; padding: 12px; }
.intro h1 { font-size: 18px; }
}
/* Escritorio: dos columnas, titular grande a la izquierda */
@media (min-width: 900px) {
:root { --ancho-papel: 420px; }
.ticket { font-size: 15px; }
.escena {
grid-template-columns: minmax(0, 460px) auto;
grid-template-rows: 1fr;
align-items: center; justify-content: center;
column-gap: 90px; padding: 40px;
}
.intro { text-align: left; }
.intro h1 { font-size: 54px; line-height: 1.02; letter-spacing: -.04em; margin-top: 16px; }
.intro p { display: block; color: var(--gris); font-size: 17px; line-height: 1.55; margin-top: 18px; max-width: 38ch; }
.maquina { align-self: center; }
}
/* Sin movimiento: no hay pliegue ni avance a saltos, solo fundidos suaves */
@media (prefers-reduced-motion: reduce) {
.campo.balanceo { animation: none; }
.impresora.imprimiendo .led { animation: none; opacity: 1; }
}
(() => {
const $ = (id) => document.getElementById(id);
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const formulario = $('formulario'), ticket = $('ticket'), impresora = $('impresora');
const nombre = $('nombre'), personasOut = $('personas'), sello = $('sello'), otra = $('otra');
/* ---------- 1. Fichas de días (los próximos 5) y de horas ---------- */
const hoy = new Date();
const dias = Array.from({ length: 5 }, (_, i) => new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + i));
const corto = new Intl.DateTimeFormat('es-ES', { weekday: 'short' });
const largo = new Intl.DateTimeFormat('es-ES', { weekday: 'short', day: 'numeric', month: 'short' });
$('dias').innerHTML = dias.map((d, i) => `
<label class="ficha"><input type="radio" name="dia" value="${i}">
<span><i>${i === 0 ? 'hoy' : i === 1 ? 'mañ.' : corto.format(d).replace('.', '')}</i><b>${d.getDate()}</b></span>
</label>`).join('');
const horas = ['13:30', '14:00', '14:30', '20:30', '21:00', '21:30'];
$('horas').innerHTML = horas.map((h) => `
<label class="ficha"><input type="radio" name="hora" value="${h}"><span>${h}</span></label>`).join('');
/* ---------- 2. Contador de personas (1 a 12) ---------- */
let personas = 2;
function ponerPersonas(n) {
personas = Math.min(12, Math.max(1, n));
personasOut.textContent = personas;
$('unidad').textContent = personas === 1 ? 'comensal' : 'comensales';
}
$('menos').addEventListener('click', () => ponerPersonas(personas - 1));
$('mas').addEventListener('click', () => ponerPersonas(personas + 1));
/* ---------- 3. Validación amable ---------- */
function marcar(campo, mal) {
campo.classList.toggle('mal', mal);
if (mal && !quieto) { // un balanceo corto, no un grito
campo.classList.remove('balanceo'); void campo.offsetWidth; campo.classList.add('balanceo');
}
}
function validar() {
const datos = {
nombre: nombre.value.trim(),
dia: formulario.querySelector('input[name="dia"]:checked'),
hora: formulario.querySelector('input[name="hora"]:checked'),
};
const fallos = [
[$('campo-nombre'), datos.nombre.length < 2],
[$('campo-dia'), !datos.dia],
[$('campo-hora'), !datos.hora],
];
fallos.forEach(([c, mal]) => marcar(c, mal));
const primero = fallos.find(([, mal]) => mal);
if (primero) { primero[0].querySelector('input').focus({ preventScroll: true }); return null; }
return datos;
}
// El aviso se va en cuanto el campo se arregla
formulario.addEventListener('input', (e) => {
const campo = e.target.closest('.campo');
if (campo && campo.classList.contains('mal')) {
const ok = e.target.type === 'text' ? e.target.value.trim().length >= 2 : true;
if (ok) campo.classList.remove('mal');
}
});
/* ---------- 4. Código de barras dibujado en SVG ----------
Decorativo: cada carácter del código decide el grosor de unas barras. */
function dibujarBarras(codigo) {
let x = 4, rects = '';
const semilla = [...codigo].map((c) => c.charCodeAt(0));
for (let i = 0; x < 194; i++) {
const v = (semilla[i % semilla.length] * (i + 7)) % 11;
const ancho = 1 + (v % 3); // barras de 1, 2 o 3 unidades
rects += `<rect x="${x}" y="0" width="${ancho}" height="50"/>`;
x += ancho + 1 + (v > 6 ? 2 : 1); // hueco variable entre barras
}
$('barras').innerHTML = `<g fill="#1A1D1B">${rects}</g>`;
}
/* ---------- 5. La secuencia: plegar → imprimir → sellar ---------- */
let ocupado = false;
const esperar = (ms) => new Promise((r) => setTimeout(r, ms));
// Avance «a tirones» de impresora térmica: N saltos; cada salto se mueve en el
// primer 45 % de su tramo y se queda quieto el resto.
function fotogramasImpresion(saltos) {
const k = [{ transform: 'translateY(-101%)', offset: 0 }];
for (let i = 1; i <= saltos; i++) {
const y = -101 + (101 * i) / saltos;
const ini = (i - 1) / saltos;
k.push({ transform: `translateY(${-101 + (101 * (i - 1)) / saltos}%)`, offset: ini, easing: 'cubic-bezier(.3,.7,.4,1)' });
k.push({ transform: `translateY(${y}%)`, offset: ini + 0.45 / saltos });
}
k.push({ transform: 'translateY(0%)', offset: 1 });
return k;
}
async function imprimir(datos) {
ocupado = true;
const dia = dias[+datos.dia.value];
const codigo = 'R-' + Math.random().toString(36).slice(2, 6).toUpperCase();
$('t-nombre').textContent = datos.nombre;
$('t-personas').textContent = personas;
$('t-dia').textContent = largo.format(dia).replace(/\./g, '');
$('t-hora').textContent = datos.hora.value;
$('t-mesa').textContent = String(3 + Math.floor(Math.random() * 18)).padStart(2, '0');
$('t-codigo').textContent = codigo;
dibujarBarras(codigo);
sello.style.opacity = 0;
// El sello cae en el centro del hueco reservado
const hueco = $('hueco-sello');
ticket.style.setProperty('--sello-y', hueco.offsetTop + hueco.offsetHeight / 2 - 19 + 'px');
// a) El formulario se inclina y se lo «traga» la ranura
await formulario.animate(quieto
? [{ opacity: 1 }, { opacity: 0 }]
: [
{ transform: 'none', opacity: 1 },
{ transform: 'perspective(900px) rotateX(-14deg) scale(.97)', opacity: 1, offset: .25 },
{ transform: 'perspective(900px) translateY(-105%) rotateX(-40deg) scaleY(.5)', opacity: .3 },
], { duration: quieto ? 250 : 650, easing: 'cubic-bezier(.6,0,.8,.4)', fill: 'forwards' }).finished;
formulario.inert = true;
// b) La impresora trabaja: el ticket sale a tirones, línea a línea
impresora.classList.add('imprimiendo');
const vibra = quieto ? null : impresora.animate(
[{ transform: 'translateY(0)' }, { transform: 'translateY(-1px)' }, { transform: 'translateY(.5px)' }],
{ duration: 90, iterations: Infinity });
await ticket.animate(quieto
? [{ transform: 'none', opacity: 0 }, { transform: 'none', opacity: 1 }]
: fotogramasImpresion(14),
{ duration: quieto ? 300 : 2100, fill: 'forwards' }).finished;
vibra && vibra.cancel();
impresora.classList.remove('imprimiendo');
// c) El sello cae de golpe y el papel acusa el impacto
await esperar(quieto ? 0 : 220);
sello.style.opacity = '';
sello.animate(quieto
? [{ opacity: 0 }, { opacity: .9 }]
: [
{ transform: 'rotate(-4deg) scale(2.6)', opacity: 0 },
{ transform: 'rotate(-16deg) scale(.9)', opacity: .95, offset: .7 },
{ transform: 'rotate(-14deg) scale(1)', opacity: .9 },
], { duration: quieto ? 200 : 260, easing: 'cubic-bezier(.5,0,.9,.5)', fill: 'forwards' });
if (!quieto) {
await esperar(180);
ticket.animate([{ translate: '0 0' }, { translate: '0 4px' }, { translate: '0 -1px' }, { translate: '0 0' }],
{ duration: 260, easing: 'ease-out' });
}
$('anuncio').textContent = `Reserva confirmada para ${personas} el ${$('t-dia').textContent} a las ${datos.hora.value}. Código ${codigo}.`;
otra.classList.add('visible');
ocupado = false;
}
// Vuelta atrás: el ticket se arranca hacia abajo y el formulario vuelve a salir limpio
async function otraReserva() {
if (ocupado) return;
ocupado = true;
otra.classList.remove('visible');
await ticket.animate(quieto
? [{ opacity: 1 }, { opacity: 0 }]
: [{ transform: 'translateY(0) rotate(0)', opacity: 1 }, { transform: 'translateY(30%) rotate(4deg)', opacity: 0 }],
{ duration: quieto ? 200 : 420, easing: 'cubic-bezier(.5,0,.8,.3)', fill: 'forwards' }).finished;
ticket.getAnimations().forEach((a) => a.cancel());
sello.getAnimations().forEach((a) => a.cancel());
sello.style.opacity = 0;
formulario.reset();
ponerPersonas(2);
formulario.querySelectorAll('.mal').forEach((c) => c.classList.remove('mal'));
formulario.getAnimations().forEach((a) => a.cancel());
formulario.inert = false;
await formulario.animate(quieto
? [{ opacity: 0 }, { opacity: 1 }]
: [{ transform: 'translateY(-105%)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: quieto ? 200 : 520, easing: 'cubic-bezier(.2,.8,.2,1)' }).finished;
ocupado = false;
}
formulario.addEventListener('submit', (e) => {
e.preventDefault(); // no se envía nada a ningún sitio
if (ocupado) return;
const datos = validar();
if (datos) imprimir(datos).then(() => { if (!demo.activo) otra.focus({ preventScroll: true }); });
});
otra.addEventListener('click', async () => {
await otraReserva();
nombre.focus({ preventScroll: true });
});
/* ---------- 6. Modo demostración: alguien escribe y reserva, en bucle ----------
Se para para siempre en cuanto la persona toca, hace clic o pulsa una tecla. */
const demo = { activo: true };
const nombres = ['Marta R.', 'Grupo Olivo', 'Andrés P.', 'Cumple de Lía'];
const pausa = async (ms) => { await esperar(ms); if (!demo.activo) throw 'parado'; };
const pulsar = async (el) => { el.classList.add('pulsado'); await pausa(140); el.classList.remove('pulsado'); };
async function bucleDemo() {
let vuelta = 0;
try {
await pausa(700);
while (demo.activo) {
// Escribe el nombre letra a letra
const campo = $('campo-nombre');
campo.classList.add('escribiendo');
for (const letra of nombres[vuelta % nombres.length]) {
nombre.value += letra;
await pausa(70 + Math.random() * 60);
}
campo.classList.remove('escribiendo');
await pausa(250);
// Personas, día y hora
const extra = 1 + (vuelta % 3);
for (let i = 0; i < extra; i++) { await pulsar($('mas')); ponerPersonas(personas + 1); await pausa(160); }
formulario.querySelectorAll('input[name="dia"]')[(vuelta + 1) % 5].checked = true;
await pausa(380);
formulario.querySelectorAll('input[name="hora"]')[(vuelta * 2 + 4) % 6].checked = true;
await pausa(450);
// Pulsa «Reservar» y deja que la impresora haga su trabajo
await pulsar($('enviar'));
formulario.requestSubmit();
while (ocupado) await pausa(100);
await pausa(2600);
await otraReserva();
await pausa(600);
vuelta++;
}
} catch (e) { $('campo-nombre').classList.remove('escribiendo'); }
}
const pararDemo = () => {
if (!demo.activo) return;
demo.activo = false;
['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => removeEventListener(t, pararDemo, true));
};
['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach((t) => addEventListener(t, pararDemo, { capture: true, passive: true }));
bucleDemo();
})();
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
3:20La reserva que se imprime como un ticket