518 lines
15 KiB
HTML
518 lines
15 KiB
HTML
<!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; }
|
||
|
||
.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>
|
||
|
||
<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()}`;
|
||
|
||
// 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 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;
|
||
merged.push({
|
||
name,
|
||
qty,
|
||
done: qty,
|
||
present: false,
|
||
completedByDisappear: true,
|
||
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);
|
||
});
|
||
}
|
||
|
||
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> |