Files
archy/docker/justworks/business/public/assets/gallery.js
T
yaya 94899e69d3 feat: integrate Just Works app and native business setup
Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
2026-10-08 19:39:38 +01:00

251 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
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 `<article class="place-card ${height} ${image ? "has-image" : "no-image"}" data-category="${esc(p.category || "other")}" style="--delay:${Math.min(index, 15) * 25}ms"><a href="${esc(url)}" ${url.startsWith("http") ? 'target="_blank" rel="noopener"' : ""}>${image ? `<figure><img src="${esc(image)}" alt="${esc(p.name)}" loading="lazy" decoding="async"><div class="image-wash"></div><span class="pay-mark ${bitcoin ? "bitcoin" : "fiat"}">${bitcoin ? "₿ Bitcoin" : "Fiat"}</span></figure>` : `<figure class="fallback"><span>${esc((p.name || "?").slice(0, 2).toUpperCase())}</span><small>${esc(categoryNames[p.category] || p.category || "Local")}</small></figure>`}<div class="card-copy"><div><p>${esc(categoryNames[p.category] || p.category || "Local place")} · ${esc(p.area || "Madeira")}</p><h2>${esc(p.name)}</h2></div><span class="arrow">↗</span>${p.rating ? `<small class="rating">★ ${Number(p.rating).toFixed(1)}</small>` : ""}<div class="card-tags">${(
p.tags || []
)
.slice(0, 3)
.map((tag) => `<span>${esc(tag)}</span>`)
.join("")}</div></div></a></article>`;
}
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;
});