Ticketing — Door Check-in Scanner
A bold door check-in console for festival gate staff. A mock camera viewport with an animated targeting frame and sweeping laser feeds a color-coded result card that turns green for valid passes, amber for already-used tickets, and red for invalid codes. A live counter tracks admitted guests against capacity with a scan-rate readout, while a manual lookup searches by name or ticket code and a recent-scans feed logs every decision. Vanilla HTML, CSS, and JavaScript only.
MCP
Code
:root {
--brand: #7c3aed;
--brand-d: #6d28d9;
--ink: #0e0e16;
--ink-2: #3a3a4d;
--muted: #6c6c80;
--bg: #f5f4f9;
--surface: #ffffff;
--line: rgba(14, 14, 22, 0.1);
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--accent: #ff3d81;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-sm: 0 1px 2px rgba(14, 14, 22, 0.06), 0 2px 6px rgba(14, 14, 22, 0.05);
--sh-md: 0 8px 24px rgba(14, 14, 22, 0.12);
--sh-lg: 0 24px 60px rgba(14, 14, 22, 0.22);
--tier-ga: #22c55e;
--tier-vip: #ff3d81;
--tier-back: #38bdf8;
--tier-press: #fbbf24;
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-image:
radial-gradient(900px 480px at 110% -10%, rgba(124, 58, 237, 0.12), transparent 60%),
radial-gradient(700px 420px at -10% 0%, rgba(255, 61, 129, 0.1), transparent 55%);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 20px 22px 48px;
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand__mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-size: 14px;
letter-spacing: -1px;
box-shadow: var(--sh-md);
}
.brand__text {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.brand__text strong {
font-weight: 800;
font-size: 16px;
}
.brand__text span {
color: var(--muted);
font-size: 13px;
font-weight: 500;
}
.topbar__meta {
display: flex;
align-items: center;
gap: 10px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 12px;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--line);
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.pill--live {
background: rgba(22, 163, 74, 0.1);
border-color: rgba(22, 163, 74, 0.25);
color: var(--ok);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.5);
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(22, 163, 74, 0); }
100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #fff;
background: linear-gradient(135deg, var(--brand-d), var(--brand));
color: #fff;
font-weight: 700;
font-size: 13px;
cursor: pointer;
box-shadow: var(--sh-sm);
}
/* ---------- Layout ---------- */
.grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 22px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
padding: 20px;
}
.panel__title {
margin: 0;
font-size: 15px;
font-weight: 700;
}
/* ---------- Scanner ---------- */
.scanner__head h1 {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.scanner__head p {
margin: 4px 0 16px;
color: var(--muted);
font-size: 14px;
}
.viewport {
position: relative;
aspect-ratio: 5 / 4;
border-radius: var(--r-md);
overflow: hidden;
background: #0a0a12;
display: grid;
place-items: center;
isolation: isolate;
}
.viewport__feed {
position: absolute;
inset: 0;
background:
radial-gradient(120% 120% at 30% 20%, rgba(124, 58, 237, 0.4), transparent 55%),
radial-gradient(120% 120% at 80% 90%, rgba(255, 61, 129, 0.35), transparent 50%),
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.04) 0 2px, transparent 2px 5px),
#0a0a12;
filter: saturate(1.1);
}
.viewport::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.45));
z-index: 1;
}
.targeting {
position: relative;
z-index: 2;
width: 58%;
aspect-ratio: 1;
max-width: 220px;
}
.corner {
position: absolute;
width: 30px;
height: 30px;
border: 3px solid #fff;
transition: border-color 0.25s;
}
.tl { top: 0; left: 0; border-right: 0; border-bottom: 0; border-top-left-radius: 6px; }
.tr { top: 0; right: 0; border-left: 0; border-bottom: 0; border-top-right-radius: 6px; }
.bl { bottom: 0; left: 0; border-right: 0; border-top: 0; border-bottom-left-radius: 6px; }
.br { bottom: 0; right: 0; border-left: 0; border-top: 0; border-bottom-right-radius: 6px; }
.laser {
position: absolute;
left: 6%;
right: 6%;
top: 12%;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 14px var(--accent);
opacity: 0;
}
.viewport__hint {
position: absolute;
z-index: 3;
bottom: 12px;
margin: 0;
padding: 5px 12px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
backdrop-filter: blur(4px);
}
.viewport[data-state="scanning"] .laser {
opacity: 1;
animation: sweep 1.1s ease-in-out infinite;
}
.viewport[data-state="scanning"] .corner {
border-color: var(--accent);
}
@keyframes sweep {
0%, 100% { top: 12%; }
50% { top: 84%; }
}
.viewport[data-state="valid"] .corner { border-color: var(--ok); }
.viewport[data-state="used"] .corner { border-color: var(--warn); }
.viewport[data-state="invalid"] .corner { border-color: var(--danger); }
.viewport[data-state="valid"],
.viewport[data-state="used"],
.viewport[data-state="invalid"] {
animation: flash 0.4s ease;
}
@keyframes flash {
0% { box-shadow: inset 0 0 0 4px currentColor; }
100% { box-shadow: inset 0 0 0 0 transparent; }
}
/* scan actions */
.scan-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin: 16px 0;
}
.scan-actions__forced {
display: flex;
gap: 6px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-weight: 700;
border: 1px solid transparent;
border-radius: var(--r-md);
padding: 11px 18px;
cursor: pointer;
transition: transform 0.08s, box-shadow 0.2s, background 0.2s, opacity 0.2s;
}
.btn:active { transform: translateY(1px); }
.btn--primary {
background: linear-gradient(135deg, var(--brand), var(--brand-d));
color: #fff;
box-shadow: var(--sh-md);
}
.btn--primary:hover { box-shadow: var(--sh-lg); }
.btn--primary[disabled] { opacity: 0.7; cursor: progress; }
.btn--ghost {
background: var(--surface);
border-color: var(--line);
color: var(--ink);
width: 100%;
margin-top: 4px;
}
.btn--ghost:hover { background: var(--bg); }
.btn__spin {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
display: none;
}
.btn--primary[disabled] .btn__spin {
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.chip {
font: inherit;
font-size: 12.5px;
font-weight: 700;
padding: 8px 11px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink-2);
cursor: pointer;
transition: transform 0.08s, background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover { transform: translateY(-1px); }
.chip--ok:hover { background: rgba(22, 163, 74, 0.1); color: var(--ok); border-color: rgba(22, 163, 74, 0.3); }
.chip--used:hover { background: rgba(217, 119, 6, 0.1); color: var(--warn); border-color: rgba(217, 119, 6, 0.3); }
.chip--bad:hover { background: rgba(220, 38, 38, 0.1); color: var(--danger); border-color: rgba(220, 38, 38, 0.3); }
/* ---------- Result card ---------- */
.result {
border-radius: var(--r-md);
border: 1px solid var(--line);
background: var(--bg);
overflow: hidden;
position: relative;
}
.result__empty {
text-align: center;
padding: 32px 20px;
color: var(--muted);
}
.result__empty .result__icon {
display: block;
font-size: 30px;
margin-bottom: 6px;
opacity: 0.6;
}
.result__empty p { margin: 0; font-size: 14px; font-weight: 500; }
.result__body {
padding: 18px 20px 20px;
position: relative;
}
.result[data-state="valid"] { background: rgba(22, 163, 74, 0.06); border-color: rgba(22, 163, 74, 0.3); }
.result[data-state="used"] { background: rgba(217, 119, 6, 0.07); border-color: rgba(217, 119, 6, 0.32); }
.result[data-state="invalid"] { background: rgba(220, 38, 38, 0.06); border-color: rgba(220, 38, 38, 0.32); }
.result__badge {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 800;
font-size: 13px;
letter-spacing: 0.08em;
padding: 6px 13px;
border-radius: 999px;
color: #fff;
}
.result[data-state="valid"] .result__badge { background: var(--ok); }
.result[data-state="used"] .result__badge { background: var(--warn); }
.result[data-state="invalid"] .result__badge { background: var(--danger); }
.result__badge::before {
content: "✓";
}
.result[data-state="used"] .result__badge::before { content: "⟳"; }
.result[data-state="invalid"] .result__badge::before { content: "✕"; }
.result__perf {
height: 2px;
margin: 16px -20px;
border-top: 2px dashed var(--line);
}
.result__guest {
display: flex;
align-items: center;
gap: 13px;
}
.result__avatar {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-weight: 800;
display: grid;
place-items: center;
flex: none;
box-shadow: var(--sh-sm);
}
.result__guest h2 { margin: 0; font-size: 18px; font-weight: 800; }
.result__guest p { margin: 1px 0 0; color: var(--muted); font-size: 13px; font-weight: 600; }
.tier-dot {
width: 14px;
height: 14px;
border-radius: 50%;
margin-left: auto;
flex: none;
border: 2px solid #fff;
box-shadow: var(--sh-sm);
}
.result__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0 0;
}
.result__meta dt {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 700;
}
.result__meta dd {
margin: 2px 0 0;
font-weight: 700;
font-size: 14px;
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.result__note {
margin: 14px 0 0;
font-size: 13px;
font-weight: 600;
padding: 10px 12px;
border-radius: var(--r-sm);
background: var(--surface);
border: 1px solid var(--line);
}
.result[data-state="used"] .result__note { color: var(--warn); }
.result[data-state="invalid"] .result__note { color: var(--danger); }
/* ---------- Side / stats ---------- */
.side {
display: flex;
flex-direction: column;
gap: 22px;
}
.stats { display: flex; flex-direction: column; gap: 18px; }
.stat { display: flex; flex-direction: column; gap: 3px; }
.stat__label {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 700;
color: var(--muted);
}
.stat--main .stat__value {
font-size: 38px;
font-weight: 800;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.stat__total { font-size: 18px; color: var(--muted); margin-left: 4px; }
.stat__value--sm { font-size: 22px; font-weight: 800; }
.stat__value--sm small { font-size: 12px; color: var(--muted); font-weight: 600; }
.stat__value--bad { color: var(--danger); }
.bar {
height: 9px;
border-radius: 999px;
background: var(--bg);
overflow: hidden;
margin: 10px 0 4px;
border: 1px solid var(--line);
}
.bar__fill {
display: block;
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--accent));
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.stat__pct { font-size: 12.5px; color: var(--muted); font-weight: 600; }
.stat-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding-top: 16px;
border-top: 1px dashed var(--line);
}
/* ---------- Lookup ---------- */
.lookup__field {
display: flex;
align-items: center;
gap: 9px;
margin-top: 12px;
padding: 0 13px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg);
transition: border-color 0.15s, box-shadow 0.15s;
}
.lookup__field:focus-within {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
background: var(--surface);
}
.lookup__field span { color: var(--muted); font-size: 17px; }
.lookup__field input {
flex: 1;
border: 0;
background: transparent;
font: inherit;
font-size: 14px;
padding: 11px 0;
color: var(--ink);
outline: none;
}
.lookup__results { list-style: none; margin: 12px 0 0; padding: 0; }
.lookup__results:empty { display: none; }
.lookup__row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
font: inherit;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--r-md);
padding: 9px 11px;
margin-bottom: 8px;
cursor: pointer;
transition: transform 0.08s, box-shadow 0.15s, border-color 0.15s;
}
.lookup__row:hover { transform: translateX(2px); box-shadow: var(--sh-sm); border-color: var(--brand); }
.lookup__row .tier-dot { margin: 0; width: 11px; height: 11px; }
.lookup__row b { font-size: 14px; }
.lookup__row small {
display: block;
color: var(--muted);
font-size: 12px;
font-family: "JetBrains Mono", monospace;
}
.lookup__row .mini-badge {
margin-left: auto;
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 999px;
}
.mini-badge--in { background: rgba(217, 119, 6, 0.12); color: var(--warn); }
.mini-badge--out { background: rgba(22, 163, 74, 0.12); color: var(--ok); }
.lookup__none { color: var(--muted); font-size: 13px; padding: 4px 2px; margin: 0; }
/* ---------- Recent ---------- */
.recent__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.link {
border: 0;
background: none;
font: inherit;
font-weight: 700;
font-size: 13px;
color: var(--brand);
cursor: pointer;
padding: 4px;
}
.link:hover { text-decoration: underline; }
.recent__list { list-style: none; margin: 14px 0 0; padding: 0; }
.recent__empty { color: var(--muted); font-size: 13px; padding: 4px 0; }
.recent__item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
animation: slideIn 0.3s ease;
}
.recent__item:last-child { border-bottom: 0; }
@keyframes slideIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.recent__icon {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
font-weight: 800;
font-size: 14px;
flex: none;
color: #fff;
}
.recent__icon--valid { background: var(--ok); }
.recent__icon--used { background: var(--warn); }
.recent__icon--invalid { background: var(--danger); }
.recent__name { font-weight: 700; font-size: 14px; }
.recent__sub {
color: var(--muted);
font-size: 12px;
font-family: "JetBrains Mono", monospace;
}
.recent__time {
margin-left: auto;
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
flex: none;
}
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
width: min(360px, calc(100vw - 32px));
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--ink);
color: #fff;
padding: 12px 15px;
border-radius: var(--r-md);
box-shadow: var(--sh-lg);
font-size: 13.5px;
font-weight: 600;
animation: toastIn 0.25s ease;
}
.toast.out { animation: toastOut 0.25s ease forwards; }
.toast::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
.toast--ok::before { background: var(--ok); }
.toast--warn::before { background: var(--warn); }
.toast--bad::before { background: var(--danger); }
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
to { opacity: 0; transform: translateY(12px); }
}
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.app { padding: 14px 14px 40px; }
.topbar { flex-wrap: wrap; gap: 12px; }
.scan-actions { flex-direction: column; align-items: stretch; }
.btn--primary { width: 100%; }
.scan-actions__forced { justify-content: space-between; }
.stat--main .stat__value { font-size: 32px; }
.result__meta { grid-template-columns: 1fr 1fr; }
}(function () {
"use strict";
// ---- Fictional ticket holders ----------------------------------------
var TIERS = {
GA: { label: "General Admission", color: "var(--tier-ga)" },
VIP: { label: "VIP Pit", color: "var(--tier-vip)" },
BACK: { label: "Backstage", color: "var(--tier-back)" },
PRESS: { label: "Press", color: "var(--tier-press)" },
};
var GUESTS = [
{ code: "NP-7K2Q", name: "Mara Velez", tier: "VIP", seat: "Pit A · Row 2" },
{ code: "NP-3H8R", name: "Desmond Okafor", tier: "GA", seat: "Floor · GA" },
{ code: "NP-9F4T", name: "Yuki Tanabe", tier: "BACK", seat: "Backstage · West" },
{ code: "NP-2C6L", name: "Priya Nadkarni", tier: "GA", seat: "Floor · GA" },
{ code: "NP-5M1W", name: "Leo Marchetti", tier: "PRESS", seat: "Press Box · 4" },
{ code: "NP-8B0X", name: "Aisha Rahman", tier: "VIP", seat: "Pit B · Row 1" },
{ code: "NP-4D7N", name: "Connor Walsh", tier: "GA", seat: "Floor · GA" },
{ code: "NP-6J3K", name: "Sofia Reyes", tier: "BACK", seat: "Backstage · East" },
{ code: "NP-1G5P", name: "Theo Lindqvist", tier: "GA", seat: "Floor · GA" },
{ code: "NP-0R9V", name: "Nadia Haddad", tier: "VIP", seat: "Pit A · Row 5" },
];
// track who has already been admitted
var admitted = {};
// ---- DOM refs --------------------------------------------------------
var $ = function (id) { return document.getElementById(id); };
var viewport = $("viewport");
var viewportHint = $("viewportHint");
var resultEl = $("result");
var resultEmpty = $("resultEmpty");
var resultBody = $("resultBody");
var refs = {
badge: $("resultBadge"),
initials: $("resultInitials"),
name: $("resultName"),
tier: $("resultTier"),
tierDot: $("resultTierDot"),
code: $("resultCode"),
seat: $("resultSeat"),
status: $("resultStatus"),
note: $("resultNote"),
override: $("overrideBtn"),
};
var simBtn = $("simScan");
var recentList = $("recentList");
var lookupForm = $("lookupForm");
var lookupInput = $("lookupInput");
var lookupResults = $("lookupResults");
var statCountIn = $("countIn");
var statBar = $("bar");
var statPct = $("pct");
var statRate = $("rate");
var statRejected = $("rejected");
var statElapsed = $("elapsed");
var clockEl = $("clock");
var CAP = 2400;
var checkedIn = 1486; // doors already open a while
var rejected = 7;
var scanTimes = []; // timestamps of valid scans, for rate
var doorsOpened = Date.now() - 71 * 60 * 1000; // ~71 min ago
var scanning = false;
var pendingOverride = null;
// seed the rate so it isn't 0 at load
(function seedRate() {
var now = Date.now();
for (var i = 0; i < 24; i++) scanTimes.push(now - Math.random() * 60000);
})();
// ---- Helpers ---------------------------------------------------------
function initials(name) {
return name.split(/\s+/).map(function (p) { return p[0]; }).join("").slice(0, 2).toUpperCase();
}
function fmtTime(d) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function toast(msg, kind) {
var wrap = $("toastWrap");
var el = document.createElement("div");
el.className = "toast" + (kind ? " toast--" + kind : "");
el.textContent = msg;
wrap.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () { el.remove(); }, 250);
}, 2600);
}
// ---- Stats render ----------------------------------------------------
function renderStats() {
statCountIn.textContent = checkedIn.toLocaleString();
var pct = Math.min(100, Math.round((checkedIn / CAP) * 100));
statBar.style.width = pct + "%";
statPct.textContent = pct + "% of capacity";
statRejected.textContent = rejected;
// scan rate = valid scans in last 60s
var cut = Date.now() - 60000;
scanTimes = scanTimes.filter(function (t) { return t >= cut; });
statRate.textContent = scanTimes.length;
var mins = Math.floor((Date.now() - doorsOpened) / 60000);
statElapsed.textContent = mins >= 60 ? Math.floor(mins / 60) + "h " + (mins % 60) + "m" : mins + "m";
}
// ---- Result card -----------------------------------------------------
function showResult(guest, state, note) {
resultEmpty.hidden = true;
resultBody.hidden = false;
resultEl.setAttribute("data-state", state);
var t = TIERS[guest.tier];
refs.badge.textContent = state === "valid" ? "VALID" : state === "used" ? "ALREADY USED" : "INVALID";
refs.initials.textContent = initials(guest.name);
refs.name.textContent = guest.name;
refs.tier.textContent = t.label;
refs.tierDot.style.background = t.color;
refs.code.textContent = guest.code;
refs.seat.textContent = guest.seat;
refs.status.textContent = state === "valid" ? "Admitted" : state === "used" ? "Re-scan" : "Denied";
refs.note.textContent = note || "";
refs.note.hidden = !note;
// override only for "used" tickets
refs.override.hidden = state !== "used";
pendingOverride = state === "used" ? guest : null;
}
function pushRecent(guest, state) {
var empty = recentList.querySelector(".recent__empty");
if (empty) empty.remove();
var li = document.createElement("li");
li.className = "recent__item";
var glyph = state === "valid" ? "✓" : state === "used" ? "⟳" : "✕";
li.innerHTML =
'<span class="recent__icon recent__icon--' + state + '">' + glyph + "</span>" +
'<div><div class="recent__name">' + guest.name + "</div>" +
'<div class="recent__sub">' + guest.code + " · " + TIERS[guest.tier].label + "</div></div>" +
'<span class="recent__time">' + fmtTime(new Date()) + "</span>";
recentList.insertBefore(li, recentList.firstChild);
while (recentList.children.length > 7) recentList.removeChild(recentList.lastChild);
}
// ---- Process a scan --------------------------------------------------
function processGuest(guest, forced) {
var state, note;
if (forced === "invalid") {
// synthesize a junk ticket
guest = { code: "??-" + Math.random().toString(36).slice(2, 6).toUpperCase(), name: "Unknown pass", tier: "GA", seat: "—" };
state = "invalid";
note = "No matching ticket for this code. Direct guest to the box office.";
} else if (forced === "used" || admitted[guest.code]) {
state = "used";
note = "This ticket was already scanned at " + (admitted[guest.code] || fmtTime(new Date())) + ". Verify ID before re-admitting.";
} else {
state = "valid";
note = TIERS[guest.tier].label + " access granted. Enjoy the show!";
}
if (state === "valid") {
admitted[guest.code] = fmtTime(new Date());
checkedIn = Math.min(CAP, checkedIn + 1);
scanTimes.push(Date.now());
toast(guest.name + " checked in", "ok");
} else if (state === "used") {
toast("Duplicate scan — " + guest.code, "warn");
} else {
rejected += 1;
toast("Invalid ticket rejected", "bad");
}
showResult(guest, state, note);
pushRecent(guest, state);
renderStats();
viewport.setAttribute("data-state", state);
viewportHint.textContent = state === "valid" ? "Admitted" : state === "used" ? "Already used" : "Rejected";
setTimeout(function () {
if (!scanning) {
viewport.setAttribute("data-state", "idle");
viewportHint.textContent = "Camera ready";
}
}, 1400);
}
function runScan(forced) {
if (scanning) return;
scanning = true;
simBtn.disabled = true;
viewport.setAttribute("data-state", "scanning");
viewportHint.textContent = "Scanning…";
setTimeout(function () {
scanning = false;
simBtn.disabled = false;
var guest;
if (forced === "invalid") {
guest = null;
} else if (forced === "used") {
// pick someone already admitted, else mark a fresh one as used
var usedCodes = Object.keys(admitted);
if (usedCodes.length) {
guest = GUESTS.filter(function (g) { return admitted[g.code]; })[0] || GUESTS[0];
} else {
guest = GUESTS[Math.floor(Math.random() * GUESTS.length)];
admitted[guest.code] = fmtTime(new Date(Date.now() - 120000));
}
} else {
guest = GUESTS[Math.floor(Math.random() * GUESTS.length)];
}
processGuest(guest || {}, forced);
}, 850);
}
// ---- Events ----------------------------------------------------------
simBtn.addEventListener("click", function () { runScan(null); });
document.querySelectorAll("[data-force]").forEach(function (btn) {
btn.addEventListener("click", function () { runScan(btn.getAttribute("data-force")); });
});
refs.override.addEventListener("click", function () {
if (!pendingOverride) return;
checkedIn = Math.min(CAP, checkedIn + 1);
scanTimes.push(Date.now());
showResult(pendingOverride, "valid", "Manually overridden by door staff. Admitted.");
pushRecent(pendingOverride, "valid");
toast("Override — " + pendingOverride.name + " admitted", "ok");
renderStats();
});
// Manual lookup
function renderLookup(q) {
lookupResults.innerHTML = "";
q = q.trim().toLowerCase();
if (!q) return;
var matches = GUESTS.filter(function (g) {
return g.name.toLowerCase().indexOf(q) !== -1 || g.code.toLowerCase().indexOf(q) !== -1;
}).slice(0, 5);
if (!matches.length) {
var none = document.createElement("p");
none.className = "lookup__none";
none.textContent = 'No guests match "' + q + '".';
lookupResults.appendChild(none);
return;
}
matches.forEach(function (g) {
var li = document.createElement("li");
var inside = !!admitted[g.code];
var btn = document.createElement("button");
btn.type = "button";
btn.className = "lookup__row";
btn.innerHTML =
'<span class="tier-dot" style="background:' + TIERS[g.tier].color + '"></span>' +
"<span><b>" + g.name + "</b><small>" + g.code + " · " + g.seat + "</small></span>" +
'<span class="mini-badge ' + (inside ? "mini-badge--in" : "mini-badge--out") + '">' +
(inside ? "Inside" : "Not in") + "</span>";
btn.addEventListener("click", function () { directScan(g); });
li.appendChild(btn);
lookupResults.appendChild(li);
});
}
// lookup picks a specific guest (bypass random)
function directScan(guest) {
if (scanning) return;
scanning = true;
simBtn.disabled = true;
viewport.setAttribute("data-state", "scanning");
viewportHint.textContent = "Looking up…";
setTimeout(function () {
scanning = false;
simBtn.disabled = false;
processGuest(guest, null);
}, 700);
}
lookupInput.addEventListener("input", function () { renderLookup(lookupInput.value); });
lookupForm.addEventListener("submit", function (e) {
e.preventDefault();
var first = lookupResults.querySelector(".lookup__row");
if (first) first.click();
});
$("clearRecent").addEventListener("click", function () {
recentList.innerHTML = '<li class="recent__empty">No scans yet.</li>';
toast("Recent scans cleared");
});
// Clock
function tick() {
clockEl.textContent = fmtTime(new Date());
renderStats();
}
tick();
setInterval(tick, 1000);
renderStats();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Door Check-in Scanner — Neon Pulse Festival</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=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar" role="banner">
<div class="brand">
<span class="brand__mark" aria-hidden="true">◢◤</span>
<div class="brand__text">
<strong>Neon Pulse Festival</strong>
<span>Door Check-in · Gate B</span>
</div>
</div>
<div class="topbar__meta">
<span class="pill pill--live"><span class="dot" aria-hidden="true"></span>Live</span>
<span class="pill" id="clock">--:--:--</span>
<button class="avatar" type="button" aria-label="Door staff: Riley Tran">RT</button>
</div>
</header>
<main class="grid">
<!-- Scanner column -->
<section class="panel scanner" aria-label="Scan viewport">
<div class="scanner__head">
<h1>Scan a ticket</h1>
<p>Point the camera at the QR on each guest's pass.</p>
</div>
<div class="viewport" id="viewport" data-state="idle">
<div class="viewport__feed" aria-hidden="true"></div>
<div class="targeting" aria-hidden="true">
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<span class="laser"></span>
</div>
<p class="viewport__hint" id="viewportHint">Camera ready</p>
</div>
<div class="scan-actions">
<button class="btn btn--primary" id="simScan" type="button">
<span class="btn__spin" aria-hidden="true"></span>
Simulate scan
</button>
<div class="scan-actions__forced" role="group" aria-label="Force a result">
<button class="chip chip--ok" data-force="valid" type="button">Valid</button>
<button class="chip chip--used" data-force="used" type="button">Used</button>
<button class="chip chip--bad" data-force="invalid" type="button">Invalid</button>
</div>
</div>
<!-- Result card -->
<article class="result" id="result" data-state="empty" aria-live="polite">
<div class="result__empty" id="resultEmpty">
<span class="result__icon" aria-hidden="true">⬚</span>
<p>Awaiting first scan of the night.</p>
</div>
<div class="result__body" id="resultBody" hidden>
<div class="result__badge" id="resultBadge">VALID</div>
<div class="result__perf" aria-hidden="true"></div>
<div class="result__guest">
<div class="result__avatar" id="resultInitials" aria-hidden="true">—</div>
<div>
<h2 id="resultName">—</h2>
<p id="resultTier">—</p>
</div>
<span class="tier-dot" id="resultTierDot" aria-hidden="true"></span>
</div>
<dl class="result__meta">
<div><dt>Ticket</dt><dd id="resultCode">—</dd></div>
<div><dt>Section</dt><dd id="resultSeat">—</dd></div>
<div><dt>Status</dt><dd id="resultStatus">—</dd></div>
</dl>
<p class="result__note" id="resultNote"></p>
<button class="btn btn--ghost" id="overrideBtn" type="button" hidden>
Override & admit anyway
</button>
</div>
</article>
</section>
<!-- Side column -->
<aside class="side">
<section class="panel stats" aria-label="Check-in stats">
<div class="stat stat--main">
<span class="stat__label">Checked in</span>
<span class="stat__value"><span id="countIn">0</span><span class="stat__total">/ <span id="countCap">2,400</span></span></span>
<div class="bar"><span class="bar__fill" id="bar"></span></div>
<span class="stat__pct" id="pct">0% of capacity</span>
</div>
<div class="stat-row">
<div class="stat">
<span class="stat__label">Scan rate</span>
<span class="stat__value stat__value--sm"><span id="rate">0</span><small>/min</small></span>
</div>
<div class="stat">
<span class="stat__label">Rejected</span>
<span class="stat__value stat__value--sm stat__value--bad" id="rejected">0</span>
</div>
<div class="stat">
<span class="stat__label">Doors open</span>
<span class="stat__value stat__value--sm" id="elapsed">0m</span>
</div>
</div>
</section>
<section class="panel lookup" aria-label="Manual lookup">
<h2 class="panel__title">Manual lookup</h2>
<form id="lookupForm" role="search">
<label class="sr-only" for="lookupInput">Search by name or ticket code</label>
<div class="lookup__field">
<span aria-hidden="true">⌕</span>
<input
id="lookupInput"
type="search"
placeholder="Name or ticket code (e.g. NP-7K2Q)"
autocomplete="off"
/>
</div>
</form>
<ul class="lookup__results" id="lookupResults"></ul>
</section>
<section class="panel recent" aria-label="Recent scans">
<div class="recent__head">
<h2 class="panel__title">Recent scans</h2>
<button class="link" id="clearRecent" type="button">Clear</button>
</div>
<ul class="recent__list" id="recentList">
<li class="recent__empty">No scans yet.</li>
</ul>
</section>
</aside>
</main>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Door Check-in Scanner
A high-contrast gate console for the fictional Neon Pulse Festival. The camera viewport mocks a live feed behind a targeting frame whose corners snap to color as a scan resolves, with a sweeping laser line during the read. Each scan drops into a result card that swaps state between a green valid pass, an amber already-used warning, and a red invalid rejection — complete with the guest’s name, tier dot, ticket code, section, and a contextual note. Used tickets surface an override button so trusted staff can admit after an ID check.
The sidebar keeps the door honest: a large checked-in counter animates against the 2,400 capacity bar, alongside a rolling scan rate per minute, a running rejected tally, and how long the doors have been open. A manual lookup field filters the guest list by name or ticket code as you type, showing whether each person is already inside, and tapping a match runs it straight through the scanner. Every decision lands in a recent-scans feed with timestamps, and toast notifications confirm each action. It all runs on vanilla JavaScript with no frameworks or build step.
Illustrative UI only — fictional events, not a real ticketing service.