(function () { const RES_KEY = "corner-cafe-reservation"; const cfg = window.SmOS_CC_Reservations; if (!cfg) return; const root = document.querySelector("[data-reservation]"); if (!root) return; const tableById = Object.fromEntries(cfg.tables.map((t) => [t.id, t])); const maxParty = 12; const els = { party: root.querySelector("[data-res-party]"), partyDown: root.querySelector("[data-res-party-down]"), partyUp: root.querySelector("[data-res-party-up]"), date: root.querySelector("[data-res-date]"), arrival: root.querySelector("[data-res-arrival]"), map: root.querySelector("[data-res-map]"), suggestions: root.querySelector("[data-res-suggestions]"), selection: root.querySelector("[data-res-selection]"), form: root.querySelector("[data-res-form]"), formError: root.querySelector("[data-res-form-error]"), email: root.querySelector("[data-res-email]"), phone: root.querySelector("[data-res-phone]"), done: root.querySelector("[data-res-done]"), modeBtns: root.querySelectorAll("[data-res-mode]"), panelMap: root.querySelector("[data-res-panel-map]"), panelSuggest: root.querySelector("[data-res-panel-suggest]"), suggestBtn: root.querySelector("[data-res-suggest-btn]"), clearBtn: root.querySelector("[data-res-clear]"), }; let partySize = 2; let selectedIds = new Set(); let mode = "map"; let reserved = new Set(); const refreshOccupied = async () => { const date = els.date?.value || todayIso(); let live = []; if (window.SmOS_API?.occupiedTables) { try { live = await window.SmOS_API.occupiedTables(date); } catch (e) { live = cfg.demoReserved || []; } } else { live = cfg.demoReserved || []; } reserved = new Set((live || []).map(String)); // Drop selection if a picked table became occupied. [...selectedIds].forEach((id) => { if (reserved.has(id)) selectedIds.delete(id); }); renderMap(); renderSelection(); if (mode === "suggest") renderSuggestions(); }; const todayIso = () => new Date().toISOString().slice(0, 10); const minutesFromTime = (hhmm) => { const [h, m] = hhmm.split(":").map(Number); return h * 60 + m; }; const formatTime24 = (mins) => { const h = Math.floor(mins / 60); const m = mins % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; }; const formatTime12 = (hhmm) => { const [h, m] = hhmm.split(":").map(Number); const ap = h >= 12 ? "pm" : "am"; const h12 = h % 12 || 12; const minStr = m ? `:${String(m).padStart(2, "0")}` : ""; return `${h12}${minStr}${ap}`; }; const arrivalSlots = () => { const step = cfg.slotMinutes || 15; const startM = minutesFromTime(cfg.openHours.start); const lastStart = minutesFromTime(cfg.openHours.lastArrival || cfg.openHours.lastBooking || "17:45"); const out = []; for (let m = startM; m <= lastStart; m += step) { const start = formatTime24(m); const end = formatTime24(m + step); out.push({ value: `${start}-${end}`, label: `${formatTime12(start)}–${formatTime12(end)}`, }); } return out; }; const initDateTime = () => { if (els.date) { els.date.min = todayIso(); if (!els.date.value) els.date.value = todayIso(); } if (els.arrival && !els.arrival.options.length) { const slots = arrivalSlots(); els.arrival.innerHTML = slots .map((s) => ``) .join(""); } }; const selectedArrivalLabel = () => { if (!els.arrival) return ""; const opt = els.arrival.options[els.arrival.selectedIndex]; return opt ? opt.textContent : ""; }; const isReserved = (id) => reserved.has(id); const totalSeats = (ids) => ids.reduce((n, id) => n + (tableById[id]?.seats || 0), 0); const areAdjacent = (ids) => { if (ids.length <= 1) return true; const set = new Set(ids); const start = ids[0]; const seen = new Set([start]); const queue = [start]; while (queue.length) { const cur = queue.shift(); (cfg.adjacency[cur] || []).forEach((nb) => { if (!set.has(nb) || seen.has(nb)) return; seen.add(nb); queue.push(nb); }); } return seen.size === ids.length; }; const adjacentGroupsFrom = (startId, need, available) => { const results = []; const walk = (cur, group, seats) => { if (seats >= need) { results.push([...group]); return; } (cfg.adjacency[cur] || []).forEach((nb) => { if (!available.has(nb) || group.includes(nb)) return; group.push(nb); walk(nb, group, seats + tableById[nb].seats); group.pop(); }); }; walk(startId, [startId], tableById[startId].seats); return results; }; const suggestOptions = (need) => { const available = new Set(cfg.tables.map((t) => t.id).filter((id) => !isReserved(id))); const options = []; const seen = new Set(); const add = (ids, kind) => { const key = [...ids].sort().join("+"); if (seen.has(key)) return; seen.add(key); const seats = totalSeats(ids); if (seats < need) return; options.push({ ids: [...ids], seats, kind, waste: seats - need, label: ids.map((id) => tableById[id].label).join(" + "), }); }; available.forEach((id) => { if (tableById[id].seats >= need) add([id], "single"); }); available.forEach((id) => { adjacentGroupsFrom(id, need, available).forEach((group) => { if (group.length > 1 && areAdjacent(group)) add(group, "merge"); }); }); options.sort((a, b) => { if (a.kind !== b.kind && a.kind === "single") return -1; if (a.kind !== b.kind && b.kind === "single") return 1; if (a.waste !== b.waste) return a.waste - b.waste; if (a.ids.length !== b.ids.length) return a.ids.length - b.ids.length; return a.label.localeCompare(b.label); }); return options.slice(0, 8); }; const renderMap = () => { if (!els.map) return; const maxRow = Math.max(...cfg.tables.map((t) => t.row)); const maxCol = Math.max(...cfg.tables.map((t) => t.col)); els.map.innerHTML = ""; els.map.style.setProperty("--res-cols", String(maxCol + 1)); els.map.style.setProperty("--res-rows", String(maxRow + 1)); const legend = document.createElement("div"); legend.className = "res-legend"; legend.innerHTML = ` Available Selected Reserved Too small alone `; els.map.appendChild(legend); const floor = document.createElement("div"); floor.className = "res-floor"; floor.setAttribute("role", "group"); floor.setAttribute("aria-label", "Cafe floor plan — choose tables"); const zones = document.createElement("div"); zones.className = "res-floor__zones"; zones.innerHTML = ` Window Counter → `; floor.appendChild(zones); const grid = document.createElement("div"); grid.className = "res-grid"; for (let r = 0; r <= maxRow; r += 1) { for (let c = 0; c <= maxCol; c += 1) { const table = cfg.tables.find((t) => t.row === r && t.col === c); const cell = document.createElement("div"); cell.className = "res-cell"; if (!table) { cell.classList.add("res-cell--empty"); grid.appendChild(cell); continue; } const taken = isReserved(table.id); const picked = selectedIds.has(table.id); const tooSmall = !taken && table.seats < partySize && !picked; const btn = document.createElement("button"); btn.type = "button"; btn.className = "res-table"; btn.classList.add(`res-table--${table.shape}`); if (taken) btn.classList.add("is-taken"); if (picked) btn.classList.add("is-selected"); if (tooSmall) btn.classList.add("is-small"); btn.disabled = taken; btn.dataset.tableId = table.id; btn.setAttribute("aria-label", `Table ${table.label}, ${table.seats} seats${taken ? ", reserved" : ""}`); btn.innerHTML = ` ${table.label} ${table.seats} `; btn.addEventListener("click", () => toggleTable(table.id)); cell.appendChild(btn); grid.appendChild(cell); } } floor.appendChild(grid); els.map.appendChild(floor); renderSelection(); }; const toggleTable = (id) => { if (isReserved(id)) return; if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id); renderMap(); renderSuggestionsHighlight(); }; const applySuggestion = (ids) => { selectedIds = new Set(ids); renderMap(); renderSuggestionsHighlight(); }; const renderSuggestionsHighlight = () => { els.suggestions?.querySelectorAll("[data-suggestion]").forEach((el) => { const ids = (el.dataset.ids || "").split(","); const match = ids.length === selectedIds.size && ids.every((id) => selectedIds.has(id)); el.classList.toggle("is-active", match); }); }; const renderSuggestions = () => { if (!els.suggestions) return; const options = suggestOptions(partySize); els.suggestions.innerHTML = ""; if (!options.length) { els.suggestions.innerHTML = `

No tables free for ${partySize} — try another time or smaller party.

`; return; } const intro = document.createElement("p"); intro.className = "res-suggest-intro"; intro.textContent = `Suggestions for ${partySize} guest${partySize === 1 ? "" : "s"}:`; els.suggestions.appendChild(intro); const list = document.createElement("ul"); list.className = "res-suggest-list"; options.forEach((opt) => { const li = document.createElement("li"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "res-suggest-card"; btn.dataset.suggestion = "1"; btn.dataset.ids = opt.ids.join(","); const kindLabel = opt.kind === "merge" ? "Merged tables" : "Single table"; btn.innerHTML = ` Table${opt.ids.length > 1 ? "s" : ""} ${opt.label} ${kindLabel} · ${opt.seats} seats · ${opt.waste ? `+${opt.waste} spare` : "Exact fit"} `; btn.addEventListener("click", () => applySuggestion(opt.ids)); li.appendChild(btn); list.appendChild(li); }); els.suggestions.appendChild(list); }; const renderSelection = () => { if (!els.selection) return; const ids = [...selectedIds]; if (!ids.length) { els.selection.hidden = true; els.selection.innerHTML = ""; return; } const seats = totalSeats(ids); const ok = seats >= partySize; const adjacent = areAdjacent(ids); els.selection.hidden = false; els.selection.className = "res-selection" + (ok && adjacent ? " is-ok" : " is-warn"); els.selection.innerHTML = `

Selected: Table${ids.length > 1 ? "s" : ""} ${ids.map((id) => tableById[id].label).join(", ")} · ${seats} seats

${ !adjacent && ids.length > 1 ? `

These tables are not all adjacent — staff may need to rearrange.

` : "" } ${ seats < partySize ? `

Need ${partySize - seats} more seat${partySize - seats === 1 ? "" : "s"} — add another table or pick a suggestion.

` : `

${ids.length > 1 ? "Merged booking" : "Single table"} · ready to confirm

` } `; }; const setParty = (n) => { partySize = Math.max(1, Math.min(maxParty, n)); if (els.party) els.party.textContent = String(partySize); renderMap(); renderSuggestions(); }; const setMode = (next) => { mode = next; els.modeBtns?.forEach((btn) => { const active = btn.dataset.resMode === next; btn.classList.toggle("is-active", active); btn.setAttribute("aria-selected", active ? "true" : "false"); }); if (els.panelMap) els.panelMap.hidden = next !== "map"; if (els.panelSuggest) els.panelSuggest.hidden = next !== "suggest"; }; const showFormError = (msg) => { if (!els.formError) return; if (!msg) { els.formError.hidden = true; els.formError.textContent = ""; return; } els.formError.hidden = false; els.formError.textContent = msg; }; const syncContactRequired = () => { const via = els.form?.querySelector('input[name="confirm_via"]:checked')?.value || "none"; if (els.email) { const on = via === "email"; els.email.required = on; els.email.disabled = !on; els.email.hidden = !on; if (!on) els.email.value = ""; } if (els.phone) { const on = via === "phone"; els.phone.required = on; els.phone.disabled = !on; els.phone.hidden = !on; if (!on) els.phone.value = ""; } }; els.form?.querySelectorAll("[data-res-confirm-via]").forEach((input) => { input.addEventListener("change", syncContactRequired); }); els.partyDown?.addEventListener("click", () => setParty(partySize - 1)); els.partyUp?.addEventListener("click", () => setParty(partySize + 1)); els.suggestBtn?.addEventListener("click", () => { renderSuggestions(); const first = suggestOptions(partySize)[0]; if (first) applySuggestion(first.ids); }); els.clearBtn?.addEventListener("click", () => { selectedIds.clear(); renderMap(); renderSuggestionsHighlight(); }); els.modeBtns?.forEach((btn) => { btn.addEventListener("click", () => setMode(btn.dataset.resMode || "map")); }); els.date?.addEventListener("change", () => { selectedIds.clear(); refreshOccupied(); }); const showDone = (reservation, ids) => { root.querySelectorAll("[data-res-hide-on-done]").forEach((el) => { el.hidden = true; }); if (els.done) { els.done.hidden = false; const lead = els.done.querySelector("[data-res-done-lead]"); const detail = els.done.querySelector("[data-res-done-detail]"); const arrivalLabel = reservation.arrivalLabel || reservation.arrivalWindow; const confirmNote = reservation.confirmVia === "email" ? `Confirmation will be sent to ${reservation.email}.` : reservation.confirmVia === "phone" ? `Confirmation will be sent to ${reservation.phone}.` : "No confirmation requested."; if (lead) { lead.textContent = `Thanks ${reservation.name}. Table${ids.length > 1 ? "s" : ""} ${reservation.tables.map((t) => t.label).join(", ")} · ${reservation.date} · expected ${arrivalLabel} · ${partySize} guests. ${confirmNote}`; } if (detail) { detail.innerHTML = `

Reference ${reservation.ref}

${reservation.merged ? "Merged seating" : "Single table"} · ${reservation.totalSeats} seats total.

No payment now — settle when you visit (or after you order sit-in).

Continue to order → `; } } }; els.form?.addEventListener("submit", async (event) => { event.preventDefault(); showFormError(""); const ids = [...selectedIds]; const seats = totalSeats(ids); if (!ids.length || seats < partySize) { showFormError("Choose enough tables for your party before confirming."); return; } syncContactRequired(); const data = new FormData(els.form); const confirmVia = String(data.get("confirm_via") || "none"); const email = String(data.get("email") || "").trim(); const phone = String(data.get("phone") || "").trim(); if (confirmVia === "email" && !email) { showFormError("Enter an email address to receive your confirmation."); return; } if (confirmVia === "phone" && !phone) { showFormError("Enter a phone number to receive your confirmation."); return; } const arrivalWindow = els.arrival?.value || arrivalSlots()[0]?.value || "09:00-09:15"; const reservation = { ref: `RES-${Date.now().toString(36).toUpperCase().slice(-5)}${Math.floor(Math.random() * 90 + 10)}`, partySize, tables: ids.map((id) => ({ id, label: tableById[id].label, seats: tableById[id].seats })), totalSeats: seats, merged: ids.length > 1, date: els.date?.value || todayIso(), arrivalWindow, arrivalLabel: selectedArrivalLabel(), name: String(data.get("name") || "").trim(), email, phone, confirmVia, notes: String(data.get("notes") || "").trim(), placedAt: new Date().toISOString(), }; const submitBtn = els.form.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; const apiPayload = { kind: "reservation", service: "reservation", ref: reservation.ref, customer_name: reservation.name, email: reservation.email || null, phone: reservation.phone || null, confirm_via: reservation.confirmVia === "none" ? null : reservation.confirmVia, party_size: reservation.partySize, arrival_date: reservation.date, arrival_window: reservation.arrivalWindow, tables: reservation.tables, notes: reservation.notes || null, payment_status: "n/a", payload: reservation, }; let savedToApi = false; if (window.SmOS_API?.postBooking) { try { await window.SmOS_API.postBooking(apiPayload); savedToApi = true; } catch (e) { /* fall back to sessionStorage */ } } try { sessionStorage.setItem(RES_KEY, JSON.stringify(reservation)); const history = JSON.parse(sessionStorage.getItem("corner-cafe-reservations") || "[]"); history.unshift(reservation); sessionStorage.setItem("corner-cafe-reservations", JSON.stringify(history.slice(0, 20))); } catch (e) { /* ignore */ } ids.forEach((id) => reserved.add(id)); showDone(reservation, ids); if (submitBtn) submitBtn.disabled = false; if (!savedToApi && window.SmOS_API) { const detail = els.done?.querySelector("[data-res-done-detail]"); if (detail) { detail.insertAdjacentHTML( "afterbegin", `

Saved locally — start the SMOS server to sync with admin.

` ); } } }); initDateTime(); syncContactRequired(); setParty(2); setMode("map"); refreshOccupied(); })();