Files
brandize-barcode/index.html
T
2026-05-25 12:25:51 -03:00

1364 lines
48 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;
}
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>
<span class="count-pill zero" id="countPill">0 scans</span>
</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;
// ── 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 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 ──────────────────────────────────────────────────────
function isDuplicate(code) {
const cells = tableBody.querySelectorAll(".col-code");
for (const cell of cells) {
if (cell.textContent === code.trim()) return true;
}
return false;
}
// ── Submit Scan ──────────────────────────────────────────────────────────
async function submitScan(code) {
const user = userSelect.value;
if (!user) {
showToast("Please select a user first!", "fail");
userSelect.focus();
// Short shake animation on the select
userSelect.style.transition = "none";
userSelect.style.outline = "2px solid rgba(255,80,80,0.8)";
setTimeout(() => {
userSelect.style.outline = "";
}, 1200);
return;
}
// Duplicate check against already-scanned codes in the table
if (isDuplicate(code)) {
showToast(
"⚠️ Already scanned: " + code.trim(),
"fail",
4000,
);
scanInput.select();
return;
}
showBanner("Looking up package…", "loading");
try {
const res = await fetch(WEBHOOK, {
method: "POST",
headers: getHeaders(true),
body: JSON.stringify({ user, code: code.trim() }),
});
if (res.status === 401) {
hideBanner();
// Restore input so it's not lost
scanInput.value = code;
needAuth(() => submitScan(code));
return;
}
if (!res.ok) {
// Try to extract a descriptive error from the response body
let errMsg = `Scan failed (HTTP ${res.status}).`;
try {
const errData = await parseJson(res);
if (errData?.error) errMsg = errData.error;
} catch (_) {}
showBanner(errMsg, "error", 0); // persistent until next action
scanInput.select();
return;
}
const item = await parseJson(res);
// Webhook may return 200 but carry an error field
if (item?.error) {
showBanner(item.error, "error", 0);
scanInput.select();
return;
}
hideBanner();
addRow(item, code);
showToast(
"✓ Added: " + (item.name || item.producttag || code),
"ok",
);
// Clear input and keep focus for next scan
scanInput.value = "";
scanInput.focus();
} catch (err) {
console.error(err);
showBanner(
"Network error scan not recorded.",
"error",
5000,
);
scanInput.select();
}
}
// ── Table Row ───────────────────────────────────────────────────────────────────
function addRow(item, code) {
emptyState.style.display = "none";
scanCount++;
countPill.textContent =
scanCount + (scanCount === 1 ? " scan" : " scans");
countPill.classList.remove("zero");
const tr = document.createElement("tr");
tr.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.trim())}</td>
`;
// Newest row at the top
tableBody.insertBefore(tr, tableBody.firstChild);
}
// ── Clear ────────────────────────────────────────────────────────────────
function clearTable() {
if (scanCount === 0 && !scanInput.value) return;
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) submitScan(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>