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:
Carsten Gram 2026-09-28 22:55:55 +02:00
parent 0653227345
commit 609c3efb05
10 changed files with 587 additions and 9 deletions

View file

@ -18,8 +18,10 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
## Tech stack ## Tech stack
- Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations) - Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations)
- Frontend: React (admin-UI) + eksisterende offentlige bestillingsside - Frontend: React (admin-UI, ikke bygget endnu) + den offentlige
(fortsat intet login for deltagere) 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` - Mail: Postal (selvhostet), `postal.carsteng.dk`, domæne `vinindkoeb.dk`
- Auth: JWT (`Authorization: Bearer`), 2 timers levetid. Superadmin- - Auth: JWT (`Authorization: Bearer`), 2 timers levetid. Superadmin-
handlinger kræver derudover en kortlivet (5 min) "eleveret" session 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), - `postal_message_id`/`postal_token` (til korrelation med webhook),
`error_message`, `sent_at`, `opened_at`, `clicked_at` `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 ## Mail-events
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
(opgave 7a-c): skabelon pr. rute (`MailTemplate`, (opgave 7a-c): skabelon pr. rute (`MailTemplate`,
@ -148,7 +179,7 @@ endnu.
## Fase 1 — nuværende scope ## 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 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki) 2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, 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 → dækker både "ingen runde åben → vis tilmelding" og "runde åben →
link til tilmelding uden bestilling" (selve linket/UI-skiftet er link til tilmelding uden bestilling" (selve linket/UI-skiftet er
en 8c-detalje). en 8c-detalje).
- **8c**: Selve den nye offentlige frontend — brugeren har ikke - **8c**: ✅ Selve den nye offentlige frontend — se "Den offentlige
adgang til den gamle frontends kode (hårdt koblet til det gamle side" ovenfor. Server-renderet af FastAPI (Jinja2 + vanilla
API), så en ny bygges. Egen stack-beslutning, ikke taget endnu. 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:** **Resterende opgaver:**
9. Admin-ordreoversigt pr. runde med "markér betalt" → trigger mail #3 9. Admin-ordreoversigt pr. runde med "markér betalt" → trigger mail #3
@ -196,3 +233,9 @@ endnu.
- Passkey-login til admin - Passkey-login til admin
- Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig) - Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig)
- sold_only_by_case-flag + validering, hvis det bliver relevant - 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.

View file

@ -1,4 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text from sqlalchemy import text
from app.db import SessionDep from app.db import SessionDep
@ -8,6 +9,7 @@ from app.routers import (
mail_templates, mail_templates,
participants, participants,
public_orders, public_orders,
public_site,
purchase_rounds, purchase_rounds,
webhooks, webhooks,
wine_categories, wine_categories,
@ -15,6 +17,7 @@ from app.routers import (
) )
app = FastAPI(title="Vinindkøb Admin API") app = FastAPI(title="Vinindkøb Admin API")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(participants.router) app.include_router(participants.router)
app.include_router(purchase_rounds.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(mail_logs.router)
app.include_router(webhooks.router) app.include_router(webhooks.router)
app.include_router(public_orders.router) app.include_router(public_orders.router)
app.include_router(public_site.router)
@app.get("/health") @app.get("/health")

View file

@ -178,9 +178,7 @@ def _signup_participant(session: Session, route_id: int, data: PublicSignupInput
return participant return participant
@router.get("/current-round", response_model=PublicPageInfo) def _build_page_info(session: Session, route: Route) -> PublicPageInfo:
def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo:
route = get_route_from_domain(request, session)
purchase_round = _get_open_round(session, route.id) purchase_round = _get_open_round(session, route.id)
if purchase_round is None: if purchase_round is None:
return PublicPageInfo(route_name=route.name, meeting_info=route.meeting_info, current_round=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) .where(WineOffering.purchase_round_id == purchase_round.id)
.options(selectinload(WineOffering.category)) .options(selectinload(WineOffering.category))
).all() ).all()
offerings = sorted(offerings, key=lambda o: (o.category.sort_order, o.id))
return PublicPageInfo( return PublicPageInfo(
route_name=route.name, 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) @router.post("/signup", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED)
def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant: def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant:
route = get_route_from_domain(request, session) route = get_route_from_domain(request, session)

View 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
View 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
View 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
View 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
View 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
View 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
View 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 %}