Opgave 8c: ny offentlig bestillings-/tilmeldingsside
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 <noreply@anthropic.com>
This commit is contained in:
parent
0653227345
commit
609c3efb05
10 changed files with 587 additions and 9 deletions
55
CLAUDE.md
55
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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
36
app/routers/public_site.py
Normal file
36
app/routers/public_site.py
Normal file
|
|
@ -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}
|
||||
)
|
||||
229
app/static/site.css
Normal file
229
app/static/site.css
Normal file
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
127
app/static/site.js
Normal file
127
app/static/site.js
Normal file
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
23
app/templates/base.html
Normal file
23
app/templates/base.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ route_name }}{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<span class="navbar-brand">{{ route_name }}</span>
|
||||
<div class="navbar-links">
|
||||
<a href="/">Bestilling</a>
|
||||
<a href="/tilmelding">Tilmelding</a>
|
||||
<a href="/afhentning">Afhentning</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
75
app/templates/order.html
Normal file
75
app/templates/order.html
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %} — {{ page.current_round.round_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<p class="route-label">{{ page.route_name }}</p>
|
||||
<h1 class="round-heading">{{ page.current_round.round_name }}</h1>
|
||||
|
||||
{% if page.current_round.intro_text %}
|
||||
<div class="intro-text">{{ page.current_round.intro_text | safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="signup-link">
|
||||
Vil du bare tilmelde dig fremtidige runder uden at bestille nu?
|
||||
<a href="/tilmelding">Tilmeld dig her</a>.
|
||||
</p>
|
||||
|
||||
<form id="order-form"
|
||||
class="order-layout"
|
||||
data-eur-dkk-rate="{{ page.current_round.eur_dkk_rate if page.current_round.eur_dkk_rate is not none else '' }}">
|
||||
<div class="catalog">
|
||||
<table class="wine-table">
|
||||
{% set ns = namespace(current_category=None) %}
|
||||
{% for wine in page.current_round.wine_offerings %}
|
||||
{% if wine.category.name != ns.current_category %}
|
||||
<tr class="category-row"><th colspan="4">{{ wine.category.name }}</th></tr>
|
||||
{% set ns.current_category = wine.category.name %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="qty-cell">
|
||||
<input type="number" min="0" step="1" value="0" inputmode="numeric"
|
||||
class="qty-input" data-offering-id="{{ wine.id }}" data-price="{{ wine.price }}">
|
||||
</td>
|
||||
<td class="wine-name-cell">
|
||||
{{ wine.name }}{% if wine.is_organic %} <span class="organic-badge" title="Økologisk">øko</span>{% endif %}
|
||||
</td>
|
||||
<td class="price-cell">{{ "%.2f"|format(wine.price) }}</td>
|
||||
<td class="row-total-cell" data-row-total-for="{{ wine.id }}">0.00</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<aside class="order-sidebar">
|
||||
<p class="total-line">Total i EUR: <span id="total-eur">0.00</span></p>
|
||||
{% if page.current_round.eur_dkk_rate %}
|
||||
<p class="total-line">Total i DKK: <span id="total-dkk">0.00</span></p>
|
||||
<p class="rate-note">1 EUR = {{ "%.2f"|format(page.current_round.eur_dkk_rate) }} KR</p>
|
||||
{% endif %}
|
||||
|
||||
<label class="field-label">Navn
|
||||
<input type="text" name="name" required>
|
||||
</label>
|
||||
|
||||
<label class="field-label">Email addresse
|
||||
<input type="email" name="email" required>
|
||||
</label>
|
||||
<p class="field-help">Vi sender en kvittering til denne email, og du vil i fremtiden blive informeret om {{ page.route_name }}!</p>
|
||||
|
||||
<label class="field-label">Telefon
|
||||
<input type="tel" name="phone" required>
|
||||
</label>
|
||||
<p class="field-help">Det er vigtigt for os at have dit mobil-nummer, så vi kan få fat på dig, i tilfælde af problemer.</p>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="is_active" checked>
|
||||
Jeg ønsker i fremtiden at modtage mails om {{ page.route_name }}!
|
||||
</label>
|
||||
|
||||
<button type="submit">Bestil nu</button>
|
||||
<div id="order-message" class="form-message" hidden></div>
|
||||
</aside>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/site.js"></script>
|
||||
{% endblock %}
|
||||
10
app/templates/pickup.html
Normal file
10
app/templates/pickup.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %} — Afhentning{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Afhentning</h1>
|
||||
{% if meeting_info %}
|
||||
<div class="prose">{{ meeting_info | safe }}</div>
|
||||
{% else %}
|
||||
<p>Der er endnu ikke tilføjet information om afhentning for {{ route_name }}.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
26
app/templates/signup.html
Normal file
26
app/templates/signup.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %} — Tilmelding{% endblock %}
|
||||
{% block content %}
|
||||
<div class="signup-card">
|
||||
<h1>Tilmeld dig {{ route_name }}</h1>
|
||||
{% if no_round_open %}
|
||||
<p>Der er ingen åben bestillingsrunde lige nu. Tilmeld dig herunder, så får du besked når næste runde åbner!</p>
|
||||
{% else %}
|
||||
<p>Tilmeld dig for at blive informeret om fremtidige runder hos {{ route_name }}, uden at bestille nu.</p>
|
||||
{% endif %}
|
||||
|
||||
<form id="signup-form">
|
||||
<label class="field-label">Navn
|
||||
<input type="text" name="name" required>
|
||||
</label>
|
||||
<label class="field-label">Email
|
||||
<input type="email" name="email" required>
|
||||
</label>
|
||||
<button type="submit">Tilmeld</button>
|
||||
<div id="signup-message" class="form-message" hidden></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/site.js"></script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Reference in a new issue