From 609c3efb05be34e236abc30b20191bb652a3430e Mon Sep 17 00:00:00 2001 From: carsten Date: Mon, 28 Sep 2026 22:55:55 +0200 Subject: [PATCH] Opgave 8c: ny offentlig bestillings-/tilmeldingsside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-renderet af FastAPI selv (Jinja2 + vanilla JS/CSS, intet build-step) mod det eksisterende domæneopløste JSON-API fra 8b. GET / viser bestillingssiden når en runde er åben (rundeoverskrift, intro_text, kategori-grupperet vinkatalog med live-beregnet EUR/DKK-total, bestillingsformular), ellers en tilmeldingsside. GET /tilmelding er altid tilgængelig uafhængigt af rundestatus, og GET /afhentning viser rutens meeting_info. Layoutet er modelleret efter den gamle sides bestillingsside. get_current_round's serialiseringslogik er udtrukket til en delt _build_page_info-helper, så JSON-API'et og de nye HTML-sider genbruger nøjagtig samme domæne-/rundeopslag. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 55 ++++++++- app/main.py | 4 + app/routers/public_orders.py | 11 +- app/routers/public_site.py | 36 ++++++ app/static/site.css | 229 +++++++++++++++++++++++++++++++++++ app/static/site.js | 127 +++++++++++++++++++ app/templates/base.html | 23 ++++ app/templates/order.html | 75 ++++++++++++ app/templates/pickup.html | 10 ++ app/templates/signup.html | 26 ++++ 10 files changed, 587 insertions(+), 9 deletions(-) create mode 100644 app/routers/public_site.py create mode 100644 app/static/site.css create mode 100644 app/static/site.js create mode 100644 app/templates/base.html create mode 100644 app/templates/order.html create mode 100644 app/templates/pickup.html create mode 100644 app/templates/signup.html diff --git a/CLAUDE.md b/CLAUDE.md index 46bcd6c..79c32dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,10 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun ## Tech stack - Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations) -- Frontend: React (admin-UI) + eksisterende offentlige bestillingsside - (fortsat intet login for deltagere) +- Frontend: React (admin-UI, ikke bygget endnu) + den offentlige + bestillings-/tilmeldingsside (opgave 8c) — server-renderet af + FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i + `app/static/`, intet build-step). Fortsat intet login for deltagere. - Mail: Postal (selvhostet), `postal.carsteng.dk`, domæne `vinindkoeb.dk` - Auth: JWT (`Authorization: Bearer`), 2 timers levetid. Superadmin- handlinger kræver derudover en kortlivet (5 min) "eleveret" session @@ -121,6 +123,35 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun - `postal_message_id`/`postal_token` (til korrelation med webhook), `error_message`, `sent_at`, `opened_at`, `clicked_at` +## Den offentlige side (opgave 8c) + +Server-renderet af samme FastAPI-app som API'et (`app/routers/ +public_site.py`, ingen prefix — adskilt fra JSON-API'et under +`/public/*`). Genbruger `get_route_from_domain` og en fælles +`_build_page_info`-helper fra `app/routers/public_orders.py` (samme +domæneopløsning som JSON-API'et), så der ikke er to steder der +udleder "hvilken rute/runde gælder denne request". + +- **`GET /`** — bestillingssiden, hvis en runde er åben + (`order.html`: rundeoverskrift, `intro_text` som rå HTML — admin- + betroet fritekst, ligesom `MailTemplate.body_html` — vinkatalog + grupperet pr. kategori med antal-input pr. linje, sidebar med + live-beregnet EUR/DKK-total, navn/email/telefon + "modtag + fremtidige mails"-checkbox, samt et link til `/tilmelding`); ellers + tilmeldingssiden (`signup.html`, samme skabelon som `/tilmelding`, + med en ekstra sætning om at ingen runde er åben). +- **`GET /tilmelding`** — samme tilmeldingsformular, altid + tilgængelig uanset rundestatus (kun navn+email). +- **`GET /afhentning`** — viser `Route.meeting_info` som rå HTML; + falder tilbage til en pæn besked hvis feltet er tomt. +- Begge formularer sender via `fetch()` (vanilla JS, `app/static/ + site.js`) direkte til de eksisterende JSON-endpoints + (`/public/orders`/`/public/signup`) — ingen full-page reload, + fejlbeskeder fra `detail`-feltet vises inline. +- **Bevidst udeladt:** "Om"-siden (fandtes ikke i det gamle site), + i18n/fransk oversættelse (se Fase 2), JS-drevet mobil-hamburgermenu + (nav'en er blot CSS-responsiv). + ## Mail-events 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** (opgave 7a-c): skabelon pr. rute (`MailTemplate`, @@ -148,7 +179,7 @@ endnu. ## Fase 1 — nuværende scope -**Færdige opgaver (1-7, 8a-b):** +**Færdige opgaver (1-7, 8a-c):** 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 2. ✅ Datamodellerne (Organization/Route-hierarki) 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, @@ -180,9 +211,15 @@ endnu. dækker både "ingen runde åben → vis tilmelding" og "runde åben → link til tilmelding uden bestilling" (selve linket/UI-skiftet er en 8c-detalje). - - **8c**: Selve den nye offentlige frontend — brugeren har ikke - adgang til den gamle frontends kode (hårdt koblet til det gamle - API), så en ny bygges. Egen stack-beslutning, ikke taget endnu. + - **8c**: ✅ Selve den nye offentlige frontend — se "Den offentlige + side" ovenfor. Server-renderet af FastAPI (Jinja2 + vanilla + JS/CSS, intet build-step, intet separat deploy). Layoutet er + modelleret efter den gamle sides bestillingsside (delt som + skærmbillede under planlægning). **Bemærk:** den interaktive + JS (live totalberegning, fetch-baseret formular-indsendelse) er + ikke maskinverificeret (udviklingsmiljøet har ikke browser- + adgang) — kun HTML-renderingen er testet. Bør afprøves i en + rigtig browser før siden går i produktion. **Resterende opgaver:** 9. Admin-ordreoversigt pr. runde med "markér betalt" → trigger mail #3 @@ -196,3 +233,9 @@ endnu. - Passkey-login til admin - Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig) - sold_only_by_case-flag + validering, hvis det bliver relevant +- i18n/fransk oversættelse af den offentlige side (8c blev bevidst + bygget uden nogen form for i18n-forberedelse — Horcher-ruterne er + stadig ikke i drift). Når det bliver aktuelt: afklar om det skal + være pr.-rute (via `Route`) eller pr.-organisation, og hvordan + admin-indtastet fritekst (`intro_text`, `meeting_info` m.fl.) + håndteres på flere sprog. diff --git a/app/main.py b/app/main.py index 725fca4..2c16a5d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from sqlalchemy import text from app.db import SessionDep @@ -8,6 +9,7 @@ from app.routers import ( mail_templates, participants, public_orders, + public_site, purchase_rounds, webhooks, wine_categories, @@ -15,6 +17,7 @@ from app.routers import ( ) app = FastAPI(title="Vinindkøb Admin API") +app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(auth.router) app.include_router(participants.router) app.include_router(purchase_rounds.router) @@ -24,6 +27,7 @@ app.include_router(mail_templates.router) app.include_router(mail_logs.router) app.include_router(webhooks.router) app.include_router(public_orders.router) +app.include_router(public_site.router) @app.get("/health") diff --git a/app/routers/public_orders.py b/app/routers/public_orders.py index 175dbeb..832cff9 100644 --- a/app/routers/public_orders.py +++ b/app/routers/public_orders.py @@ -178,9 +178,7 @@ def _signup_participant(session: Session, route_id: int, data: PublicSignupInput return participant -@router.get("/current-round", response_model=PublicPageInfo) -def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo: - route = get_route_from_domain(request, session) +def _build_page_info(session: Session, route: Route) -> PublicPageInfo: purchase_round = _get_open_round(session, route.id) if purchase_round is None: return PublicPageInfo(route_name=route.name, meeting_info=route.meeting_info, current_round=None) @@ -190,6 +188,7 @@ def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo: .where(WineOffering.purchase_round_id == purchase_round.id) .options(selectinload(WineOffering.category)) ).all() + offerings = sorted(offerings, key=lambda o: (o.category.sort_order, o.id)) return PublicPageInfo( route_name=route.name, @@ -219,6 +218,12 @@ def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo: ) +@router.get("/current-round", response_model=PublicPageInfo) +def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo: + route = get_route_from_domain(request, session) + return _build_page_info(session, route) + + @router.post("/signup", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED) def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant: route = get_route_from_domain(request, session) diff --git a/app/routers/public_site.py b/app/routers/public_site.py new file mode 100644 index 0000000..1d18c18 --- /dev/null +++ b/app/routers/public_site.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from app.db import SessionDep +from app.routers.public_orders import _build_page_info, get_route_from_domain + +router = APIRouter(tags=["public-site"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/", response_class=HTMLResponse) +def public_home(request: Request, session: SessionDep) -> HTMLResponse: + route = get_route_from_domain(request, session) + page_info = _build_page_info(session, route) + if page_info.current_round is None: + return templates.TemplateResponse( + request, "signup.html", {"route_name": route.name, "no_round_open": True} + ) + return templates.TemplateResponse(request, "order.html", {"route_name": route.name, "page": page_info}) + + +@router.get("/tilmelding", response_class=HTMLResponse) +def public_signup_page(request: Request, session: SessionDep) -> HTMLResponse: + route = get_route_from_domain(request, session) + return templates.TemplateResponse( + request, "signup.html", {"route_name": route.name, "no_round_open": False} + ) + + +@router.get("/afhentning", response_class=HTMLResponse) +def public_pickup_page(request: Request, session: SessionDep) -> HTMLResponse: + route = get_route_from_domain(request, session) + return templates.TemplateResponse( + request, "pickup.html", {"route_name": route.name, "meeting_info": route.meeting_info} + ) diff --git a/app/static/site.css b/app/static/site.css new file mode 100644 index 0000000..05367e4 --- /dev/null +++ b/app/static/site.css @@ -0,0 +1,229 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: #222; + background: #fff; +} + +.navbar { + background: #1a1a1a; + color: #fff; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: 16px 24px; +} + +.navbar-brand { + font-size: 1.1rem; + font-weight: 600; +} + +.navbar-links a { + color: #fff; + text-decoration: none; + margin-left: 24px; + font-size: 0.95rem; +} + +.navbar-links a:hover { + text-decoration: underline; +} + +.content { + max-width: 1150px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.route-label { + text-align: center; + margin: 0 0 4px; + color: #666; +} + +.round-heading { + text-align: center; + font-size: 2.2rem; + margin: 0 0 24px; +} + +.intro-text { + text-align: center; + max-width: 700px; + margin: 0 auto 16px; + line-height: 1.6; +} + +.intro-text p { + margin: 0 0 12px; +} + +.signup-link { + text-align: center; + margin: 0 0 32px; + color: #555; +} + +.order-layout { + display: flex; + gap: 32px; + align-items: flex-start; + flex-wrap: wrap; +} + +.catalog { + flex: 3 1 600px; + min-width: 0; +} + +.wine-table { + width: 100%; + border-collapse: collapse; +} + +.wine-table .category-row th { + text-align: center; + text-transform: uppercase; + font-size: 0.95rem; + padding: 16px 8px 8px; + border-bottom: 1px solid #ddd; +} + +.wine-table td { + padding: 6px 8px; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; +} + +.qty-cell { + width: 56px; +} + +.qty-input { + width: 48px; + padding: 4px; +} + +.price-cell, +.row-total-cell { + text-align: right; + white-space: nowrap; + width: 80px; +} + +.organic-badge { + font-size: 0.75rem; + color: #2e7d32; + border: 1px solid #2e7d32; + border-radius: 4px; + padding: 0 4px; + margin-left: 4px; +} + +.order-sidebar { + flex: 1 1 260px; + min-width: 240px; + position: sticky; + top: 24px; +} + +.total-line { + font-weight: 600; + margin: 0 0 4px; +} + +.rate-note { + font-size: 0.85rem; + color: #666; + margin: 0 0 20px; +} + +.field-label { + display: block; + font-weight: 600; + margin: 16px 0 4px; +} + +.field-label input { + display: block; + width: 100%; + margin-top: 4px; + padding: 8px; + font-size: 1rem; +} + +.field-help { + font-size: 0.85rem; + color: #666; + margin: 4px 0 0; +} + +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 20px 0; + font-size: 0.9rem; +} + +button[type="submit"] { + width: 100%; + padding: 12px; + font-size: 1rem; + background: #1a1a1a; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +button[type="submit"]:hover { + background: #333; +} + +.form-message { + margin-top: 12px; + padding: 10px; + border-radius: 4px; + font-size: 0.9rem; +} + +.form-message.success { + background: #e6f4ea; + color: #1e4620; +} + +.form-message.error { + background: #fdecea; + color: #611a15; +} + +.signup-card { + max-width: 420px; + margin: 0 auto; +} + +.signup-card form { + margin-top: 24px; +} + +.prose p { + line-height: 1.6; +} + +@media (max-width: 800px) { + .order-layout { + flex-direction: column; + } + + .order-sidebar { + position: static; + width: 100%; + } +} diff --git a/app/static/site.js b/app/static/site.js new file mode 100644 index 0000000..983b7ab --- /dev/null +++ b/app/static/site.js @@ -0,0 +1,127 @@ +(function () { + function formatAmount(n) { + return n.toFixed(2); + } + + function showMessage(el, text, kind) { + el.textContent = text; + el.className = "form-message " + kind; + el.hidden = false; + } + + async function extractErrorDetail(response) { + try { + const body = await response.json(); + if (typeof body.detail === "string") return body.detail; + if (Array.isArray(body.detail)) { + return body.detail.map((e) => e.msg || JSON.stringify(e)).join(", "); + } + return JSON.stringify(body.detail); + } catch (err) { + return "Der skete en fejl. Prøv igen."; + } + } + + const orderForm = document.getElementById("order-form"); + if (orderForm) { + const qtyInputs = Array.from(orderForm.querySelectorAll(".qty-input")); + const totalEurEl = document.getElementById("total-eur"); + const totalDkkEl = document.getElementById("total-dkk"); + const rateAttr = orderForm.dataset.eurDkkRate; + const rate = rateAttr ? parseFloat(rateAttr) : null; + + function recalculate() { + let totalEur = 0; + qtyInputs.forEach((input) => { + const qty = parseInt(input.value, 10) || 0; + const price = parseFloat(input.dataset.price) || 0; + const rowTotal = qty * price; + totalEur += rowTotal; + const cell = orderForm.querySelector( + '[data-row-total-for="' + input.dataset.offeringId + '"]' + ); + if (cell) cell.textContent = formatAmount(rowTotal); + }); + if (totalEurEl) totalEurEl.textContent = formatAmount(totalEur); + if (totalDkkEl && rate) totalDkkEl.textContent = formatAmount(totalEur * rate); + } + + qtyInputs.forEach((input) => input.addEventListener("input", recalculate)); + recalculate(); + + orderForm.addEventListener("submit", async function (event) { + event.preventDefault(); + const messageEl = document.getElementById("order-message"); + const orderLines = qtyInputs + .map((input) => ({ + wine_offering_id: parseInt(input.dataset.offeringId, 10), + quantity: parseInt(input.value, 10) || 0, + })) + .filter((line) => line.quantity > 0); + + if (orderLines.length === 0) { + showMessage(messageEl, "Vælg mindst én vin med et antal større end 0.", "error"); + return; + } + + const payload = { + participant: { + name: orderForm.elements["name"].value, + email: orderForm.elements["email"].value, + phone: orderForm.elements["phone"].value, + is_active: orderForm.elements["is_active"].checked, + }, + order_lines: orderLines, + }; + + try { + const response = await fetch("/public/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (response.ok) { + orderForm.querySelector(".catalog").hidden = true; + orderForm.querySelectorAll(".field-label, .checkbox-label, button[type=submit]").forEach((el) => { + el.hidden = true; + }); + showMessage(messageEl, "Tak for din bestilling! En kvittering er sendt til din email.", "success"); + } else { + showMessage(messageEl, await extractErrorDetail(response), "error"); + } + } catch (err) { + showMessage(messageEl, "Kunne ikke sende bestillingen. Tjek din internetforbindelse og prøv igen.", "error"); + } + }); + } + + const signupForm = document.getElementById("signup-form"); + if (signupForm) { + signupForm.addEventListener("submit", async function (event) { + event.preventDefault(); + const messageEl = document.getElementById("signup-message"); + const payload = { + name: signupForm.elements["name"].value, + email: signupForm.elements["email"].value, + }; + + try { + const response = await fetch("/public/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (response.ok) { + signupForm.querySelectorAll("label, button[type=submit]").forEach((el) => { + el.hidden = true; + }); + showMessage(messageEl, "Tak! Du er nu tilmeldt.", "success"); + } else { + showMessage(messageEl, await extractErrorDetail(response), "error"); + } + } catch (err) { + showMessage(messageEl, "Kunne ikke sende tilmeldingen. Tjek din internetforbindelse og prøv igen.", "error"); + } + }); + } +})(); diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..65fab14 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {{ route_name }}{% block title %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/app/templates/order.html b/app/templates/order.html new file mode 100644 index 0000000..2a73008 --- /dev/null +++ b/app/templates/order.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %} — {{ page.current_round.round_name }}{% endblock %} +{% block content %} +

{{ page.route_name }}

+

{{ page.current_round.round_name }}

+ + {% if page.current_round.intro_text %} +
{{ page.current_round.intro_text | safe }}
+ {% endif %} + + + +
+
+ + {% set ns = namespace(current_category=None) %} + {% for wine in page.current_round.wine_offerings %} + {% if wine.category.name != ns.current_category %} + + {% set ns.current_category = wine.category.name %} + {% endif %} + + + + + + + {% endfor %} +
{{ wine.category.name }}
+ + + {{ wine.name }}{% if wine.is_organic %} øko{% endif %} + {{ "%.2f"|format(wine.price) }}0.00
+
+ + +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/pickup.html b/app/templates/pickup.html new file mode 100644 index 0000000..e2b1557 --- /dev/null +++ b/app/templates/pickup.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %} — Afhentning{% endblock %} +{% block content %} +

Afhentning

+ {% if meeting_info %} +
{{ meeting_info | safe }}
+ {% else %} +

Der er endnu ikke tilføjet information om afhentning for {{ route_name }}.

+ {% endif %} +{% endblock %} diff --git a/app/templates/signup.html b/app/templates/signup.html new file mode 100644 index 0000000..ce2d1cd --- /dev/null +++ b/app/templates/signup.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %} — Tilmelding{% endblock %} +{% block content %} + +{% endblock %} +{% block scripts %} + +{% endblock %}