Files
karczma-aplikacja-stoliki/public/stolik2 copy 2.html
2026-04-30 20:21:29 +02:00

629 lines
19 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="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Karczma Biesiada Twoje Zamówienie</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=Plus+Jakarta+Sans:wght@300;400;600;700&family=Playfair+Display:wght@700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #e2b07e; /* Ciepłe złoto/miedź */
--bg: #0f0f11;
--surface: #1c1c1f;
--surface-light: #2c2c2e;
--text-main: #ffffff;
--text-muted: #9a9a9e;
--success: #4ade80;
--accent: #f59e0b;
--radius: 20px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
margin: 0;
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.6;
overflow-x: hidden;
}
/* --- LOADER SCREEN --- */
#loadingScreen {
position: fixed;
inset: 0;
z-index: 100;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
transition: opacity 0.5s ease, visibility 0.5s;
}
.loader-icon {
width: 80px;
height: 80px;
border: 3px solid var(--surface-light);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin-bottom: 24px;
}
.loader-text h2 {
font-family: 'Playfair Display', serif;
margin: 0 0 8px;
color: var(--primary);
}
.loader-msg { color: var(--text-muted); font-size: 14px; min-height: 20px; }
/* --- MAIN LAYOUT --- */
.container {
max-width: 500px;
margin: 0 auto;
padding: 24px 16px 100px;
}
header {
text-align: center;
margin-bottom: 32px;
padding-top: 20px;
}
.logo-text {
font-family: 'Playfair Display', serif;
font-size: 28px;
margin: 0;
background: linear-gradient(to right, #fff, var(--primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.table-badge {
display: inline-block;
background: var(--surface-light);
padding: 6px 16px;
border-radius: 99px;
font-size: 13px;
font-weight: 600;
margin-top: 8px;
color: var(--primary);
border: 1px solid rgba(226, 176, 126, 0.2);
}
/* --- STATUS CARD --- */
.status-card {
background: var(--surface);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 24px;
position: relative;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.05);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.status-title {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
display: block;
margin-bottom: 4px;
}
.status-value {
font-size: 22px;
font-weight: 700;
color: var(--text-main);
}
.progress-container {
height: 8px;
background: var(--surface-light);
border-radius: 10px;
margin: 15px 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--primary), var(--accent));
box-shadow: 0 0 15px rgba(226, 176, 126, 0.4);
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- ITEMS LIST --- */
.items-container h3 {
font-size: 18px;
margin: 0 0 16px 8px;
}
.item-card {
background: var(--surface);
margin-bottom: 12px;
padding: 16px;
border-radius: 16px;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.2s;
border-left: 4px solid var(--surface-light);
}
.item-card.ready { border-left-color: var(--success); }
.item-card.archived { opacity: 0.82; }
.history-section { margin-top: 26px; }
.history-note {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 12px 8px;
line-height: 1.45;
}
.item-info { display: flex; flex-direction: column; }
.item-name { font-weight: 600; font-size: 16px; }
.item-meta { font-size: 12px; color: var(--text-muted); }
.item-qty {
background: var(--surface-light);
padding: 6px 12px;
border-radius: 10px;
font-weight: 700;
color: var(--primary);
}
/* --- EMPTY STATE --- */
.empty-state {
text-align: center;
padding: 40px 20px;
}
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
/* --- ANIMATIONS --- */
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { opacity: 0; visibility: hidden; pointer-events: none; display: none; }
.meta-footer {
font-size: 11px;
color: var(--text-muted);
margin-top: 20px;
text-align: center;
}
</style>
</head>
<body>
<div id="loadingScreen">
<div class="loader-icon"></div>
<div class="loader-text">
<h2>Karczma Biesiada</h2>
<div class="loader-msg" id="loaderMsg">Łączenie z kuchnią...</div>
</div>
</div>
<div class="container">
<header>
<h1 class="logo-text">Karczma Biesiada</h1>
<div id="tableLabel" class="table-badge">Wybierz stolik</div>
</header>
<main id="mainContent">
<section class="status-card">
<div class="status-header">
<div>
<span class="status-title">Aktualny status</span>
<div id="prepStatus" class="status-value">Oczekiwanie...</div>
</div>
<div id="statusIcon" style="font-size: 28px;"></div>
</div>
<div class="progress-container">
<div id="progressBar" class="progress-bar"></div>
</div>
<div id="statusMeta" style="font-size: 12px; color: var(--text-muted);">
Sprawdzamy co pysznego się przygotowuje...
</div>
</section>
<section class="items-container">
<h3>Twoje zamówione dania</h3>
<div id="emptyState" class="empty-state hidden">
<div class="empty-icon">🍽️</div>
<p style="color: var(--text-muted)">Aktualnie nie przygotowujemy niczego dla tego stolika.</p>
<p style="font-size: 14px">Jeśli właśnie złożyłeś zamówienie, daj nam chwilkę na jego przetworzenie.</p>
</div>
<div id="itemsList"></div>
</section>
<section id="historySection" class="items-container history-section hidden">
<h3>Twoje poprzednie zamówienia</h3>
<p class="history-note">To są pozycje z innych wizyt, które były widoczne na tym telefonie po zeskanowaniu kodów QR. Jeśli kiedyś byłeś w restauracji bez skanowania kodu, tych pozycji tu nie będzie 🙂</p>
<div id="historyList"></div>
</section>
<div id="metaFooter" class="meta-footer"></div>
</main>
</div>
<script>
const params = new URLSearchParams(location.search);
const tableParam = (params.get("table") || "").trim();
// UI Elements
const loadingScreen = document.getElementById("loadingScreen");
const loaderMsg = document.getElementById("loaderMsg");
const tableLabel = document.getElementById("tableLabel");
const prepStatus = document.getElementById("prepStatus");
const progressBar = document.getElementById("progressBar");
const statusMeta = document.getElementById("statusMeta");
const itemsList = document.getElementById("itemsList");
const emptyState = document.getElementById("emptyState");
const metaFooter = document.getElementById("metaFooter");
const statusIcon = document.getElementById("statusIcon");
const storageKey = `stolik2_state_${(tableParam || "unknown").toLowerCase()}`;
const historyKey = "stolik2_global_history";
const historySection = document.getElementById("historySection");
const historyList = document.getElementById("historyList");
const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
const HOT_WINDOW_MS = 5 * 60 * 60 * 1000;
// Dynamic Loader Messages
const msgs = ["Rozgrzewamy piece...", "Szef kuchni sprawdza składniki...", "Łączenie z sercem restauracji...", "Prawie gotowe..."];
let msgIdx = 0;
const msgInterval = setInterval(() => {
msgIdx = (msgIdx + 1) % msgs.length;
loaderMsg.textContent = msgs[msgIdx];
}, 4000);
// Initial State
if (tableParam) {
tableLabel.textContent = `Stolik ${tableParam}`;
}
function updateUI(bills) {
// Hide loader on first data
loadingScreen.classList.add("hidden");
clearInterval(msgInterval);
const allArticles = bills.flatMap(b => Array.isArray(b?.Articles) ? b.Articles : []);
const items = mergeWithPersistedItems(allArticles);
if (items.length === 0) {
showEmptyState();
return;
}
renderItems(items);
updateStatus(bills, items);
}
function detectTableCandidates(bill) {
const remark = String(bill?.Remark || "");
const description = String(bill?.Description || "").trim();
// 1) klasyczny format: "STOLIK 9", "STOLIK 11A"
const stolikMatch = remark.match(/STOLIK\s*([0-9A-Z]+)/i);
const fromRemark = stolikMatch ? stolikMatch[1].toLowerCase() : "";
// 2) czasem numer bywa w samym Description
const fromDescription = description.toLowerCase();
return { fromRemark, fromDescription, remark: remark.toLowerCase() };
}
function showEmptyState() {
emptyState.classList.remove("hidden");
itemsList.innerHTML = "";
prepStatus.textContent = "Brak aktywnych zamówień";
statusIcon.textContent = "🍃";
progressBar.style.width = "0%";
statusMeta.textContent = "Zapraszamy do złożenia zamówienia u kelnera.";
}
function normalizeArticleName(rawName) {
const name = String(rawName || "Pozycja");
// Usuwa gramatury typu: "300G", "250 G", "500/200/150G".
const withoutWeight = name.replace(
/\b\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)*\s*[gG]\b/g,
""
);
return withoutWeight
.replace(/\s{2,}/g, " ")
.replace(/\s+([,.;:!?])/g, "$1")
.trim() || "Pozycja";
}
function loadPersistedItems() {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function savePersistedItems(items) {
try {
localStorage.setItem(storageKey, JSON.stringify(items));
} catch {
// brak miejsca/tryb prywatny
}
}
function loadGlobalHistory() {
try {
const raw = localStorage.getItem(historyKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveGlobalHistory(entries) {
const now = Date.now();
const cleaned = entries
.filter((e) => e && e.name && Number.isFinite(e.archivedAt))
.filter((e) => (now - e.archivedAt) <= SIX_MONTHS_MS);
try {
localStorage.setItem(historyKey, JSON.stringify(cleaned));
} catch {
// ignore storage errors
}
}
function addItemsToGlobalHistory(items, sourceTable) {
if (!items.length) return;
const now = Date.now();
const existing = loadGlobalHistory();
// zapobiegamy dokładnym duplikatom (ta sama nazwa + qty + stół blisko czasu)
const dedupWindowMs = 2 * 60 * 1000;
const toAdd = items.filter((item) => {
return !existing.some((h) =>
h.name === item.name &&
Number(h.qty) === Number(item.qty) &&
String(h.sourceTable || "") === String(sourceTable || "") &&
Math.abs((h.archivedAt || 0) - now) <= dedupWindowMs
);
}).map((item) => ({
name: item.name,
qty: item.qty,
sourceTable: sourceTable || "?",
archivedAt: now
}));
if (!toAdd.length) return;
saveGlobalHistory([...existing, ...toAdd]);
}
function renderGlobalHistory() {
const now = Date.now();
const history = loadGlobalHistory()
.filter((e) => (now - (e.archivedAt || 0)) <= SIX_MONTHS_MS)
.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0));
saveGlobalHistory(history);
if (!history.length) {
historySection.classList.add("hidden");
historyList.innerHTML = "";
return;
}
historySection.classList.remove("hidden");
historyList.innerHTML = "";
history.forEach((entry) => {
const dt = new Date(entry.archivedAt || Date.now());
const div = document.createElement("div");
div.className = "item-card archived ready";
div.innerHTML = `
<div class="item-info">
<span class="item-name">${entry.name}</span>
<span class="item-meta">Stolik ${entry.sourceTable || "?"}${dt.toLocaleDateString("pl-PL")} ${dt.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</span>
</div>
<div class="item-qty">x${entry.qty}</div>
`;
historyList.appendChild(div);
});
}
function mergeWithPersistedItems(articles) {
const current = new Map();
articles.forEach(a => {
const name = normalizeArticleName(a.Name);
const todo = parseFloat(String(a.QuantityToDo || a.QuantitySet || "0").replace(",","."));
const done = parseFloat(String(a.QuantityDone || "0").replace(",","."));
if (!current.has(name)) {
current.set(name, { name, qty: 0, done: 0, present: true, completedByDisappear: false });
}
const curr = current.get(name);
curr.qty += Number.isFinite(todo) ? todo : 0;
curr.done += Number.isFinite(done) ? done : 0;
});
const persisted = loadPersistedItems();
const persistedMap = new Map(persisted.map(i => [i.name, i]));
const merged = [];
// Aktualnie obecne pozycje z WS
current.forEach((item) => {
merged.push({
...item,
present: true,
completedByDisappear: false,
updatedAt: Date.now()
});
});
// Pozycje, które były wcześniej, ale zniknęły z WS -> zostają jako gotowe
persistedMap.forEach((oldItem, name) => {
if (current.has(name)) return;
const qty = Number.isFinite(oldItem?.qty) ? oldItem.qty : 0;
const archivedAt = Number.isFinite(oldItem?.archivedAt) ? oldItem.archivedAt : Date.now();
const shouldMoveToGlobal = (Date.now() - archivedAt) > HOT_WINDOW_MS;
if (shouldMoveToGlobal) {
addItemsToGlobalHistory([{ name, qty }], tableParam);
return;
}
merged.push({
name,
qty,
done: qty,
present: false,
completedByDisappear: true,
archivedAt,
updatedAt: Date.now()
});
});
// Aktywne na górze, gotowe (zniknięte) na dole
merged.sort((a, b) => {
if (a.present !== b.present) return a.present ? -1 : 1;
return a.name.localeCompare(b.name, "pl");
});
savePersistedItems(merged);
return merged;
}
function renderItems(items) {
emptyState.classList.add("hidden");
itemsList.innerHTML = "";
items.forEach((item) => {
const isReady = item.done >= item.qty && item.qty > 0;
const div = document.createElement("div");
div.className = `item-card ${isReady ? 'ready' : ''} ${item.present ? '' : 'archived'}`;
let meta = "🔥 W przygotowaniu";
if (isReady && item.completedByDisappear) {
meta = "✅ Gotowe (zrealizowane)";
} else if (isReady) {
meta = "✅ Gotowe";
}
div.innerHTML = `
<div class="item-info">
<span class="item-name">${item.name}</span>
<span class="item-meta">${meta}</span>
</div>
<div class="item-qty">x${item.qty}</div>
`;
itemsList.appendChild(div);
});
renderGlobalHistory();
}
function updateStatus(bills, items) {
let total = 0;
let done = 0;
items.forEach(i => {
total += Number.isFinite(i.qty) ? i.qty : 0;
done += Number.isFinite(i.done) ? i.done : 0;
});
const pct = total > 0 ? (done / total) * 100 : 0;
progressBar.style.width = `${pct}%`;
if (pct >= 100) {
prepStatus.textContent = "Gotowe do podania!";
statusIcon.textContent = "😋";
statusMeta.textContent = "Wszystkie Twoje dania opuściły już kuchnię.";
} else if (pct > 0) {
prepStatus.textContent = "Częściowo gotowe";
statusIcon.textContent = "🍳";
statusMeta.textContent = "Pierwsze pyszności już na Ciebie czekają!";
} else {
prepStatus.textContent = "W przygotowaniu";
statusIcon.textContent = "👨‍🍳";
statusMeta.textContent = "Twoje zamówienie jest właśnie tworzone przez naszych kucharzy.";
}
// Footer meta
const newest = [...bills].sort((a,b) => new Date(b?.Date || 0) - new Date(a?.Date || 0))[0];
const time = newest?.Date
? new Date(newest.Date).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})
: "--:--";
metaFooter.textContent = `Zamówienie złożone o godzinie ${time} • Stolik ${tableParam}`;
}
// WebSocket Logic
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
const parsed = data?.parsed;
if (!parsed || !Array.isArray(parsed.Bills)) return;
if (!tableParam) {
updateUI([]);
return;
}
const myTable = tableParam.toLowerCase();
const matches = parsed.Bills.filter(b => {
const c = detectTableCandidates(b);
return (
c.fromRemark === myTable ||
c.fromDescription === myTable ||
c.remark.includes(`stolik ${myTable}`)
);
});
updateUI(matches);
} catch (e) { console.error("Data error", e); }
};
ws.onclose = () => setTimeout(connect, 3000); // Auto-reconnect
ws.onerror = () => {
loaderMsg.textContent = "Problem z połączeniem. Próbujemy ponownie...";
};
}
connect();
// Fallback: If no data after 25s, show empty state anyway
setTimeout(() => {
if (!loadingScreen.classList.contains("hidden")) {
updateUI([]);
}
}, 25000);
</script>
</body>
</html>