SMOS / modules /SmOS_CC /reservation.js
0001AMA's picture
Rebrand SmOS_CC customer surfaces to HAH restaurant.
2c541ca verified
Raw
History Blame Contribute Delete
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();
})();