Download modules/SmOS_CC/reservation.js from 0001AMA/SMOS: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/modules/SmOS_CC/reservation.js
- Command line
-
hf download hf://spaces/0001AMA/SMOS/modules/SmOS_CC/reservation.js
-
curl -L -o reservation.js https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/modules/SmOS_CC/reservation.js
19.2 kB
| (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)); | |
| // 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) => `<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) { | |
| /* fall back to sessionStorage */ | |
| } | |
| } | |
| 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) { | |
| /* 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", | |
| `<p class="res-warn">Saved locally — start the SMOS server to sync with admin.</p>` | |
| ); | |
| } | |
| } | |
| }); | |
| initDateTime(); | |
| syncContactRequired(); | |
| setParty(2); | |
| setMode("map"); | |
| refreshOccupied(); | |
| })(); | |