Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

143
web/html/js/views/public.js Normal file
View File

@@ -0,0 +1,143 @@
// Öffentliche Ansicht einer Liste über /s/<token>. Kein Konto nötig.
// Nur ansehen und - sofern erlaubt - abhaken. Auf Druck ausgelegt.
"use strict";
import { get, post } from "../api.js";
import { clear, el, euro, formatPack, mount } from "../dom.js";
function formatDate(iso) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export async function publicView(root, { token }) {
let view = null;
let banner = null;
async function load() {
view = await get(`/api/public/${encodeURIComponent(token)}`);
}
function say(message, kind = "error") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
function itemRow(item) {
const bought = item.status === "bought";
const check = view.allow_check
? el("button.check", {
type: "button",
"aria-pressed": bought ? "true" : "false",
title: bought ? "Als offen markieren" : "Als gekauft markieren",
onclick: async () => {
const next = bought ? "open" : "bought";
item.status = next; // sofort anzeigen
render();
try {
await post(`/api/public/${encodeURIComponent(token)}/items/${item.id}`,
{ status: next });
await load();
} catch (err) {
await load().catch(() => {});
say(err.message);
}
render();
},
}, bought ? "✓" : "")
: el("span.check.readonly", { "aria-hidden": "true" }, bought ? "✓" : "");
const pack = formatPack(item.pack_size, item.pack_unit);
return el("li", { className: `item${bought ? " bought" : ""}` },
el("div.item-main", {},
check,
el("span", {
className: `item-count${item.count > 1 ? " many" : ""}`,
}, `${item.count || 1}\u00d7`),
el("div.item-text", {},
el("span.name", {}, item.article_name),
pack || item.variant || item.note
? el("span.sub", {},
[pack, item.variant, item.note].filter(Boolean).join(" · "))
: null),
item.price_cents
? el("span.price-static", {},
euro(item.total_cents ?? item.price_cents))
: null
)
);
}
function render() {
mount(root,
el("header.public-head", {},
el("h1", {}, view.list_name),
el("p.sub", {},
"Geteilte Ansicht",
view.allow_check ? " du kannst Artikel abhaken." : " nur zum Ansehen.",
` Gültig bis ${formatDate(view.expires_at)}.`)
),
banner ? el("p.error", {}, banner.message) : null,
view.markets.length
? view.markets.map((market) =>
el("section.market", {},
el("h2", {},
el("span", {}, market.market_name),
el("span.market-meta", {},
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
market.categories.map((cat) =>
el("div.category", {},
el("h3", {}, cat.category_name),
el("ul.items", {}, cat.items.map(itemRow))))))
: el("p.empty", {}, "Die Liste ist leer."),
el("footer.summary", {},
el("div.total", {},
el("span", {}, "Gesamt"),
el("strong", {}, euro(view.grand_total_cents)))),
el("p.footnote.no-print", {},
// Eigene Druckseite statt window.print(): Sie ist auf A4
// ausgelegt und funktioniert auch, wenn der Empfänger die
// Ansicht nur weiterleiten will.
el("a.linklike", {
href: `/api/public/${encodeURIComponent(token)}/print`,
target: "_blank",
rel: "noopener",
}, "Drucken"),
" · ",
el("button.linklike", {
type: "button",
onclick: async () => {
try {
await load();
render();
} catch (err) {
say(err.message);
}
},
}, "Aktualisieren"))
);
}
try {
await load();
} catch (err) {
mount(root,
el("section.card", {},
el("h1", {}, "Liste nicht verfügbar"),
el("p.error", {}, err.message),
el("p.lead", {},
"Bitte die Person, die den Link geteilt hat, um einen neuen."))
);
return;
}
render();
}