import { mountLogo } from "./ds/logo.js"; import { trackGallery } from "./general-analytics.js"; document .querySelectorAll("[data-logo]") .forEach((el) => mountLogo(el, { icon: true })); document.querySelector("#themeToggle")?.remove(); const $ = (id) => document.querySelector(id); const gallery = $("#gallery"), search = $("#search"), empty = $("#empty"); const categoryNames = { restaurant: "Restaurant", cafe: "Café", bar: "Bar", bakery: "Bakery", stay: "Stay", hotel: "Stay", shop: "Shop", market: "Market", beauty: "Beauty", barber: "Barber", activity: "Experience", other: "Local place", }; const mealTerms = { breakfast: ["breakfast", "brunch", "bakery", "cafe", "coffee"], lunch: ["lunch", "restaurant", "cafe", "takeaway"], dinner: ["dinner", "restaurant", "bar", "evening"], coffee: ["coffee", "cafe", "bakery"], drinks: ["drinks", "bar", "wine", "cocktail", "beer", "poncha"], stay: ["stay", "hotel", "hostel", "guesthouse", "accommodation"], shop: ["shop", "market", "store", "retail"], }; let places = [], filtered = [], payment = "all", quick = "", shown = 0, seed = 0; const PAGE = 36; const esc = (v) => String(v ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ], ); const words = (p) => [p.name, p.category, p.area, p.address, ...(p.tags || [])] .join(" ") .toLowerCase(); const photo = (p) => (p.images || []).find(Boolean) || p.image || ""; function pseudo(value) { let n = 0; for (const c of value) n = (n * 31 + c.charCodeAt(0)) >>> 0; return n; } function isOpen(p) { if (!p.hours) return false; const text = p.hours.toLowerCase(); if (/open 24|24\/7/.test(text)) return true; if (/closed/.test(text) && !/[–-]/.test(text)) return false; return true; } function matchQuick(p) { if (!quick) return true; if (quick === "open") return isOpen(p); const hay = words(p); return (mealTerms[quick] || [quick]).some((term) => hay.includes(term)); } function card(p, index) { const image = photo(p), height = ["portrait", "landscape", "tall"][(pseudo(p.slug) + seed) % 3], bitcoin = p.payment === "bitcoin", url = p.justworks || p.nsite || p.website || "#"; return `
${image ? `
${esc(p.name)}
${bitcoin ? "₿ Bitcoin" : "Fiat"}
` : `
${esc((p.name || "?").slice(0, 2).toUpperCase())}${esc(categoryNames[p.category] || p.category || "Local")}
`}

${esc(categoryNames[p.category] || p.category || "Local place")} · ${esc(p.area || "Madeira")}

${esc(p.name)}

↗${p.rating ? `★ ${Number(p.rating).toFixed(1)}` : ""}
${( p.tags || [] ) .slice(0, 3) .map((tag) => `${esc(tag)}`) .join("")}
`; } function apply(reset = true) { const term = search.value.trim().toLowerCase(); filtered = places.filter( (p) => (payment === "all" || p.payment === payment) && matchQuick(p) && (!term || words(p).includes(term)), ); filtered.sort( (a, b) => (photo(b) ? 1 : 0) - (photo(a) ? 1 : 0) || (b.rating || 0) - (a.rating || 0), ); if (seed) filtered.sort( (a, b) => (pseudo(a.slug + seed) % 997) - (pseudo(b.slug + seed) % 997), ); if (reset) { shown = 0; gallery.innerHTML = ""; } renderMore(); $("#resultCount").textContent = `${filtered.length} ${filtered.length === 1 ? "place" : "places"}`; $("#resultContext").textContent = [ payment !== "all" ? payment === "bitcoin" ? "Bitcoin accepted" : "Fiat payment" : "", quick && quick !== "open" ? quick[0].toUpperCase() + quick.slice(1) : quick === "open" ? "Published hours available" : "", term ? `matching “${term}”` : "", ] .filter(Boolean) .join(" · ") || "Real merchants from Just Works Maps"; empty.hidden = filtered.length > 0; gallery.hidden = !filtered.length; history.replaceState( null, "", `?${new URLSearchParams({ ...(term ? { q: term } : {}), ...(payment !== "all" ? { pay: payment } : {}), ...(quick ? { filter: quick } : {}) })}`, ); } function renderMore() { const batch = filtered.slice(shown, shown + PAGE); gallery.insertAdjacentHTML( "beforeend", batch.map((p, i) => card(p, shown + i)).join(""), ); shown += batch.length; document .querySelectorAll("img") .forEach( (img) => (img.onerror = () => img.closest(".place-card")?.classList.add("broken-image")), ); } function selectPayment(value) { payment = value; document .querySelectorAll("[data-payment]") .forEach((b) => b.classList.toggle("active", b.dataset.payment === value)); apply(); trackGallery("surface_interaction", `payment_filter:${value}`); } function selectQuick(value) { quick = value; document .querySelectorAll("[data-query]") .forEach((b) => b.classList.toggle("active", b.dataset.query === value)); apply(); if (value) trackGallery("surface_interaction", `filter:${value}`); } function clear() { search.value = ""; payment = "all"; quick = ""; seed = 0; document .querySelectorAll("[data-payment]") .forEach((b) => b.classList.toggle("active", b.dataset.payment === "all")); document .querySelectorAll("[data-query]") .forEach((b) => b.classList.toggle("active", !b.dataset.query)); apply(); } document .querySelectorAll("[data-payment]") .forEach((b) => (b.onclick = () => selectPayment(b.dataset.payment))); document .querySelectorAll("[data-query]") .forEach((b) => (b.onclick = () => selectQuick(b.dataset.query))); let searchTimer; search.oninput = () => { apply(); clearTimeout(searchTimer); if (search.value.trim()) searchTimer = setTimeout( () => trackGallery("surface_interaction", "search"), 700, ); }; gallery.addEventListener("click", (event) => { const card = event.target.closest(".place-card"); if (card) trackGallery( "surface_interaction", `place_open:${card.dataset.category || "other"}`, ); }); $("#clearSearch").onclick = () => { search.value = ""; apply(); search.focus(); }; $("#clearAll").onclick = clear; $("#shuffle").onclick = () => { seed = Date.now(); apply(); trackGallery("surface_interaction", "shuffle"); }; new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && shown < filtered.length) renderMore(); }, { rootMargin: "800px" }, ).observe($("#sentinel")); async function boot() { const data = await fetch("/api/gallery", { cache: "no-store" }).then((r) => { if (!r.ok) throw new Error("data"); return r.json(); }); places = data.places || []; $("#totalCount").textContent = `${places.length} independent places`; $("#allCount").textContent = places.length; $("#bitcoinCount").textContent = places.filter( (p) => p.payment === "bitcoin", ).length; $("#fiatCount").textContent = places.filter( (p) => p.payment === "fiat", ).length; const params = new URLSearchParams(location.search); search.value = params.get("q") || ""; payment = params.get("pay") || "all"; quick = params.get("filter") || ""; document .querySelectorAll("[data-payment]") .forEach((b) => b.classList.toggle("active", b.dataset.payment === payment), ); document .querySelectorAll("[data-query]") .forEach((b) => b.classList.toggle("active", b.dataset.query === quick)); apply(); trackGallery("surface_view", "open"); } boot().catch(() => { $("#resultCount").textContent = "Gallery temporarily unavailable"; empty.hidden = false; gallery.hidden = true; });