| (function () { |
| const RES_KEY = "hah-restaurant-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)); |
| |
| [...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) => `<option value="${s.value}">${s.label}</option>`) |
| .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 = ` |
| <span><i class="res-swatch res-swatch--free"></i> Available</span> |
| <span><i class="res-swatch res-swatch--pick"></i> Selected</span> |
| <span><i class="res-swatch res-swatch--taken"></i> Reserved</span> |
| <span><i class="res-swatch res-swatch--small"></i> Too small alone</span> |
| `; |
| 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 = ` |
| <span class="res-zone res-zone--window">Window</span> |
| <span class="res-zone res-zone--counter">Counter →</span> |
| `; |
| 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 = ` |
| <span class="res-table__id">${table.label}</span> |
| <span class="res-table__seats">${table.seats}</span> |
| `; |
| 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 = `<p class="res-empty">No tables free for ${partySize} — try another time or smaller party.</p>`; |
| 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 = ` |
| <span class="res-suggest-card__title">Table${opt.ids.length > 1 ? "s" : ""} ${opt.label}</span> |
| <span class="res-suggest-card__meta">${kindLabel} · ${opt.seats} seats · ${opt.waste ? `+${opt.waste} spare` : "Exact fit"}</span> |
| `; |
| 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 = ` |
| <p><strong>Selected:</strong> Table${ids.length > 1 ? "s" : ""} ${ids.map((id) => tableById[id].label).join(", ")} · ${seats} seats</p> |
| ${ |
| !adjacent && ids.length > 1 |
| ? `<p class="res-warn">These tables are not all adjacent — staff may need to rearrange.</p>` |
| : "" |
| } |
| ${ |
| seats < partySize |
| ? `<p class="res-warn">Need ${partySize - seats} more seat${partySize - seats === 1 ? "" : "s"} — add another table or pick a suggestion.</p>` |
| : `<p class="res-ok">${ids.length > 1 ? "Merged booking" : "Single table"} · ready to confirm</p>` |
| } |
| `; |
| }; |
|
|
| 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 = ` |
| <p>Reference <strong>${reservation.ref}</strong></p> |
| <p>${reservation.merged ? "Merged seating" : "Single table"} · ${reservation.totalSeats} seats total.</p> |
| <p class="pay-hint">No payment now — settle when you visit (or after you order sit-in).</p> |
| <a class="checkout-submit checkout-submit--link" href="order.html?service=sitting-in&mode=menu">Continue to order →</a> |
| `; |
| } |
| } |
| }; |
|
|
| 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) { |
| |
| } |
| } |
|
|
| try { |
| sessionStorage.setItem(RES_KEY, JSON.stringify(reservation)); |
| const history = JSON.parse(sessionStorage.getItem("hah-restaurant-reservations") || "[]"); |
| history.unshift(reservation); |
| sessionStorage.setItem("hah-restaurant-reservations", JSON.stringify(history.slice(0, 20))); |
| } catch (e) { |
| |
| } |
|
|
| 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", |
| `<p class="res-warn">Saved locally — start the SMOS server to sync with admin.</p>` |
| ); |
| } |
| } |
| }); |
|
|
| initDateTime(); |
| syncContactRequired(); |
| setParty(2); |
| setMode("map"); |
| refreshOccupied(); |
| })(); |
|
|