Files
ben.de-roo.org/domme-converters/assets/js/app.js
T

310 lines
10 KiB
JavaScript

"use strict";
// Data komt uit products.js (window.PRODUCTS / window.CATEGORIES).
const PRODUCTS = window.PRODUCTS || [];
const CATEGORIES = window.CATEGORIES || [{ key: "all", label: "Alles" }];
const TAX_RATE = 0.21;
// Winkelmandje: Map van product-id -> aantal.
const cart = new Map();
let activeFilter = "all";
const els = {
grid: document.getElementById("grid"),
filters: document.getElementById("filters"),
productCount: document.getElementById("productCount"),
overlay: document.getElementById("overlay"),
drawer: document.getElementById("drawer"),
cartItems: document.getElementById("cartItems"),
cartCount: document.getElementById("cartCount"),
cartSub: document.getElementById("cartSub"),
cartTax: document.getElementById("cartTax"),
cartTotal: document.getElementById("cartTotal"),
checkoutBtn: document.getElementById("checkoutBtn"),
modalWrap: document.getElementById("modalWrap"),
modalContent: document.getElementById("modalContent"),
toast: document.getElementById("toast"),
openCart: document.getElementById("openCart"),
closeCart: document.getElementById("closeCart"),
};
// Hulpfuncties voor opmaak.
const formatPrice = (value) => "\u20AC" + value.toFixed(2).replace(".", ",");
const escapeHtml = (str) =>
String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const stars = (rating) => "\u2605".repeat(rating) + "\u2606".repeat(5 - rating);
const findProduct = (id) => PRODUCTS.find((p) => p.id === id);
// Tegelinhoud: toon de foto als die er is, val anders terug op de connector-tegel.
// onerror verwijdert een gebroken afbeelding en toont alsnog de connectors.
function thumbInner(p) {
const fallback = `
<span class="conn">${escapeHtml(p.from)}</span>
<span class="arrow">&rarr;</span>
<span class="conn">${escapeHtml(p.to)}</span>
`;
if (p.image) {
return `<img src="${escapeHtml(p.image)}" alt="${escapeHtml(p.name)}"
onerror="this.remove(); this.parentNode.classList.add('thumb--fallback'); this.parentNode.insertAdjacentHTML('beforeend', this.parentNode.dataset.fallback);"
/>`;
}
return fallback;
}
// Rendering.
function renderFilters() {
els.filters.innerHTML = CATEGORIES.map((c) =>
`<button class="filter${c.key === activeFilter ? " active" : ""}" data-filter="${c.key}">${escapeHtml(c.label)}</button>`
).join("");
}
function visibleProducts() {
return activeFilter === "all" ? PRODUCTS : PRODUCTS.filter((p) => p.category === activeFilter);
}
function renderCatalog() {
const list = visibleProducts();
els.productCount.textContent = list.length + " producten" + (activeFilter === "all" ? "" : " in deze categorie");
els.grid.innerHTML = list.map((p) => {
const fallback = `<span class="conn">${escapeHtml(p.from)}</span><span class="arrow">&rarr;</span><span class="conn">${escapeHtml(p.to)}</span>`;
const thumb = p.image
? `<img src="${escapeHtml(p.image)}" alt="${escapeHtml(p.name)}" onerror="this.remove(); this.parentNode.innerHTML = this.parentNode.dataset.fallback;" />`
: fallback;
return `
<article class="card">
<div class="thumb" data-fallback="${escapeHtml(fallback)}">
${thumb}
</div>
<div class="body">
<div class="meta">
<span class="sku">${escapeHtml(p.id)}</span>
<span class="stars">${stars(p.rating)}<span class="reviews">(${p.reviews})</span></span>
</div>
<h3>${escapeHtml(p.name)}</h3>
<p class="desc">${escapeHtml(p.desc)}</p>
<div class="foot">
<span class="price">${formatPrice(p.price)}<small>${formatPrice(p.old)}</small></span>
<button class="add-btn" data-add="${p.id}">Toevoegen</button>
</div>
</div>
</article>
`;
}).join("");
}
function cartTotals() {
let count = 0;
let subtotal = 0;
for (const [id, qty] of cart) {
const product = findProduct(id);
if (!product) continue;
count += qty;
subtotal += product.price * qty;
}
const tax = subtotal * TAX_RATE;
return { count, subtotal, tax, total: subtotal + tax };
}
function renderCart() {
const { count, subtotal, tax, total } = cartTotals();
els.cartCount.textContent = String(count);
els.cartSub.innerHTML = formatPrice(subtotal);
els.cartTax.innerHTML = formatPrice(tax);
els.cartTotal.innerHTML = formatPrice(total);
els.checkoutBtn.disabled = count === 0;
if (cart.size === 0) {
els.cartItems.innerHTML = '<div class="cart-empty">Uw winkelmandje is leeg.</div>';
return;
}
const rows = [];
for (const [id, qty] of cart) {
const p = findProduct(id);
if (!p) continue;
const tile = p.image
? `<img src="${escapeHtml(p.image)}" alt="" onerror="this.remove(); this.parentNode.textContent = this.parentNode.dataset.fallback;" />`
: escapeHtml(p.from);
rows.push(`
<div class="cart-row">
<div class="tile" data-fallback="${escapeHtml(p.from)}" aria-hidden="true">${tile}</div>
<div class="info">
<strong>${escapeHtml(p.name)}</strong>
<span class="unit">${formatPrice(p.price)} per stuk</span>
<div class="qty">
<button data-step="-1" data-id="${p.id}" aria-label="Aantal verlagen">&minus;</button>
<span>${qty}</span>
<button data-step="1" data-id="${p.id}" aria-label="Aantal verhogen">+</button>
</div>
</div>
<span class="line-total">${formatPrice(p.price * qty)}</span>
</div>
`);
}
els.cartItems.innerHTML = rows.join("");
}
// Toast.
let toastTimer;
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2200);
}
// Mandje-operaties.
function addToCart(id) {
const product = findProduct(id);
if (!product) return;
cart.set(id, (cart.get(id) || 0) + 1);
renderCart();
showToast(product.name + " toegevoegd aan het mandje.");
}
function stepQuantity(id, step) {
if (!cart.has(id)) return;
const next = cart.get(id) + step;
if (next <= 0) {
cart.delete(id);
} else {
cart.set(id, next);
}
renderCart();
}
function clearCart() {
cart.clear();
renderCart();
}
// Drawer.
function openCart() {
els.overlay.classList.add("open");
els.drawer.classList.add("open");
}
function closeCart() {
els.overlay.classList.remove("open");
els.drawer.classList.remove("open");
}
// Modal.
function openModal() { els.modalWrap.classList.add("open"); }
function closeModal() { els.modalWrap.classList.remove("open"); }
function renderCheckoutForm() {
const { total } = cartTotals();
els.modalContent.innerHTML = `
<h2>Afrekenen</h2>
<p class="sub">Dit formulier verwerkt geen gegevens. Ingevoerde waarden worden niet opgeslagen of verzonden.</p>
<form id="payForm" novalidate>
<div class="field">
<label for="f-name">Naam</label>
<input id="f-name" name="name" required autocomplete="name" placeholder="Voor- en achternaam" />
</div>
<div class="field">
<label for="f-email">E-mailadres</label>
<input id="f-email" name="email" type="email" required autocomplete="email" placeholder="naam@voorbeeld.nl" />
</div>
<div class="field">
<label for="f-address">Bezorgadres</label>
<input id="f-address" name="address" required autocomplete="street-address" placeholder="Straat, huisnummer, plaats" />
</div>
<div class="field">
<label for="f-card">Kaartnummer (voer geen echte gegevens in)</label>
<input id="f-card" name="card" inputmode="numeric" required placeholder="0000 0000 0000 0000" />
</div>
<div class="row2">
<div class="field">
<label for="f-exp">Vervaldatum</label>
<input id="f-exp" name="exp" required placeholder="MM/JJ" />
</div>
<div class="field">
<label for="f-cvc">CVC</label>
<input id="f-cvc" name="cvc" inputmode="numeric" required placeholder="123" />
</div>
</div>
<button class="pay-btn" type="submit">Plaats bestelling (${formatPrice(total)})</button>
<p class="pay-note">Demonstratieformulier. Er wordt geen betaling uitgevoerd.</p>
</form>
`;
document.getElementById("payForm").addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
// Gebruik native validatie in plaats van ongeldige invoer te negeren.
if (!form.checkValidity()) {
form.reportValidity();
return;
}
renderSuccess();
});
}
function renderSuccess() {
const { count } = cartTotals();
const orderId = "CL-" + Math.floor(100000 + Math.random() * 900000);
els.modalContent.innerHTML = `
<div class="success">
<div class="check" aria-hidden="true">&checkmark;</div>
<h2>Bestelling geregistreerd</h2>
<p>${count} fictief artikel(en) zijn vastgelegd in deze demonstratie.</p>
<div class="order-id">Referentie: ${escapeHtml(orderId)}</div>
<p>Verwachte levering: niet van toepassing.</p>
<button class="pay-btn" id="doneBtn" style="margin-top:18px">Verder winkelen</button>
</div>
`;
clearCart();
document.getElementById("doneBtn").addEventListener("click", () => {
closeModal();
showToast("Winkelmandje geleegd.");
});
}
// Event-koppeling (delegatie waar lijsten betrokken zijn).
els.filters.addEventListener("click", (event) => {
const button = event.target.closest("[data-filter]");
if (!button) return;
activeFilter = button.dataset.filter;
renderFilters();
renderCatalog();
});
els.grid.addEventListener("click", (event) => {
const button = event.target.closest("[data-add]");
if (button) addToCart(button.dataset.add);
});
els.cartItems.addEventListener("click", (event) => {
const button = event.target.closest("[data-step]");
if (button) stepQuantity(button.dataset.id, Number(button.dataset.step));
});
els.openCart.addEventListener("click", openCart);
els.closeCart.addEventListener("click", closeCart);
els.overlay.addEventListener("click", closeCart);
els.checkoutBtn.addEventListener("click", () => {
if (cart.size === 0) return;
closeCart();
renderCheckoutForm();
openModal();
});
els.modalWrap.addEventListener("click", (event) => {
if (event.target === els.modalWrap) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeModal();
closeCart();
}
});
// Eerste render.
document.getElementById("year").textContent = String(new Date().getFullYear());
renderFilters();
renderCatalog();
renderCart();