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 `
${e(p.name + ${p.sale ? 'Room to save' : p.isNew ? 'New arrival' : ""}

${e(p.finish)} · ${e(p.size)}

${e(p.name)}

${money(p.price)} ${p.was ? `${money(p.was)}` : ""}${p.coverage ? ' / box' : ""}

Available in demo
`; } const empty = (title, text) => `

A little room for possibility

${title}

${text}

Explore the collection
`; function renderWishlist() { const target = $("#wishlist-content"); if (!target) return; target.innerHTML = state.wishlist.length ? `

${state.wishlist.length} saved products

${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 `
${e(p.name)}

${e(p.name)}

${e(p.size)} · ${e(p.finish)}

${money(p.price)}${p.coverage ? " per box" : ""}

${money(p.price * l.quantity)}
`; }) .join( "", )}

Continue exploring

`; } 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)}
Keep exploring

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.", );