import {
base,
categories,
products,
productById,
money,
escapeHTML as e,
filterProducts,
} from "./stillwater-catalog.mjs";
import {
storageKey,
emptyState,
sanitizeState,
setQuantity,
totals,
tileBoxes,
} from "./stillwater-state.mjs";
const $ = (s) => document.querySelector(s),
$$ = (s) => [...document.querySelectorAll(s)];
const orderKey = "stillwater-order-v1";
let storageAvailable = true;
function readState() {
try {
return sanitizeState(JSON.parse(localStorage.getItem(storageKey)));
} catch {
storageAvailable = false;
return emptyState();
}
}
let state = readState(),
toastTimer;
const productURL = (p) => `${base}product/${p.id}/`;
function announce(message, showBasket = false) {
const toast = $(".toast");
clearTimeout(toastTimer);
toast.replaceChildren(document.createTextNode(message));
if (showBasket) {
const a = document.createElement("a");
a.href = base + "basket/";
a.textContent = "View basket";
toast.append(a);
}
toast.hidden = false;
toastTimer = setTimeout(() => (toast.hidden = true), 6000);
}
function saveState() {
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch {
storageAvailable = false;
announce(
"Browser storage is unavailable. Your selections only last on this page.",
);
}
updateHeader();
}
function updateHeader() {
$$("[data-cart-count]").forEach(
(el) => (el.textContent = totals(state).count),
);
$$("[data-wishlist-count]").forEach(
(el) => (el.textContent = state.wishlist.length),
);
$$("[data-save]").forEach((el) => {
el.disabled = false;
el.setAttribute(
"aria-pressed",
String(state.wishlist.includes(el.dataset.save)),
);
});
$$("[data-add]").forEach((el) => (el.disabled = false));
}
const heart =
' ';
function card(p) {
return `${heart} ${p.sale ? '
Room to save ' : p.isNew ? '
New arrival ' : ""}
${e(p.finish)} · ${e(p.size)}
${money(p.price)} ${p.was ? `${money(p.was)}` : ""}${p.coverage ? ' / box ' : ""}
Available in demo Add to basket
`;
}
const empty = (title, text) =>
`
`;
function renderWishlist() {
const target = $("#wishlist-content");
if (!target) return;
target.innerHTML = state.wishlist.length
? `${state.wishlist.length} saved products
Add all to basket ${state.wishlist.map((id) => card(productById(id))).join("")}
`
: empty(
"Your shortlist starts here.",
"Save a product using its heart button and find it here when you are ready.",
);
updateHeader();
}
function totalsHTML(s = state) {
const t = totals(s);
return `Products ${money(t.subtotal)}
${t.discount ? `WELCOME10 −${money(t.discount)}
` : ""}${s.fulfilment === "collection" ? "Collection" : "Delivery"} ${t.shipping ? money(t.shipping) : "Free"}
Total ${money(t.total)}
`;
}
function renderBasket() {
const target = $("#basket-content");
if (!target) return;
if (!state.cart.length) {
target.innerHTML = empty(
"Something good belongs here.",
"Your basket is empty. Start with a favourite and build the room around it.",
);
return;
}
target.innerHTML = `Demo store. No real items will be purchased. Stock and prices are illustrative.
${state.cart
.map((l) => {
const p = productById(l.id);
return `
${money(p.price * l.quantity)} `;
})
.join(
"",
)}
Continue exploring
Your room, so far. ${totalsHTML()}
How would you like to receive it?Home delivery Showroom collection Continue to demo checkout VAT included. Delivery is free at £500 after discounts, otherwise £39. Collection is free.
`;
}
function refresh() {
updateHeader();
renderWishlist();
renderBasket();
renderCheckoutSummary();
}
document.documentElement.classList.add("js-ready");
$$("[data-enable]").forEach((el) => (el.disabled = false));
updateHeader();
renderWishlist();
renderBasket();
const mobile = $(".mobile-toggle"),
nav = $("#store-nav");
mobile?.addEventListener("click", () => {
const open = mobile.getAttribute("aria-expanded") !== "true";
mobile.setAttribute("aria-expanded", String(open));
nav.classList.toggle("is-open", open);
});
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
$$(".mega[open]").forEach((d) => (d.open = false));
if (nav?.classList.contains("is-open")) {
nav.classList.remove("is-open");
mobile.setAttribute("aria-expanded", "false");
mobile.focus();
}
}
});
document.addEventListener("click", (ev) => {
if (!ev.target.closest(".mega"))
$$(".mega[open]").forEach((d) => (d.open = false));
const add = ev.target.closest("[data-add]");
if (add) {
const p = productById(add.dataset.add);
if (!p) return;
const input = add.dataset.quantityInput
? document.getElementById(add.dataset.quantityInput)
: null;
if (input && !input.reportValidity()) return;
const quantity = input ? Number(input.value) : 1;
const current = state.cart.find((l) => l.id === p.id)?.quantity || 0;
state = setQuantity(state, p.id, current + quantity);
saveState();
announce(
current >= p.stock
? "All available demo stock is already in your basket."
: current + quantity > p.stock
? `Basket limited to ${p.stock} available items.`
: `${p.name} added to your basket.`,
true,
);
refresh();
}
const saved = ev.target.closest("[data-save]");
if (saved) {
const id = saved.dataset.save;
if (!productById(id)) return;
const had = state.wishlist.includes(id);
state.wishlist = had
? state.wishlist.filter((x) => x !== id)
: [...state.wishlist, id];
saveState();
renderWishlist();
announce(
had ? "Removed from your saved pieces." : "Saved to your shortlist.",
);
if ($("#wishlist-content"))
($("#wishlist-content button") || $("#wishlist-content a"))?.focus();
}
const remove = ev.target.closest("[data-remove]");
if (remove) {
state = setQuantity(state, remove.dataset.remove, 0);
saveState();
renderBasket();
announce("Product removed from the basket.");
($("#basket-content input") || $("#basket-content a"))?.focus();
}
if (ev.target.closest("[data-add-saved]")) {
for (const id of state.wishlist)
state = setQuantity(
state,
id,
(state.cart.find((l) => l.id === id)?.quantity || 0) + 1,
);
saveState();
announce(
"Your saved pieces are in the basket, within available stock.",
true,
);
}
if (ev.target.closest("[data-remove-coupon]")) {
state.coupon = "";
saveState();
renderBasket();
$("#coupon")?.focus();
announce("Discount removed.");
}
const zoom = ev.target.closest("[data-zoom]");
if (zoom) {
const dialog = $("#image-dialog");
dialog.querySelector("img").src = zoom.dataset.zoom;
dialog.querySelector("img").alt = zoom.querySelector("img").alt;
dialog.showModal();
}
if (ev.target.closest("[data-close-dialog]")) $("#image-dialog").close();
if (ev.target.closest("[data-print]")) window.print();
if (ev.target.closest("[data-clear-storage]")) {
try {
localStorage.removeItem(storageKey);
sessionStorage.removeItem(orderKey);
} catch {}
state = emptyState();
refresh();
ev.target.closest("section").querySelector(".form-status").textContent =
"Stillwater basket, saved products and order summary have been cleared.";
}
});
document.addEventListener("change", (ev) => {
if (ev.target.matches("[data-cart-quantity]")) {
const input = ev.target;
if (!input.reportValidity()) {
input.value =
state.cart.find((line) => line.id === input.dataset.cartQuantity)
?.quantity || 1;
return;
}
const id = input.dataset.cartQuantity;
state = setQuantity(state, id, input.value);
saveState();
renderBasket();
document.querySelector(`[data-cart-quantity="${id}"]`)?.focus();
announce("Basket quantity updated.");
}
if (ev.target.id === "basket-fulfilment") {
state.fulfilment =
ev.target.value === "collection" ? "collection" : "delivery";
saveState();
renderBasket();
$("#basket-fulfilment")?.focus();
}
});
document.addEventListener("submit", (ev) => {
if (ev.target.id === "coupon-form") {
ev.preventDefault();
const code = new FormData(ev.target).get("coupon").trim().toUpperCase();
if (code !== "WELCOME10") {
ev.target.querySelector("[role=status]").textContent =
"That code is not available. Try WELCOME10 for the demo.";
return;
}
state.coupon = code;
saveState();
renderBasket();
$("#coupon")?.focus();
announce("10% demonstration discount applied.");
}
});
// Catalog search/filter state is shareable and restores through browser Back/Forward.
const catalog = $("[data-catalog]"),
filters = $("#product-filters"),
sort = $("#product-sort");
if (catalog) {
const initialCategory = catalog.dataset.category;
function loadFilters() {
const params = new URLSearchParams(location.search);
filters.elements.query.value = params.get("q") || "";
filters.elements.category.value = params.has("category")
? params.get("category")
: initialCategory;
filters.elements.finish.value = params.get("finish") || "";
filters.elements.max.value = params.get("max") || "";
sort.value = params.get("sort") || "featured";
if (!sort.value) sort.value = "featured";
$("#site-search").value = params.get("q") || "";
}
function showResults(updateURL = false) {
const values = Object.fromEntries(new FormData(filters));
const list = filterProducts({
query: values.query,
category: values.category,
finish: values.finish,
max: values.max ? Number(values.max) : Infinity,
sort: sort.value,
sale: catalog.dataset.mode === "sale",
newOnly: catalog.dataset.mode === "new",
});
$("#result-count").textContent =
`${list.length} product${list.length === 1 ? "" : "s"}`;
$("#catalog-results").innerHTML = list.length
? `${list.map(card).join("")}
`
: empty(
"No pieces found just yet.",
"Try another search or clear a filter to explore more of the collection.",
);
if (updateURL) {
const params = new URLSearchParams();
if (values.query.trim()) params.set("q", values.query.trim());
if (values.category !== initialCategory)
params.set("category", values.category);
if (values.finish) params.set("finish", values.finish);
if (values.max) params.set("max", values.max);
if (sort.value !== "featured") params.set("sort", sort.value);
history.replaceState(
null,
"",
location.pathname + (params.size ? "?" + params : ""),
);
}
}
loadFilters();
showResults();
filters.addEventListener("input", () => showResults(true));
filters.addEventListener("submit", (ev) => {
ev.preventDefault();
showResults(true);
});
sort.addEventListener("change", () => showResults(true));
filters.addEventListener("reset", () =>
setTimeout(() => {
sort.value = "featured";
filters.elements.category.value = initialCategory;
showResults(true);
}, 0),
);
window.addEventListener("popstate", () => {
loadFilters();
showResults();
});
}
$$("[data-tile-calculator]").forEach((form) =>
form.addEventListener("submit", (ev) => {
ev.preventDefault();
if (!form.reportValidity()) return;
const data = new FormData(form);
const boxes = tileBoxes(
Number(data.get("area")),
Number(data.get("waste")),
);
const p = productById($("[data-quantity-input]")?.dataset.add);
if (!p || !boxes) return;
form.querySelector("[role=status]").textContent =
`${boxes} boxes cover ${(boxes * 1.44).toFixed(2)} m². Product total: ${money(boxes * p.price)}.${boxes > p.stock ? ` Only ${p.stock} boxes are available in this demo; quantity has not been changed.` : " Quantity updated above."}`;
if (boxes <= p.stock) $("#product-quantity").value = boxes;
}),
);
function download(text, filename) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const objectURL = URL.createObjectURL(blob),
a = document.createElement("a");
a.href = objectURL;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectURL), 1000);
}
const today = new Date();
today.setDate(today.getDate() + 1);
const tomorrow = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
$$("[data-future-date]").forEach((el) => (el.min = tomorrow));
const query = new URLSearchParams(location.search);
for (const [key, name] of [
["type", "Appointment"],
["showroom", "Showroom"],
]) {
const el = document.querySelector(`[name="${name}"]`);
if (el && [...el.options].some((o) => o.value === query.get(key)))
el.value = query.get(key);
}
$$("[data-demo-form]").forEach((form) =>
form.addEventListener("submit", (ev) => {
ev.preventDefault();
if (!form.reportValidity()) return;
const output = form.querySelector(".form-status"),
title = form.dataset.demoForm;
if (title === "Newsletter preference") {
output.textContent =
"Sign-up preview complete. No email address has been saved and no subscription has been created.";
form.reset();
return;
}
const entries = [...new FormData(form)].filter(([, v]) => String(v).trim());
const saved = state.wishlist.map((id) => {
const p = productById(id);
return `${p.name} · ${p.size} · ${money(p.price)}`;
});
const brief = `STILLWATER — ${title.toUpperCase()}\nLocal demonstration only. Not submitted or booked.\n\n${entries.map(([k, v]) => `${k}: ${v}`).join("\n")}${title.includes("Design") && saved.length ? "\n\nSaved products:\n" + saved.join("\n") : ""}`;
output.replaceChildren();
const h = document.createElement("h3");
h.textContent = "Your brief is ready.";
const p = document.createElement("p");
p.textContent =
"Review it below. Nothing has been sent or booked. Edit the fields above and submit again to update it.";
const pre = document.createElement("pre");
pre.textContent = brief;
const b = document.createElement("button");
b.type = "button";
b.className = "btn";
b.textContent = "Download brief";
b.addEventListener("click", () => download(brief, "stillwater-brief.txt"));
output.append(h, p, pre, b);
output.tabIndex = -1;
output.focus();
}),
);
$("#showroom-search")?.addEventListener("submit", (ev) => {
ev.preventDefault();
filterShowrooms(new FormData(ev.target).get("location"));
});
$("#showroom-search")?.addEventListener("reset", () => filterShowrooms(""));
function filterShowrooms(raw) {
const q = String(raw).trim().toLowerCase();
const prefix = q.match(/^[a-z]+/)?.[0] || "";
let count = 0;
$$(".showroom-card").forEach((card) => {
const show =
!q || card.dataset.city.includes(q) || prefix === card.dataset.code;
card.hidden = !show;
if (show) count++;
});
$("#showroom-status").textContent = count
? `${count} concept showroom${count === 1 ? "" : "s"} found`
: "No concept showroom matches that search. Try Manchester, Bristol or London.";
}
$("#budget-calculator")?.addEventListener("submit", (ev) => {
ev.preventDefault();
if (!ev.target.reportValidity()) return;
const budget = Math.round(
Number(new FormData(ev.target).get("budget")) * 100,
);
$("#budget-result").innerHTML = `${[
["Products", 0.4],
["Labour", 0.4],
["Preparation", 0.1],
["Contingency", 0.1],
]
.map(
([label, share]) =>
`
${label} ${money(Math.round(budget * share))}
`,
)
.join("")}
`;
});
// Contact and address data never enter browser storage or the confirmation receipt.
const checkout = $("#checkout-form");
function renderCheckoutSummary() {
const target = $("#checkout-summary");
if (!target) return;
target.innerHTML = `Order summary ${state.cart
.map((l) => {
const p = productById(l.id);
return `${e(p.name)}${e(p.size)} · Qty ${l.quantity} ${money(p.price * l.quantity)}
`;
})
.join(
"",
)}${totalsHTML()}Illustrative VAT included. No payment will be taken.
Edit basket `;
}
function changeFulfilment() {
if (!checkout) return;
state.fulfilment =
checkout.elements.Fulfilment.value === "Collection"
? "collection"
: "delivery";
const collection = state.fulfilment === "collection";
$("#delivery-fields").hidden = collection;
$("#collection-choice").hidden = !collection;
$("#delivery-fields")
.querySelectorAll("input")
.forEach((el) => {
el.disabled = collection;
if (!el.readOnly) el.required = !collection;
});
saveState();
renderCheckoutSummary();
}
function goStep(step) {
if (!checkout) return;
$$("[data-step]").forEach((el) => {
el.hidden = Number(el.dataset.step) !== step;
el.querySelectorAll("input,select").forEach(
(input) => (input.disabled = Number(el.dataset.step) !== step),
);
});
if (step === 1) changeFulfilment();
const heading = checkout.querySelector(`[data-step="${step}"] h2`);
heading.tabIndex = -1;
heading.focus();
}
if (checkout) {
if (!state.cart.length) {
checkout.innerHTML = empty(
"Your basket is waiting.",
"Add a product before exploring the checkout.",
);
renderCheckoutSummary();
} else {
checkout.elements.Fulfilment.value =
state.fulfilment === "collection" ? "Collection" : "Delivery";
checkout.elements.Acknowledgement.disabled = true;
changeFulfilment();
checkout.elements.Fulfilment.addEventListener("change", changeFulfilment);
$("[data-next-step]").addEventListener("click", () => {
if (!checkout.reportValidity()) return;
const data = Object.fromEntries(new FormData(checkout));
const fulfilment =
state.fulfilment === "collection"
? data.Showroom
: `${data.Address}\n${data.City}\n${data.Postcode.toUpperCase()}`;
$("#checkout-review").innerHTML =
`${e(data.Name)} \n${e(data.Email)}\n\n${e(data.Fulfilment)}\n${e(fulfilment)}
${totalsHTML()}`;
goStep(2);
});
$("[data-prev-step]").addEventListener("click", () => goStep(1));
checkout.addEventListener("submit", (ev) => {
ev.preventDefault();
if (
!state.cart.length ||
!checkout.reportValidity() ||
$('[data-step="2"]').hidden
)
return;
const receipt = {
id: `SW-${Date.now().toString(36).toUpperCase()}`,
date: new Date().toISOString(),
state: sanitizeState(state),
};
try {
sessionStorage.setItem(orderKey, JSON.stringify(receipt));
} catch {
checkout.querySelector(".form-status").textContent =
"Session storage is unavailable. Enable browser storage to save a demo order; your basket has been kept.";
return;
}
state.cart = [];
state.coupon = "";
saveState();
location.assign(base + "confirmation/");
});
}
}
const confirmation = $("#confirmation-content");
if (confirmation) {
try {
const receipt = JSON.parse(sessionStorage.getItem(orderKey));
if (
receipt &&
/^SW-[A-Z0-9]+$/.test(receipt.id) &&
Number.isFinite(Date.parse(receipt.date))
) {
const order = sanitizeState(receipt.state);
if (order.cart.length) {
const summary = `STILLWATER DEMO ORDER ${receipt.id}\nNo payment, shipment or reservation.\n${new Date(receipt.date).toLocaleDateString("en-GB")}\n\n${order.cart
.map((l) => {
const p = productById(l.id);
return `${p.name}, ${p.size} × ${l.quantity}: ${money(p.price * l.quantity)}`;
})
.join(
"\n",
)}\n\nTotal: ${money(totals(order).total)}\nFulfilment: ${order.fulfilment}\n`;
confirmation.innerHTML = `✓
Step 3 of 3 / Demo complete
Your room is taking shape. You’ve completed the sample shopping journey. No order was sent, no payment was taken and no stock was reserved.
Reference: ${receipt.id} · ${new Date(receipt.date).toLocaleDateString("en-GB")}
${order.cart
.map((l) => {
const p = productById(l.id);
return `
${e(p.name)} · ${e(p.size)} × ${l.quantity} ${money(p.price * l.quantity)}
`;
})
.join(
"",
)}${totalsHTML(order)}
Contact and address details have not been saved.
`;
$("#download-order").addEventListener("click", () =>
download(summary, "stillwater-demo-order.txt"),
);
}
}
} catch {
/* Preserve the useful empty state if a receipt is invalid. */
}
}
window.addEventListener("storage", (ev) => {
if (ev.key === storageKey || ev.key === null) {
state = readState();
refresh();
if (checkout?.elements?.Fulfilment && state.cart.length) {
checkout.elements.Fulfilment.value =
state.fulfilment === "collection" ? "Collection" : "Delivery";
goStep(1);
announce("Basket updated in another tab. Please review it again.");
} else if (checkout?.elements?.Fulfilment) {
checkout.innerHTML = empty(
"Your basket is now empty.",
"Your basket changed in another tab. Add products to continue.",
);
}
}
});
if (!storageAvailable)
announce(
"Browser storage is unavailable; shopping selections may not persist between pages.",
);