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.
251 lines
8.1 KiB
JavaScript
251 lines
8.1 KiB
JavaScript
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 `<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;
|
||
});
|