// Lightweight visual flourishes: // - count-up animation on [data-countup] elements at page load // - fade-in on htmx swap targets via a transient .v-just-swapped class // Respects prefers-reduced-motion by no-oping both effects. (function () { const prefersReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function animateCount(el) { if (prefersReduced) return; const raw = el.textContent.trim(); const m = raw.match(/^(\$|£|€|¥)?(-?[\d,]+(?:\.\d+)?)$/); if (!m) return; const prefix = m[1] || ""; const numeric = m[2].replace(/,/g, ""); const target = parseFloat(numeric); if (!isFinite(target)) return; const decimals = numeric.includes(".") ? numeric.split(".")[1].length : 0; const format = (v) => prefix + (decimals > 0 ? v.toFixed(decimals) : Math.floor(v).toString()); const duration = 650; const start = performance.now(); el.textContent = format(0); function tick(now) { const t = Math.min(1, (now - start) / duration); const v = target * easeOutCubic(t); el.textContent = format(v); if (t < 1) { requestAnimationFrame(tick); } else { el.textContent = format(target); } } requestAnimationFrame(tick); } function runCountUps() { document.querySelectorAll("[data-countup]").forEach(animateCount); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", runCountUps); } else { runCountUps(); } document.addEventListener("htmx:afterSwap", function (evt) { if (prefersReduced) return; const target = evt.detail && evt.detail.target; if (!target || !target.classList) return; target.classList.add("v-just-swapped"); setTimeout(() => target.classList.remove("v-just-swapped"), 400); }); // Auction countdowns. Each [data-countdown] reads its sibling/ancestor's // data-ends-at ISO timestamp. Above 1h remaining we update once a minute // (text barely changes); inside the last hour we tick every second and tint // urgent / critical so the eye lands on it. Past the end we render "ended" // and stop ticking that node. function formatRemaining(ms) { if (ms <= 0) return { text: "ended", urgent: false, critical: false }; const s = Math.floor(ms / 1000); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const minutes = Math.floor((s % 3600) / 60); const seconds = s % 60; const urgent = ms < 60 * 60 * 1000; const critical = ms < 5 * 60 * 1000; let text; if (days > 0) text = days + "d " + hours + "h"; else if (hours > 0) text = hours + "h " + minutes + "m"; else if (minutes > 0) text = minutes + "m " + String(seconds).padStart(2, "0") + "s"; else text = seconds + "s"; return { text, urgent, critical }; } function resolveEndsAt(el) { let cur = el; while (cur && cur.dataset && !cur.dataset.endsAt) cur = cur.parentElement; return cur && cur.dataset ? cur.dataset.endsAt : null; } function tickCountdown(el, endsAtMs) { const remaining = endsAtMs - Date.now(); const f = formatRemaining(remaining); el.textContent = f.text; el.classList.toggle("v-countdown-urgent", f.urgent && !f.critical); el.classList.toggle("v-countdown-critical", f.critical && remaining > 0); el.classList.toggle("v-countdown-ended", remaining <= 0); return remaining; } function startCountdowns() { document.querySelectorAll("[data-countdown]").forEach(function (el) { const iso = resolveEndsAt(el); if (!iso) return; const endsAtMs = Date.parse(iso); if (!isFinite(endsAtMs)) return; let timer = null; function loop() { const remaining = tickCountdown(el, endsAtMs); if (remaining <= 0) { if (timer) clearTimeout(timer); return; } // Tick every second below an hour, every 30 seconds otherwise. The // long-interval path keeps "3d 4h" from causing constant repaints. const interval = remaining < 60 * 60 * 1000 ? 1000 : 30000; timer = setTimeout(loop, interval); } loop(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", startCountdowns); } else { startCountdowns(); } // After an htmx swap, freshly-inserted countdowns need to be started too. document.addEventListener("htmx:afterSwap", startCountdowns); })();