diff --git a/app.js b/app.js new file mode 100644 index 0000000..021ea7d --- /dev/null +++ b/app.js @@ -0,0 +1,433 @@ +"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, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +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 = ""; + 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 = + ''; + return; + } + + const data = await parseJson(res); + console.log("[HR] Users response:", data); + buildDropdown(data); + hideBanner(); + } catch (err) { + console.error(err); + showBanner("Network error – could not reach the server.", "error"); + userSelect.innerHTML = + ''; + } +} + +function buildDropdown(users) { + userSelect.innerHTML = ''; + + // Normalise all shapes n8n may return: + // { Name: ["Oliver", "Luka", "Benni"] } ← actual format + // [ { Name: "Oliver" }, ... ] ← standard array + // { Name: "Oliver" } ← single object + let names = []; + if (Array.isArray(users)) { + // Array of objects: pull .Name from each + names = users.map((u) => u.Name).filter(Boolean); + } else if (users && Array.isArray(users.Name)) { + // Single object with a Name array + names = users.Name.filter(Boolean); + } else if (users && users.Name) { + // Single object with a single Name string + names = [users.Name]; + } + + names.forEach((name) => { + userSelect.appendChild(new Option(name, name)); + }); +} + +// ── 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 isn't 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 = ` + + + Processing… + ${esc(code)} + + ${esc(code)} + `; + tableBody.insertBefore(tr, tableBody.firstChild); + return tr; +} + +function resolveRow(rowEl, item) { + const code = rowEl.querySelector(".col-code").textContent; + rowEl.className = item.gepackt ? "row-already-packed" : ""; + rowEl.innerHTML = ` + ${esc(item.country || "—")} + ${esc(item.name || "—")} + ${esc(item.producttag || "—")} + ${esc(code)} + `; + 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 = ` + + + ${esc(msg)} + ${esc(code)} + + ${esc(code)} + `; +} + +// ── 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(); +}); diff --git a/index.html b/index.html index ef9c137..3485ecb 100644 --- a/index.html +++ b/index.html @@ -7,887 +7,7 @@ content="width=device-width, initial-scale=1.0, user-scalable=no" /> Health Routine – Package Scanner - + @@ -1071,459 +191,6 @@
- + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..a13936b --- /dev/null +++ b/styles.css @@ -0,0 +1,901 @@ +*, +*::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; +} + +/* Already-packed row */ +.row-already-packed td { + background: #fff0f0 !important; + color: #c71b1b; +} + +.row-already-packed .country-badge { + background: #fecaca; + color: #991b1b; +} + +.row-already-packed .name-cell { + color: #c71b1b; + font-weight: 600; +} + +.row-already-packed .tag-pill { + background: #fee2e2; + border-color: #fca5a5; + color: #991b1b; +} + +/* 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; +}