${safeImages.map((src, i) => `
})
`).join('')}
Just WorksLive merchant screen
${esc(merchant.name)}${esc([merchant.category, merchant.area].filter(Boolean).join(' · '))}
Happening now
Orders · Payments · Tips
◉Latest on Nostr${latest ? `${ago(latest.created_at)} · signed event` : 'Merchant feed'}
${latest ? esc(latest.content.slice(0, 220)) : 'No signed text post from this merchant yet.'}
${esc(merchant.npub.slice(0, 18))}…${esc(merchant.npub.slice(-8))}
Now at
${esc(merchant.name)}
${safeImages.map((_, i) => ``).join('')}
`;
document.querySelectorAll('[data-logo]').forEach(host => mountLogo(host, {icon: true}));
document.querySelectorAll('.screen-qr img').forEach(img => img.addEventListener('error', () => {
img.hidden = true;
img.closest('a').querySelector('small').textContent = 'QR unavailable · open link ↗';
}));
mountAppControls(document.querySelector(".screen-app-controls"));
startSlideshow(safeImages.length);
startActivity(startQRTheatre());
}
function startQRTheatre() {
const stage = document.querySelector('.stage'), actions = document.querySelector('.screen-actions');
const cards = [...actions.querySelectorAll('.screen-qr')], choices = [...actions.querySelectorAll('[data-qr-choice]')];
const pause = actions.querySelector('.qr-pause'), motion = matchMedia('(prefers-reduced-motion: reduce)');
let current = 0, timer, paused = false, active = false;
const choose = index => {
current = index;
cards.forEach((card, i) => card.classList.toggle('is-current', i === current));
choices.forEach((button, i) => button.setAttribute('aria-pressed', String(i === current)));
};
const sync = () => {
clearTimeout(timer);
const running = !active && !motion.matches && !paused && !actions.contains(document.activeElement);
stage.dataset.rotation = running ? 'running' : 'paused';
if (running) timer = setTimeout(() => {choose((current + 1) % cards.length); sync();}, 12000);
};
choices.forEach((button, i) => button.addEventListener('click', () => {choose(i); sync();}));
pause.addEventListener('click', () => {
paused = !paused;
pause.setAttribute('aria-pressed', String(paused));
pause.textContent = paused ? 'Resume rotation' : 'Pause rotation';
sync();
});
actions.addEventListener('focusin', sync);
actions.addEventListener('focusout', () => queueMicrotask(sync));
motion.addEventListener('change', () => {
if (motion.matches && actions.querySelector('.qr-controls').contains(document.activeElement)) cards[current].focus();
// When leaving reduced motion, retain whichever of the three links has focus.
const focused = cards.indexOf(document.activeElement);
if (focused !== -1) choose(focused);
sync();
});
return count => {
const nextActive = count > 0;
// Keep keyboard focus on a usable link when a control leaves the layout.
if (nextActive && actions.querySelector('.qr-controls').contains(document.activeElement)) cards[current].focus();
if (!nextActive && active) {
const focused = cards.indexOf(document.activeElement);
if (focused !== -1) choose(focused);
}
stage.dataset.view = nextActive ? 'active' : 'idle';
stage.dataset.activityCount = String(count);
if (nextActive !== active || !stage.dataset.rotation) {active = nextActive; sync();}
};
}
function startSlideshow(count) {
let current = 0;
const slides = [...document.querySelectorAll('.slide')], dots = [...document.querySelectorAll('.progress i')];
const clock = document.querySelector('#clock');
const tick = () => {const date = new Date(); clock.textContent = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); clock.dateTime = date.toISOString();};
tick();
setInterval(tick, 10000);
if (count > 1) setInterval(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
slides[current].classList.remove('active'); dots[current].classList.remove('active');
current = (current + 1) % count;
slides[current].classList.add('active'); dots[current].classList.add('active');
}, 9000);
}
function startActivity(setView) {
const list = document.querySelector('#activity-items'), connection = document.querySelector('.feed-connection');
const announcement = document.querySelector('.activity-announcement');
let events = [], lastHTML = '', connected = false, tipSource = false;
let expiryTimer;
let serverTime = Date.now(), receivedAt = performance.now();
const draw = () => {
const current = serverTime + performance.now() - receivedAt;
events = events.filter(item => Date.parse(item.expires_at) > current);
setView(Math.min(events.length, 4));
clearTimeout(expiryTimer);
if (events.length) expiryTimer = setTimeout(draw, Math.max(1, Math.ceil(Math.min(...events.map(item => Date.parse(item.expires_at))) - current)));
const html = events.slice(0, 4).map(item => {
const order = item.type === 'order', tip = item.type === 'tip';
const title = order ? `Table ${item.table}` : tip ? 'Thank you for the tip!' : 'JustPay received';
const details = order ? (item.items?.length ? item.items.map(product => `${product.qty} × ${product.name}`).join(' · ') : 'New order · details with the team') : tip ? `${Number(item.amount_sats).toLocaleString()} sats` : new Intl.NumberFormat('en', {style:'currency',currency:'EUR'}).format(item.amount_cents / 100);
const remaining = Math.max(0, Date.parse(item.expires_at) - current);
return `