Files
brandize-barcode/index.html
T
Oliver 45cde10a21 async
2026-05-25 12:31:47 -03:00

1519 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<title>Health Routine Package Scanner</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--primary: #005f5a;
--primary-dark: #004845;
--primary-light: #e0f0ef;
--bg: #f7f7fa;
--card: #eaeaf2;
--white: #ffffff;
--text: #0f0f0f;
--text-muted: rgba(15, 15, 15, 0.55);
--border: #d4d4d4;
--sage: #82917d;
--error: #c71b1b;
--error-bg: #fff0f0;
--success: #15803d;
--success-bg: #f0fdf4;
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 20px rgba(0, 95, 90, 0.13);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
}
html,
body {
height: 100%;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 16px;
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100dvh;
overflow: hidden;
}
/* ── Brand Bar ───────────────────────────────────────── */
.brand-bar {
background: var(--white);
border-bottom: 1px solid var(--border);
padding: 10px 20px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.brand-logo-wrap {
width: 38px;
height: 38px;
background: var(--primary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 5px;
}
.brand-logo-wrap img {
width: 100%;
height: 100%;
object-fit: contain;
filter: invert(1) brightness(10);
}
.brand-name {
font-size: 17px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.3px;
}
.brand-divider {
width: 1px;
height: 18px;
background: var(--border);
}
.brand-subtitle {
font-size: 13px;
color: var(--text-muted);
font-weight: 400;
}
/* ── Controls Bar ────────────────────────────────────── */
.controls-bar {
background: var(--primary);
padding: 12px 20px;
box-shadow: var(--shadow-md);
flex-shrink: 0;
}
.controls-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* Scan input */
.scan-wrap {
flex: 1 1 220px;
position: relative;
min-width: 0;
}
.scan-icon {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
pointer-events: none;
opacity: 0.75;
}
.scan-icon svg {
width: 18px;
height: 18px;
fill: white;
}
#scanInput {
width: 100%;
padding: 11px 14px 11px 42px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: var(--r-md);
background: rgba(255, 255, 255, 0.15);
color: white;
font-size: 16px;
font-weight: 600;
letter-spacing: 1.5px;
outline: none;
transition:
border-color 0.2s,
background 0.2s,
box-shadow 0.2s;
-webkit-font-smoothing: antialiased;
}
#scanInput::placeholder {
color: rgba(255, 255, 255, 0.65);
font-weight: 400;
letter-spacing: 2.5px;
font-size: 14px;
}
#scanInput:focus {
border-color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.22);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
}
/* User dropdown */
#userSelect {
flex: 0 1 175px;
padding: 11px 36px 11px 13px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: var(--r-md);
background: rgba(255, 255, 255, 0.15);
color: white;
font-size: 14px;
font-weight: 500;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(255,255,255,0.75)'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 13px center;
transition:
border-color 0.2s,
background 0.2s;
min-width: 0;
}
#userSelect:focus {
border-color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.22);
}
#userSelect option {
background: var(--primary-dark);
color: white;
font-weight: 400;
}
/* Clear button */
.btn-clear {
flex-shrink: 0;
padding: 11px 18px;
border: 2px solid rgba(255, 255, 255, 0.45);
border-radius: var(--r-md);
background: transparent;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s,
transform 0.1s;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.btn-clear:hover {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.8);
}
.btn-clear:active {
transform: scale(0.97);
}
/* ── Status Banner ───────────────────────────────────── */
#statusBanner {
flex-shrink: 0;
padding: 9px 20px;
font-size: 13px;
font-weight: 500;
text-align: center;
display: none;
align-items: center;
justify-content: center;
gap: 8px;
}
#statusBanner.active {
display: flex;
}
#statusBanner.error {
background: var(--error-bg);
color: var(--error);
border-bottom: 1px solid #fecaca;
}
#statusBanner.loading {
background: var(--primary-light);
color: var(--primary);
border-bottom: 1px solid #a7d7d4;
}
.dot-spinner {
display: inline-flex;
gap: 4px;
}
.dot-spinner span {
width: 5px;
height: 5px;
background: var(--primary);
border-radius: 50%;
animation: dotPulse 1.2s ease-in-out infinite;
}
.dot-spinner span:nth-child(2) {
animation-delay: 0.2s;
}
.dot-spinner span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dotPulse {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* ── Main / Table ────────────────────────────────────── */
main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 16px 20px 20px;
gap: 10px;
overflow: hidden;
}
.table-meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.table-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
}
.count-pill {
background: var(--primary);
color: white;
font-size: 12px;
font-weight: 700;
padding: 2px 11px;
border-radius: 99px;
letter-spacing: 0.2px;
transition: all 0.2s;
}
.count-pill.zero {
background: var(--border);
color: var(--text-muted);
}
/* Table wrapper — this scrolls */
.table-wrapper {
flex: 1;
min-height: 0;
overflow: auto;
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
background: var(--white);
border: 1px solid var(--border);
position: relative;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 380px;
}
/* Sticky header */
thead {
position: sticky;
top: 0;
z-index: 5;
}
thead tr {
background: var(--primary);
}
thead th {
padding: 13px 18px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
border-bottom: 2px solid var(--primary-dark);
}
thead th:first-child {
width: 90px;
}
thead th:last-child {
width: 200px;
}
/* Hidden code column — stores raw scan code for duplicate checking */
.col-code {
display: none;
}
/* Queue status pill */
.queue-pill {
background: var(--primary-light);
color: var(--primary);
font-size: 12px;
font-weight: 700;
padding: 2px 11px;
border-radius: 99px;
letter-spacing: 0.2px;
display: flex;
align-items: center;
gap: 5px;
}
.queue-pill.hidden {
display: none;
}
/* Pending row */
.row-pending td {
padding: 0;
}
.pending-cell {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 18px;
font-size: 13px;
color: var(--text-muted);
}
.pending-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 95, 90, 0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.pending-label {
font-style: italic;
}
.pending-code {
font-family: "Courier New", monospace;
font-size: 12px;
color: var(--text);
opacity: 0.6;
}
/* Error row */
.row-error td {
padding: 0;
}
.error-cell {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 18px;
background: var(--error-bg);
font-size: 13px;
}
.error-icon {
color: var(--error);
font-weight: 700;
flex-shrink: 0;
}
.error-msg {
color: var(--error);
flex: 1;
}
.error-code {
font-family: "Courier New", monospace;
font-size: 11px;
color: var(--error);
opacity: 0.55;
}
tbody tr {
border-bottom: 1px solid var(--card);
animation: rowIn 0.28s ease-out;
transition: background 0.15s;
}
@keyframes rowIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
tbody tr:nth-child(odd) {
background: var(--white);
}
tbody tr:nth-child(even) {
background: #f4f4f8;
}
tbody tr:hover {
background: var(--primary-light) !important;
}
tbody tr:last-child {
border-bottom: none;
}
tbody td {
padding: 12px 18px;
font-size: 14px;
color: var(--text);
vertical-align: middle;
}
.country-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--primary-light);
color: var(--primary);
font-weight: 800;
font-size: 12px;
padding: 3px 10px;
border-radius: 99px;
letter-spacing: 0.8px;
}
.name-cell {
font-weight: 500;
font-size: 14px;
}
.tag-pill {
display: inline-block;
background: #f3f4f6;
color: #374151;
font-family: "Courier New", "SF Mono", monospace;
font-size: 12px;
padding: 4px 10px;
border-radius: var(--r-sm);
border: 1px solid #e5e7eb;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
/* Empty state */
.empty-state {
padding: 64px 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-muted);
}
.empty-icon {
width: 56px;
height: 56px;
background: var(--card);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-icon svg {
width: 26px;
height: 26px;
stroke: var(--text-muted);
fill: none;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--text-muted);
}
.empty-hint {
font-size: 13px;
color: var(--border);
text-align: center;
max-width: 260px;
}
/* ── Auth Modal ──────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-overlay.hidden {
display: none;
}
.modal-card {
background: var(--white);
border-radius: var(--r-lg);
padding: 30px 28px 26px;
max-width: 420px;
width: 100%;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
animation: modalIn 0.22s ease-out;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.94) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-icon {
width: 44px;
height: 44px;
background: var(--primary-light);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.modal-icon svg {
width: 22px;
height: 22px;
stroke: var(--primary);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.modal-title {
font-size: 19px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
}
.modal-sub {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 22px;
}
.field-label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--text);
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 6px;
}
.field-input {
display: block;
width: 100%;
padding: 11px 13px;
border: 2px solid var(--border);
border-radius: var(--r-sm);
font-size: 15px;
color: var(--text);
outline: none;
transition:
border-color 0.18s,
box-shadow 0.18s;
margin-bottom: 14px;
background: var(--white);
}
.field-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 95, 90, 0.1);
}
.modal-error {
font-size: 13px;
color: var(--error);
display: none;
margin-bottom: 14px;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 6px;
}
.btn-ghost {
padding: 11px 18px;
background: transparent;
color: var(--text-muted);
border: 2px solid var(--border);
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s;
}
.btn-ghost:hover {
background: var(--card);
border-color: #bbb;
}
.btn-primary {
flex: 1;
padding: 11px 18px;
background: var(--primary);
color: white;
border: 2px solid transparent;
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition:
background 0.2s,
transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Inline spinner for buttons */
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: white;
border-radius: 50%;
animation: spin 0.55s linear infinite;
display: none;
}
.btn-primary.loading .btn-spinner {
display: block;
}
.btn-primary.loading .btn-text {
opacity: 0.8;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── Toast ───────────────────────────────────────────── */
#toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(16px);
background: var(--text);
color: white;
padding: 10px 22px;
border-radius: 99px;
font-size: 14px;
font-weight: 500;
box-shadow: var(--shadow-md);
z-index: 2000;
opacity: 0;
transition:
opacity 0.25s,
transform 0.25s;
pointer-events: none;
white-space: nowrap;
}
#toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#toast.ok {
background: var(--success);
}
#toast.fail {
background: var(--error);
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 620px) {
.brand-divider,
.brand-subtitle {
display: none;
}
.controls-inner {
gap: 8px;
}
.scan-wrap {
flex: 1 1 100%;
order: 1;
}
#userSelect {
flex: 1 1 auto;
order: 2;
min-width: 0;
}
.btn-clear {
order: 3;
}
main {
padding: 12px;
}
thead th,
tbody td {
padding: 11px 12px;
}
.tag-pill {
max-width: 130px;
font-size: 11px;
}
}
@media (min-width: 621px) and (max-width: 960px) {
.scan-wrap {
flex: 1 1 240px;
}
#userSelect {
flex: 0 1 150px;
}
}
/* Scrollbar styling */
.table-wrapper::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.table-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.table-wrapper::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 99px;
}
.table-wrapper::-webkit-scrollbar-thumb:hover {
background: #b4b4b4;
}
</style>
</head>
<body>
<!-- Brand bar -->
<div class="brand-bar">
<div class="brand-logo-wrap">
<img src="./logo.png" alt="Health Routine Logo" />
</div>
<span class="brand-name">Health Routine</span>
<div class="brand-divider"></div>
<span class="brand-subtitle">Package Scanner</span>
</div>
<!-- Controls bar -->
<div class="controls-bar">
<div class="controls-inner">
<div class="scan-wrap">
<span class="scan-icon">
<!-- Barcode icon -->
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="2" y="4" width="2" height="16" />
<rect x="6" y="4" width="1" height="16" />
<rect x="9" y="4" width="2" height="16" />
<rect x="13" y="4" width="1" height="16" />
<rect x="16" y="4" width="3" height="16" />
<rect x="21" y="4" width="1" height="16" />
</svg>
</span>
<input
type="text"
id="scanInput"
name="NAME"
placeholder="SCAN HERE"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
inputmode="text"
/>
</div>
<select id="userSelect">
<option value="">Loading…</option>
</select>
<button class="btn-clear" onclick="clearTable()">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<line x1="1" y1="1" x2="11" y2="11" />
<line x1="11" y1="1" x2="1" y2="11" />
</svg>
Clear
</button>
</div>
</div>
<!-- Status banner -->
<div id="statusBanner"></div>
<!-- Main content -->
<main>
<div class="table-meta">
<span class="table-label">Scan Log</span>
<div style="display: flex; align-items: center; gap: 8px">
<span class="queue-pill hidden" id="queuePill">
<span
class="pending-spinner"
style="
width: 10px;
height: 10px;
border-width: 1.5px;
"
></span>
<span id="queuePillText">0 pending</span>
</span>
<span class="count-pill zero" id="countPill">0 scans</span>
</div>
</div>
<div class="table-wrapper" id="tableWrapper">
<table id="scanTable">
<thead>
<tr>
<th>Country</th>
<th>Name</th>
<th>Tag</th>
<th class="col-code">Code</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<!-- Empty state (shown when no rows) -->
<div class="empty-state" id="emptyState">
<div class="empty-icon">
<svg viewBox="0 0 24 24">
<path
d="M3 7V5a2 2 0 012-2h2M17 3h2a2 2 0 012 2v2M21 17v2a2 2 0 01-2 2h-2M7 21H5a2 2 0 01-2-2v-2"
/>
<rect x="7" y="7" width="10" height="10" rx="1" />
</svg>
</div>
<div class="empty-title">No scans yet</div>
<div class="empty-hint">
Select a user, then scan or type a package code and
press Enter
</div>
</div>
</div>
</main>
<!-- Auth Modal -->
<div class="modal-overlay hidden" id="authModal">
<div class="modal-card">
<div class="modal-icon">
<svg viewBox="0 0 24 24">
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
</div>
<div class="modal-title">Authentication Required</div>
<div class="modal-sub">
The webhook requires basic authentication. Enter your
credentials to continue.
</div>
<label class="field-label" for="authUser">Username</label>
<input
type="text"
class="field-input"
id="authUser"
autocomplete="username"
placeholder="your username"
/>
<label class="field-label" for="authPass">Password</label>
<input
type="password"
class="field-input"
id="authPass"
autocomplete="current-password"
placeholder="••••••••"
/>
<div class="modal-error" id="modalError">
Incorrect credentials. Please try again.
</div>
<div class="modal-actions">
<button class="btn-ghost" onclick="cancelAuth()">
Cancel
</button>
<button
class="btn-primary"
id="authBtn"
onclick="submitAuth()"
>
<span class="btn-spinner"></span>
<span class="btn-text">Connect</span>
</button>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast"></div>
<script>
"use strict";
// ── Config ──────────────────────────────────────────────────────────────
const WEBHOOK =
"https://brandize.app.n8n.cloud/webhook/e0268b0f-4935-49ba-bfdf-1c0ee01d3b9d";
// ── State ───────────────────────────────────────────────────────────────────
let creds = null; // { user, pass }
let pendingAuthCb = null; // function to retry after auth
let scanCount = 0;
const scanQueue = []; // { code, user, rowEl } — FIFO
let queueBusy = false; // true while drainQueue is running
// ── DOM refs ─────────────────────────────────────────────────────────────
const scanInput = document.getElementById("scanInput");
const userSelect = document.getElementById("userSelect");
const tableBody = document.getElementById("tableBody");
const emptyState = document.getElementById("emptyState");
const countPill = document.getElementById("countPill");
const queuePill = document.getElementById("queuePill");
const queuePillText = document.getElementById("queuePillText");
const banner = document.getElementById("statusBanner");
const authModal = document.getElementById("authModal");
const authUser = document.getElementById("authUser");
const authPass = document.getElementById("authPass");
const authBtn = document.getElementById("authBtn");
const modalError = document.getElementById("modalError");
const toast = document.getElementById("toast");
// ── Cookie helpers ────────────────────────────────────────────────────────
function saveCreds(user, pass) {
const val = btoa(
encodeURIComponent(JSON.stringify({ user, pass })),
);
document.cookie = `hr_auth=${val}; max-age=${30 * 24 * 3600}; SameSite=Strict; path=/`;
}
function loadCredsFromCookie() {
const match = document.cookie.match(
/(?:^|;\s*)hr_auth=([^;]+)/,
);
if (match) {
try {
return JSON.parse(decodeURIComponent(atob(match[1])));
} catch (_) {}
}
return null;
}
function clearCredsCookie() {
document.cookie = "hr_auth=; max-age=0; path=/";
}
// ── JSON parser (handles array, single object, and NDJSON) ───────────────
async function parseJson(res) {
const text = await res.text();
// Standard JSON (array or object)
try {
const parsed = JSON.parse(text);
return parsed;
} catch (_) {}
// NDJSON — n8n sometimes returns one object per line
try {
const lines = text
.trim()
.split("\n")
.filter((l) => l.trim());
if (lines.length > 0) {
return lines.map((l) => JSON.parse(l));
}
} catch (_) {}
throw new Error("Could not parse server response");
}
// ── Helpers ──────────────────────────────────────────────────────────────
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getHeaders(json = true) {
const h = {};
if (json) h["Content-Type"] = "application/json";
if (creds) {
h["Authorization"] =
"Basic " + btoa(creds.user + ":" + creds.pass);
}
return h;
}
// ── Toast ────────────────────────────────────────────────────────────────
let toastTimer = null;
function showToast(msg, type = "", ms = 3200) {
clearTimeout(toastTimer);
toast.textContent = msg;
toast.className = "visible " + type;
toastTimer = setTimeout(() => {
toast.className = "";
}, ms);
}
// ── Status Banner ────────────────────────────────────────────────────────
let bannerTimer = null;
function showBanner(msg, type, autohide = 0) {
clearTimeout(bannerTimer);
banner.textContent = "";
if (type === "loading") {
const dots = document.createElement("div");
dots.className = "dot-spinner";
dots.innerHTML = "<span></span><span></span><span></span>";
banner.appendChild(dots);
const t = document.createTextNode(" " + msg);
banner.appendChild(t);
} else {
banner.textContent = msg;
}
banner.className = "active " + type;
if (autohide) bannerTimer = setTimeout(hideBanner, autohide);
}
function hideBanner() {
banner.className = "";
banner.textContent = "";
}
// ── Auth Modal ───────────────────────────────────────────────────────────
function needAuth(retryCb) {
pendingAuthCb = retryCb;
modalError.style.display = "none";
// Pre-fill from saved cookie so user only needs to confirm
const saved = loadCredsFromCookie();
authUser.value = saved?.user || "";
authPass.value = saved?.pass || "";
authModal.classList.remove("hidden");
// Focus password if username already filled, otherwise username
setTimeout(
() => (saved ? authPass.focus() : authUser.focus()),
50,
);
}
function cancelAuth() {
authModal.classList.add("hidden");
pendingAuthCb = null;
showBanner("Authentication cancelled.", "error", 4000);
}
async function submitAuth() {
const u = authUser.value.trim();
const p = authPass.value;
if (!u || !p) {
modalError.textContent =
"Please enter both username and password.";
modalError.style.display = "block";
return;
}
creds = { user: u, pass: p };
saveCreds(u, p); // persist to cookie
authBtn.classList.add("loading");
authBtn.disabled = true;
modalError.style.display = "none";
// Close modal first, then retry
authModal.classList.add("hidden");
authBtn.classList.remove("loading");
authBtn.disabled = false;
if (pendingAuthCb) {
const cb = pendingAuthCb;
pendingAuthCb = null;
await cb();
}
}
authPass.addEventListener("keydown", (e) => {
if (e.key === "Enter") submitAuth();
});
// ── Load Users ───────────────────────────────────────────────────────────
async function loadUsers() {
showBanner("Loading users…", "loading");
try {
const res = await fetch(WEBHOOK, {
method: "GET",
headers: getHeaders(false),
});
if (res.status === 401) {
hideBanner();
needAuth(loadUsers);
return;
}
if (!res.ok) {
showBanner(
`Could not load users (HTTP ${res.status}).`,
"error",
);
userSelect.innerHTML =
'<option value="">Error tap to retry</option>';
return;
}
const data = await parseJson(res);
buildDropdown(data);
hideBanner();
} catch (err) {
console.error(err);
showBanner(
"Network error could not reach the server.",
"error",
);
userSelect.innerHTML =
'<option value="">Offline check connection</option>';
}
}
function buildDropdown(users) {
userSelect.innerHTML =
'<option value="">— Select User —</option>';
// Normalise: webhook may return a single object or an array
const list = Array.isArray(users)
? users
: users
? [users]
: [];
list.forEach((u) => {
const opt = new Option(u.Name, u.Name);
userSelect.appendChild(opt);
});
}
// ── Duplicate check ──────────────────────────────────────────────────────
// Checks both resolved rows and pending rows (all have .col-code)
function isDuplicate(code) {
for (const cell of tableBody.querySelectorAll(".col-code")) {
if (cell.textContent === code) return true;
}
return false;
}
// ── Queue pill ──────────────────────────────────────────────────────────
function updateQueuePill() {
const n = scanQueue.length;
if (n > 0) {
queuePillText.textContent =
n + (n === 1 ? " pending" : " pending");
queuePill.classList.remove("hidden");
} else {
queuePill.classList.add("hidden");
}
}
// ── Enqueue (called immediately on Enter) ──────────────────────────────
function enqueue(raw) {
const code = raw.trim();
if (!code) return;
const user = userSelect.value;
if (!user) {
showToast("Please select a user first!", "fail");
userSelect.style.outline = "2px solid rgba(255,80,80,0.8)";
setTimeout(() => {
userSelect.style.outline = "";
}, 1200);
return;
}
if (isDuplicate(code)) {
showToast("⚠️ Already scanned: " + code, "fail", 4000);
scanInput.select();
return;
}
// Clear input immediately so the next scan can happen right away
scanInput.value = "";
scanInput.focus();
hideBanner();
// Add a pending row to the table (also registers code for dupe detection)
const rowEl = addPendingRow(code);
// Push onto queue
scanQueue.push({ code, user, rowEl });
updateQueuePill();
// Kick off the queue processor if it isnt running already
if (!queueBusy) drainQueue();
}
// ── Queue drain (processes items sequentially in the background) ────────
async function drainQueue() {
if (queueBusy) return;
queueBusy = true;
while (scanQueue.length > 0) {
const item = scanQueue[0]; // peek — only shift after handling
const result = await processItem(item);
if (result === "auth") {
// Credentials needed — pause queue, resume after login
queueBusy = false;
needAuth(drainQueue);
return;
}
scanQueue.shift();
updateQueuePill();
}
queueBusy = false;
updateQueuePill();
}
// ── Process one queued item ────────────────────────────────────────────
async function processItem({ code, user, rowEl }) {
try {
const res = await fetch(WEBHOOK, {
method: "POST",
headers: getHeaders(true),
body: JSON.stringify({ user, code }),
});
if (res.status === 401) return "auth"; // signal caller to pause
if (!res.ok) {
let errMsg = `Scan failed (HTTP ${res.status})`;
try {
const errData = await parseJson(res);
if (errData?.error) errMsg = errData.error;
} catch (_) {}
rowError(rowEl, errMsg);
return "ok";
}
const item = await parseJson(res);
if (item?.error) {
rowError(rowEl, item.error);
return "ok";
}
resolveRow(rowEl, item);
return "ok";
} catch (err) {
console.error(err);
rowError(rowEl, "Network error could not reach server");
return "ok";
}
}
// ── Table rows ────────────────────────────────────────────────────────────────
function addPendingRow(code) {
emptyState.style.display = "none";
const tr = document.createElement("tr");
tr.className = "row-pending";
tr.innerHTML = `
<td colspan="3" class="pending-cell">
<span class="pending-spinner"></span>
<span class="pending-label">Processing…</span>
<span class="pending-code">${esc(code)}</span>
</td>
<td class="col-code">${esc(code)}</td>
`;
tableBody.insertBefore(tr, tableBody.firstChild);
return tr;
}
function resolveRow(rowEl, item) {
const code = rowEl.querySelector(".col-code").textContent;
rowEl.className = "";
rowEl.innerHTML = `
<td><span class="country-badge">${esc(item.country || "—")}</span></td>
<td class="name-cell">${esc(item.name || "—")}</td>
<td><span class="tag-pill" title="${esc(item.producttag || "")}">${esc(item.producttag || "—")}</span></td>
<td class="col-code">${esc(code)}</td>
`;
scanCount++;
countPill.textContent =
scanCount + (scanCount === 1 ? " scan" : " scans");
countPill.classList.remove("zero");
}
function rowError(rowEl, msg) {
const code = rowEl.querySelector(".col-code").textContent;
rowEl.className = "row-error";
rowEl.innerHTML = `
<td colspan="3" class="error-cell">
<span class="error-icon">✕</span>
<span class="error-msg">${esc(msg)}</span>
<span class="error-code">${esc(code)}</span>
</td>
<td class="col-code">${esc(code)}</td>
`;
}
// ── Clear ──────────────────────────────────────────────────────────────────────
function clearTable() {
const hasContent =
scanCount > 0 || scanQueue.length > 0 || scanInput.value;
if (!hasContent) return;
// Cancel any pending queue items
scanQueue.length = 0;
queueBusy = false;
updateQueuePill();
tableBody.innerHTML = "";
scanInput.value = "";
scanCount = 0;
countPill.textContent = "0 scans";
countPill.classList.add("zero");
emptyState.style.display = "";
scanInput.focus();
showToast("Table cleared", "", 2000);
}
// ── Scan Input Enter ──────────────────────────────────────────────────────────
scanInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const v = scanInput.value.trim();
if (v) enqueue(v);
}
});
// Retry on clicking offline dropdown
userSelect.addEventListener("focus", () => {
if (
userSelect.options.length <= 1 &&
userSelect.options[0]?.value === "" &&
/error|offline/i.test(userSelect.options[0]?.text)
) {
loadUsers();
}
});
// ── Init ─────────────────────────────────────────────────────────────────
window.addEventListener("DOMContentLoaded", () => {
// Restore saved credentials before the first network call
const saved = loadCredsFromCookie();
if (saved) creds = saved;
loadUsers();
scanInput.focus();
});
</script>
</body>
</html>