This commit is contained in:
2026-03-05 21:45:40 +00:00
parent 39c80a0253
commit 146a9bda99
16 changed files with 956 additions and 24 deletions

View File

@@ -10,4 +10,4 @@ RUN uv sync --frozen --no-dev
COPY . . COPY . .
EXPOSE 8000 EXPOSE 8000
CMD ["uv", "run", "gunicorn", "-b", "0.0.0.0:8000", "--timeout", "300", "--graceful-timeout", "30", "--keep-alive", "5", "app:app"] CMD ["uv", "run", "gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--timeout", "300", "--graceful-timeout", "30", "--keep-alive", "5", "--error-logfile", "-", "--access-logfile", "-", "app:app"]

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from flask import ( from flask import (
@@ -209,11 +210,65 @@ DEFAULT_INVITATION_GROUPS = [
] ]
AGE_REQUIRED_NAMES = {"Lena", "Finn", "Fin", "Bruno"} AGE_REQUIRED_NAMES = {"Lena", "Finn", "Fin", "Bruno"}
HOTEL_RECOMMENDATIONS = [
{
"id": "parkhotel_sonnenberg",
"name": "Parkhotel Sonnenberg",
"address": "Friedrichstraße 65, 65343 Eltville am Rhein",
"website_url": "https://parkhotel-sonnenberg.com/",
"drive_minutes": "5",
"walk_minutes": "27",
},
{
"id": "hotel_frankenbach",
"name": "Hotel Frankenbach",
"address": "Wilhelmstraße 13, 65343 Eltville am Rhein",
"website_url": "https://www.hotel-frankenbach.de/",
"drive_minutes": "5",
"walk_minutes": "19",
},
{
"id": "spritzenhaus",
"name": "Das Spritzenhaus",
"address": "Platz von Montrichard 1, 65343 Eltville am Rhein",
"website_url": "https://www.das-spritzenhaus.de/",
"drive_minutes": "5",
"walk_minutes": "22",
},
{
"id": "kronenschloesschen",
"name": "Hotel Kronenschlösschen",
"address": "Rheinallee, 65347 Eltville-Hattenheim",
"website_url": "https://www.kronenschloesschen.de/",
"drive_minutes": "6",
"walk_minutes": "63",
},
{
"id": "kloster_eberbach",
"name": "Kloster Eberbach",
"address": "Kloster-Eberbach-Straße 1, 65346 Eltville am Rhein",
"website_url": "https://www.kloster-eberbach.de/",
"drive_minutes": "10",
"walk_minutes": "69",
},
{
"id": "weinhaus_engel",
"name": "Weinhaus & Hotel Engel",
"address": "Hauptstraße 10, 65345 Eltville-Rauenthal",
"website_url": "https://weinhaus-engel.com/rauenthal/",
"drive_minutes": "10",
"walk_minutes": "49",
},
]
TEXTS = { TEXTS = {
"de": { "de": {
"brand": "Svenja & Dominic", "brand": "Svenja & Dominic",
"subtitle": "Willkommen zu unserer Hochzeits-App", "subtitle": "Willkommen in unserer Hochzeits-App",
"login_note": "Passwortgeschützter Zugriff für unsere Gäste.", "login_note": "Passwortgeschützter Zugriff für unsere Gäste.",
"login_privacy_title": "Datenschutzhinweis",
"login_privacy_text": "Beim Anzeigen eingebetteter Karten werden Daten an Google übertragen. Details findet ihr in der Datenschutzerklärung.",
"login_privacy_accept": "Verstanden",
"login": "Login", "login": "Login",
"group_name": "Benutzername", "group_name": "Benutzername",
"group_password": "Passwort", "group_password": "Passwort",
@@ -247,17 +302,59 @@ TEXTS = {
"location": "Location", "location": "Location",
"gifts": "Geschenke", "gifts": "Geschenke",
"visit_location": "Zur Location-Webseite", "visit_location": "Zur Location-Webseite",
"location_story_title": "Klostermühle in den Weinbergen",
"location_story_text": "Die Klostermühle liegt mitten in den Weinbergen und verbindet ein besonderes Weingut-Ambiente mit saisonaler Küche. Euch erwartet eine entspannte Atmosphäre zwischen Natur, gutem Essen und einem wunderschönen Rahmen für unseren Hochzeitstag.",
"route_from_current": "Route ab aktuellem Standort",
"maps_privacy_notice": "Zur Anzeige der Karte werden Daten an Google übertragen.", "maps_privacy_notice": "Zur Anzeige der Karte werden Daten an Google übertragen.",
"maps_load_button": "Google Maps anzeigen", "maps_load_button": "Google Maps anzeigen",
"privacy": "Datenschutz", "privacy": "Datenschutz",
"imprint": "Impressum", "imprint": "Impressum",
"hero_headline": "Willkommen zu unserer Hochzeit", "hero_headline": "Willkommen zu unserer Hochzeit",
"hero_text": "Wir freuen uns riesig, diesen besonderen Tag mit euch zu feiern.", "hero_text": "Wir freuen uns riesig, diesen besonderen Tag am 04.09.26 zu feiern.",
"hero_headline_with_group": "Willkommen zu unserer Hochzeit, {name}",
"hero_text_single": "Wir freuen uns riesig, diesen besonderen Tag am 04.09.26 mit dir zu feiern.",
"hero_text_group": "Wir freuen uns riesig, diesen besonderen Tag am 04.09.26 mit euch zu feiern.",
"hero_app_hint": "In dieser Webapp könnt ihr Zu- oder Absagen, Fotos hochladen, die Galerie ansehen und alle wichtigen Infos rund um den Tag finden. Bitte gebt eure Zu- oder Absage möglichst bald ab.",
"to_guest_area": "Zum Gästebereich", "to_guest_area": "Zum Gästebereich",
"schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.", "schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.",
"schedule_intro": "Hier findet ihr den Ablauf für unseren Hochzeitstag.",
"schedule_arrival_title": "Ankunft",
"schedule_arrival_text": "Wir freuen uns auf eure Ankunft.",
"schedule_ceremony_title": "Trauzeremonie",
"schedule_ceremony_text": "Trauzeremonie im Rosengarten und Fotos.",
"schedule_reception_title": "Sektempfang & Häppchen",
"schedule_reception_text": "Sektempfang im Hof der Klostermühle.",
"schedule_buffet_title": "Buffet-Eröffnung",
"schedule_buffet_text": "Das Buffet wird eröffnet, danach folgen die Brautpaarspiele. Später gibt es zusätzlich Käseplatten.",
"schedule_cake_title": "Tortenanschnitt",
"schedule_cake_text": "Gemeinsamer Tortenmoment am Abend.",
"schedule_afterwards_time": "Anschließend",
"schedule_party_title": "Party",
"schedule_party_text": "Wir feiern gemeinsam bis 03:00 Uhr.",
"hotels_text": "Empfehlungen folgen. Bitte frühzeitig buchen.", "hotels_text": "Empfehlungen folgen. Bitte frühzeitig buchen.",
"taxi_text": "Taxi-Service: 01234 / 567890 (24/7).", "hotels_intro": "Hier findet ihr Hotels in der Nähe. Bitte bucht frühzeitig und schaut euch die aktuellen Preise direkt auf den Hotel-Webseiten an.",
"gifts_text": "Eure Anwesenheit ist unser schönstes Geschenk. Falls ihr uns trotzdem etwas schenken möchtet, freuen wir uns über einen Beitrag zu unserer Reise nach der Hochzeit.", "hotel_visit_website": "Zur Hotel-Webseite",
"hotel_route_drive": "Route zur Location (Auto)",
"hotel_route_walk": "Route zur Location (zu Fuß)",
"hotel_time_note": "Genaue Fahr- und Gehzeit seht ihr live in Google Maps.",
"hotel_route_modal_title": "Route zur Location",
"hotel_route_modal_close": "Karte schließen",
"hotel_route_open_maps": "In Google Maps öffnen",
"hotel_drive_badge": "Auto ca. {minutes} Min",
"hotel_walk_badge": "Zu Fuß ca. {minutes} Min",
"hotel_parkhotel_sonnenberg_desc": "Ruhiges Hotel mit Blick über Eltville.",
"hotel_hotel_frankenbach_desc": "Zentral in Eltville, gute Anbindung zur Location.",
"hotel_spritzenhaus_desc": "Historisches Haus am Rhein mit modernen Zimmern.",
"hotel_kronenschloesschen_desc": "Elegantes Hotel in Hattenheim, nah an den Weinbergen.",
"hotel_kloster_eberbach_desc": "Besonderes Ambiente am Kloster mit kurzer Fahrzeit.",
"hotel_weinhaus_engel_desc": "Gemütliches Weinhaus-Hotel in Rauenthal.",
"taxi_text": "An einem Taxiservice arbeiten wir noch.",
"taxi_sticker_alt": "Bauarbeiter-Sticker: Arbeit in Progress",
"gifts_teaser": "Wir würden uns über Geschenke sehr freuen...",
"gifts_reveal_button": "Wunsch aufdecken",
"gifts_image_alt": "Ein großer Haufen Geldscheine",
"gifts_caption": "Trommelwirbel... in unserer 70-Quadratmeter-Wohnung ist leider kein Platz mehr für Materielles.",
"gifts_text": "Also: Money! Wir freuen uns über einen finanziellen Beitrag zu unserer Reise nach der Hochzeit.",
"gallery_uploaded_by": "von {name}", "gallery_uploaded_by": "von {name}",
"gallery_empty": "Noch keine Bilder vorhanden.", "gallery_empty": "Noch keine Bilder vorhanden.",
"gallery_image_alt": "Upload von {name}", "gallery_image_alt": "Upload von {name}",
@@ -305,6 +402,9 @@ TEXTS = {
"brand": "Svenja & Dominic", "brand": "Svenja & Dominic",
"subtitle": "Welcome to our wedding app", "subtitle": "Welcome to our wedding app",
"login_note": "Password-protected access for our guests.", "login_note": "Password-protected access for our guests.",
"login_privacy_title": "Privacy notice",
"login_privacy_text": "When embedded maps are displayed, data is transferred to Google. Details are available in the privacy policy.",
"login_privacy_accept": "Got it",
"login": "Login", "login": "Login",
"group_name": "Username", "group_name": "Username",
"group_password": "Password", "group_password": "Password",
@@ -338,17 +438,59 @@ TEXTS = {
"location": "Location", "location": "Location",
"gifts": "Gifts", "gifts": "Gifts",
"visit_location": "Visit location website", "visit_location": "Visit location website",
"location_story_title": "Klostermuehle in the vineyards",
"location_story_text": "Klostermuehle is surrounded by vineyards and combines a unique winery atmosphere with seasonal cuisine. Expect a relaxed setting with nature, great food, and a beautiful backdrop for our wedding day.",
"route_from_current": "Route from current location",
"maps_privacy_notice": "To display the map, data will be transferred to Google.", "maps_privacy_notice": "To display the map, data will be transferred to Google.",
"maps_load_button": "Show Google Maps", "maps_load_button": "Show Google Maps",
"privacy": "Privacy", "privacy": "Privacy",
"imprint": "Imprint", "imprint": "Imprint",
"hero_headline": "Welcome to our wedding", "hero_headline": "Welcome to our wedding",
"hero_text": "We are so excited to celebrate this special day with you.", "hero_text": "We are incredibly excited to celebrate this special day on 09/04/26.",
"hero_headline_with_group": "Welcome to our wedding, {name}",
"hero_text_single": "We are incredibly excited to celebrate this special day on 09/04/26 with you.",
"hero_text_group": "We are incredibly excited to celebrate this special day on 09/04/26 with all of you.",
"hero_app_hint": "In this web app, you can send your RSVP, upload photos, view the gallery, and find all important details for the day. Please submit your RSVP soon.",
"to_guest_area": "Open guest area", "to_guest_area": "Open guest area",
"schedule_text": "3:00 PM ceremony, 5:00 PM reception, 7:00 PM dinner.", "schedule_text": "3:00 PM ceremony, 5:00 PM reception, 7:00 PM dinner.",
"schedule_intro": "Here is the schedule for our wedding day.",
"schedule_arrival_title": "Arrival",
"schedule_arrival_text": "We look forward to welcoming you.",
"schedule_ceremony_title": "Wedding Ceremony",
"schedule_ceremony_text": "Wedding ceremony in the rose garden and photos.",
"schedule_reception_title": "Sparkling Reception & Snacks",
"schedule_reception_text": "Sparkling reception in the courtyard of the Klostermuehle.",
"schedule_buffet_title": "Buffet Opening",
"schedule_buffet_text": "The buffet opens, followed by wedding games. Cheese platters will be served later.",
"schedule_cake_title": "Cake Cutting",
"schedule_cake_text": "Cake cutting together in the evening.",
"schedule_afterwards_time": "Afterwards",
"schedule_party_title": "Party",
"schedule_party_text": "We celebrate together until 3:00 AM.",
"hotels_text": "Recommendations will follow. Please book early.", "hotels_text": "Recommendations will follow. Please book early.",
"taxi_text": "Taxi service: 01234 / 567890 (24/7).", "hotels_intro": "Here are hotel options nearby. Please book early and check current rates directly on each hotel website.",
"gifts_text": "Your presence is the greatest gift to us. If you would still like to give something, we would appreciate a contribution to our post-wedding trip.", "hotel_visit_website": "Visit hotel website",
"hotel_route_drive": "Route to location (car)",
"hotel_route_walk": "Route to location (walk)",
"hotel_time_note": "Exact driving and walking times are shown live in Google Maps.",
"hotel_route_modal_title": "Route to location",
"hotel_route_modal_close": "Close map",
"hotel_route_open_maps": "Open in Google Maps",
"hotel_drive_badge": "By car about {minutes} min",
"hotel_walk_badge": "On foot about {minutes} min",
"hotel_parkhotel_sonnenberg_desc": "Quiet hotel with a view over Eltville.",
"hotel_hotel_frankenbach_desc": "Central in Eltville with good access to the venue.",
"hotel_spritzenhaus_desc": "Historic house by the Rhine with modern rooms.",
"hotel_kronenschloesschen_desc": "Elegant hotel in Hattenheim near the vineyards.",
"hotel_kloster_eberbach_desc": "Unique monastery setting with a short drive to the venue.",
"hotel_weinhaus_engel_desc": "Cozy winehouse hotel in Rauenthal.",
"taxi_text": "We are still working on a taxi service.",
"taxi_sticker_alt": "Construction worker sticker: Work in progress",
"gifts_teaser": "We would be very happy to receive gifts...",
"gifts_reveal_button": "Reveal wish",
"gifts_image_alt": "A big pile of cash",
"gifts_caption": "Drum roll... in our 70-square-meter apartment, we sadly have no room left for material things.",
"gifts_text": "So: Money! We would be very happy about a financial contribution to our trip after the wedding.",
"gallery_uploaded_by": "by {name}", "gallery_uploaded_by": "by {name}",
"gallery_empty": "No photos available yet.", "gallery_empty": "No photos available yet.",
"gallery_image_alt": "Uploaded by {name}", "gallery_image_alt": "Uploaded by {name}",
@@ -463,6 +605,62 @@ def get_wedding_countdown_iso() -> str:
return parsed_local.replace(tzinfo=tz).isoformat() return parsed_local.replace(tzinfo=tz).isoformat()
def build_google_maps_directions(origin: str, travel_mode: str) -> str:
query = urlencode(
{
"api": "1",
"origin": origin,
"destination": app.config["LOCATION_ADDRESS"],
"travelmode": travel_mode,
}
)
return f"https://www.google.com/maps/dir/?{query}"
def build_google_maps_destination(destination: str, travel_mode: str) -> str:
query = urlencode(
{
"api": "1",
"destination": destination,
"travelmode": travel_mode,
}
)
return f"https://www.google.com/maps/dir/?{query}"
def build_google_maps_route_embed(origin: str, travel_mode: str) -> str:
mode_flag = "d" if travel_mode == "driving" else "w"
query = urlencode(
{
"saddr": origin,
"daddr": app.config["LOCATION_ADDRESS"],
"dirflg": mode_flag,
"output": "embed",
}
)
return f"https://www.google.com/maps?{query}"
def get_hotels_for_page() -> list[dict]:
hotels = []
for entry in HOTEL_RECOMMENDATIONS:
hotels.append(
{
"name": entry["name"],
"address": entry["address"],
"website_url": entry["website_url"],
"description": t(f"hotel_{entry['id']}_desc"),
"drive_badge": t("hotel_drive_badge").format(minutes=entry["drive_minutes"]),
"walk_badge": t("hotel_walk_badge").format(minutes=entry["walk_minutes"]),
"drive_route_url": build_google_maps_directions(entry["address"], "driving"),
"walk_route_url": build_google_maps_directions(entry["address"], "walking"),
"drive_route_embed_url": build_google_maps_route_embed(entry["address"], "driving"),
"walk_route_embed_url": build_google_maps_route_embed(entry["address"], "walking"),
}
)
return hotels
@app.context_processor @app.context_processor
def inject_common() -> dict: def inject_common() -> dict:
role = session.get("role", "guest") role = session.get("role", "guest")
@@ -479,6 +677,7 @@ def inject_common() -> dict:
"location_name": app.config["LOCATION_NAME"], "location_name": app.config["LOCATION_NAME"],
"location_address": app.config["LOCATION_ADDRESS"], "location_address": app.config["LOCATION_ADDRESS"],
"location_website_url": app.config["LOCATION_WEBSITE_URL"], "location_website_url": app.config["LOCATION_WEBSITE_URL"],
"location_route_url": build_google_maps_destination(app.config["LOCATION_ADDRESS"], "driving"),
"google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"], "google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"],
"wedding_date": app.config["WEDDING_DATE"], "wedding_date": app.config["WEDDING_DATE"],
"wedding_countdown_iso": get_wedding_countdown_iso(), "wedding_countdown_iso": get_wedding_countdown_iso(),
@@ -693,7 +892,21 @@ def set_lang(code: str):
@app.get("/welcome") @app.get("/welcome")
@login_required @login_required
def welcome(): def welcome():
return render_template("welcome.html") db = get_db()
member_count_row = db.execute(
"SELECT COUNT(*) AS c FROM group_members WHERE group_id = ?",
(int(session["group_id"]),),
).fetchone()
member_count = int((member_count_row["c"] if member_count_row else 0) or 0)
is_single_guest = member_count <= 1
welcome_headline = t("hero_headline_with_group").format(name=session.get("group_name", ""))
welcome_text = t("hero_text_single") if is_single_guest else t("hero_text_group")
return render_template(
"welcome.html",
welcome_headline=welcome_headline,
welcome_text=welcome_text,
welcome_hint=t("hero_app_hint"),
)
@app.get("/gaestebereich") @app.get("/gaestebereich")
@@ -918,7 +1131,8 @@ def info(page: str):
return redirect(url_for("guest_area")) return redirect(url_for("guest_area"))
if page == "gifts" and session.get("role") == "admin": if page == "gifts" and session.get("role") == "admin":
return redirect(url_for("guest_area")) return redirect(url_for("guest_area"))
return render_template("info.html", page=page) hotels = get_hotels_for_page() if page == "hotels" else []
return render_template("info.html", page=page, hotels=hotels)
@app.get("/datenschutz") @app.get("/datenschutz")

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 640" role="img" aria-labelledby="title desc">
<title id="title">Pile of money</title>
<desc id="desc">Stylized stack of money bundles.</desc>
<defs>
<linearGradient id="bg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#224234"/>
<stop offset="100%" stop-color="#173127"/>
</linearGradient>
<linearGradient id="bill" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#d4f2c2"/>
<stop offset="100%" stop-color="#9cd18f"/>
</linearGradient>
</defs>
<rect width="960" height="640" rx="36" fill="url(#bg)"/>
<g opacity="0.15">
<circle cx="130" cy="120" r="120" fill="#fff"/>
<circle cx="850" cy="80" r="100" fill="#fff"/>
</g>
<g transform="translate(120 180)">
<g transform="translate(0 220)">
<rect x="0" y="0" width="720" height="140" rx="18" fill="url(#bill)"/>
<rect x="285" y="0" width="150" height="140" fill="#6ca95f"/>
<circle cx="360" cy="70" r="34" fill="#e8f9db"/>
<text x="360" y="82" text-anchor="middle" font-size="36" font-family="Arial, sans-serif" fill="#2f5e35">$</text>
</g>
<g transform="translate(45 130)">
<rect x="0" y="0" width="640" height="130" rx="18" fill="url(#bill)"/>
<rect x="255" y="0" width="130" height="130" fill="#6ca95f"/>
<circle cx="320" cy="65" r="30" fill="#e8f9db"/>
<text x="320" y="76" text-anchor="middle" font-size="32" font-family="Arial, sans-serif" fill="#2f5e35">$</text>
</g>
<g transform="translate(110 45)">
<rect x="0" y="0" width="560" height="120" rx="16" fill="url(#bill)"/>
<rect x="225" y="0" width="110" height="120" fill="#6ca95f"/>
<circle cx="280" cy="60" r="27" fill="#e8f9db"/>
<text x="280" y="69" text-anchor="middle" font-size="28" font-family="Arial, sans-serif" fill="#2f5e35">$</text>
</g>
</g>
<text x="480" y="90" text-anchor="middle" font-size="54" font-family="Arial, sans-serif" fill="#f0db8e">Gift Fund</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -101,6 +101,7 @@ h3 {
width: 100%; width: 100%;
padding: 1.1rem; padding: 1.1rem;
color: #fff; color: #fff;
text-align: center;
} }
.hero-overlay h1, .hero-overlay h1,
@@ -120,6 +121,22 @@ h3 {
margin-top: 0.6rem; margin-top: 0.6rem;
} }
.hero-hint {
margin-top: 0.8rem;
max-width: 58ch;
font-size: clamp(0.94rem, 1.55vw, 1.05rem);
color: rgba(255, 255, 255, 0.93);
}
.hero-hint-below {
margin: 0.2rem 0 1rem;
max-width: 72ch;
text-align: center;
color: rgba(31, 26, 23, 0.92);
font-size: clamp(0.97rem, 1.45vw, 1.05rem);
line-height: 1.45;
}
.card { .card {
background: var(--card); background: var(--card);
border: 1px solid rgba(31, 58, 47, 0.11); border: 1px solid rgba(31, 58, 47, 0.11);
@@ -131,6 +148,13 @@ h3 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Center standalone copy blocks while keeping forms and tables unaffected */
section.card > h1,
section.card > h2,
section.card > p {
text-align: center;
}
.hero { .hero {
background: background:
radial-gradient(circle at 12% 16%, rgba(184, 145, 76, 0.16), transparent 44%), radial-gradient(circle at 12% 16%, rgba(184, 145, 76, 0.16), transparent 44%),
@@ -224,6 +248,10 @@ h3 {
padding: clamp(1.2rem, 2.6vw, 1.7rem); padding: clamp(1.2rem, 2.6vw, 1.7rem);
} }
.login-intro {
text-align: center;
}
.login-intro h1 { .login-intro h1 {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-size: clamp(2rem, 4.2vw, 3.3rem); font-size: clamp(2rem, 4.2vw, 3.3rem);
@@ -294,6 +322,48 @@ h3 {
0 1px 0 rgba(178, 137, 70, 0.4) inset; 0 1px 0 rgba(178, 137, 70, 0.4) inset;
} }
.login-privacy-banner {
position: fixed;
left: 50%;
bottom: 0.9rem;
transform: translateX(-50%);
width: min(760px, calc(100vw - 1.2rem));
z-index: 1250;
border: 1px solid rgba(31, 58, 47, 0.16);
border-radius: 14px;
padding: 0.8rem 0.85rem;
background: rgba(255, 252, 247, 0.98);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(4px);
}
.login-privacy-title {
margin: 0;
color: var(--forest);
font-weight: 700;
font-size: 0.95rem;
}
.login-privacy-text {
margin: 0.32rem 0 0;
font-size: 0.9rem;
line-height: 1.35;
color: rgba(31, 26, 23, 0.92);
}
.login-privacy-text a {
color: var(--forest);
font-weight: 700;
}
.login-privacy-btn {
margin-top: 0.6rem;
width: auto;
min-width: 10rem;
border-radius: 999px;
padding: 0.6rem 1.05rem;
}
.form-grid { .form-grid {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
@@ -902,6 +972,322 @@ input[type="file"]:focus {
margin: 0.8rem 0; margin: 0.8rem 0;
} }
.schedule-timeline {
display: grid;
gap: 0.85rem;
margin-top: 0.9rem;
}
.schedule-item {
position: relative;
overflow: hidden;
border-radius: 14px;
padding: 1rem;
border: 1px solid rgba(31, 58, 47, 0.12);
background-color: #f5ece0;
background-image:
linear-gradient(180deg, rgba(22, 34, 28, 0.5), rgba(22, 34, 28, 0.28)),
var(--schedule-card-image);
background-size: cover;
background-position: var(--schedule-card-position, center);
color: #fff;
box-shadow:
0 10px 24px rgba(31, 58, 47, 0.15),
0 1px 0 rgba(255, 255, 255, 0.25) inset;
}
.schedule-time {
margin: 0 0 0.2rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 247, 230, 0.95);
}
.schedule-item h2 {
margin: 0;
font-size: 1.28rem;
line-height: 1.15;
color: #fff;
}
.schedule-item p {
margin: 0.45rem 0 0;
max-width: 42ch;
color: rgba(255, 255, 255, 0.95);
}
.hotel-list {
display: grid;
gap: 0.9rem;
margin-top: 0.8rem;
}
.hotel-card {
border-radius: 14px;
border: 1px solid rgba(31, 58, 47, 0.12);
padding: 1rem;
text-align: center;
background: linear-gradient(180deg, #fffdf8 0%, #fdf5e8 100%);
box-shadow:
0 10px 22px rgba(31, 58, 47, 0.12),
0 1px 0 rgba(178, 137, 70, 0.2) inset;
}
.hotel-card h2 {
margin: 0;
font-size: 1.25rem;
color: var(--forest);
}
.hotel-address {
margin: 0.32rem 0 0;
color: rgba(31, 58, 47, 0.84);
font-size: 0.95rem;
}
.hotel-card p {
margin: 0.52rem 0 0;
}
.hotel-badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.62rem;
justify-content: center;
}
.hotel-badges span {
border-radius: 999px;
border: 1px solid rgba(31, 58, 47, 0.18);
background: rgba(255, 255, 255, 0.9);
padding: 0.26rem 0.62rem;
font-size: 0.86rem;
color: rgba(31, 58, 47, 0.9);
}
.hotel-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 0.55rem;
margin-top: 0.8rem;
justify-items: center;
}
.hotel-actions .btn {
width: min(100%, 320px);
border-radius: 999px;
padding: 0.74rem 1.2rem;
}
body.has-route-modal {
overflow: hidden;
}
.route-modal[hidden] {
display: none;
}
.route-modal {
position: fixed;
inset: 0;
z-index: 1300;
display: grid;
place-items: center;
padding: 1rem;
}
.route-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(18, 28, 23, 0.62);
backdrop-filter: blur(2px);
}
.route-modal-panel {
position: relative;
width: min(980px, 96vw);
border-radius: 16px;
overflow: hidden;
background: #fffdf8;
border: 1px solid rgba(31, 58, 47, 0.18);
box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28);
}
.route-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.75rem 0.85rem;
border-bottom: 1px solid rgba(31, 58, 47, 0.12);
background: linear-gradient(180deg, #fff, #fdf4e6);
}
.route-modal-head h2 {
margin: 0;
font-size: 1.04rem;
color: var(--forest);
}
.route-modal-close {
white-space: nowrap;
font-size: 0.85rem;
padding: 0.42rem 0.76rem;
border-radius: 999px;
}
.route-modal-close.btn-ghost {
background: rgba(255, 255, 255, 0.56);
border-color: rgba(31, 58, 47, 0.2);
box-shadow: none;
}
.route-modal-body {
padding: 0.65rem;
position: relative;
}
.route-modal-body iframe {
display: block;
width: 100%;
min-height: min(72vh, 640px);
border: 0;
border-radius: 12px;
}
.route-map-actions {
position: absolute;
right: 1.15rem;
bottom: 1.15rem;
}
.route-open-btn {
min-width: clamp(220px, 38vw, 320px);
border-radius: 999px;
padding: 0.78rem 1.25rem;
box-shadow: 0 10px 22px rgba(31, 58, 47, 0.24);
}
.taxi-coming-soon {
margin: 0.3rem auto 0.9rem;
max-width: min(460px, 100%);
}
.taxi-coming-soon img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
filter: drop-shadow(0 10px 22px rgba(31, 58, 47, 0.18));
}
.gift-fun {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(31, 58, 47, 0.12);
padding: 1rem;
background: linear-gradient(155deg, #fff8eb 0%, #f3e6cc 100%);
}
.gift-lead {
margin: 0;
font-size: clamp(1.02rem, 2.1vw, 1.2rem);
font-weight: 700;
}
.gift-reveal-btn {
margin-top: 0.85rem;
width: auto;
}
.gift-reveal {
margin-top: 0.95rem;
position: relative;
padding: 0.9rem;
border-radius: 12px;
border: 1px solid rgba(31, 58, 47, 0.15);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.94));
}
.gift-reveal.is-active {
animation: gift-pop-in 420ms cubic-bezier(0.2, 0.75, 0.2, 1) both;
}
.gift-image-wrap {
max-width: min(640px, 100%);
margin: 0 auto;
}
.gift-image-wrap img {
display: block;
width: 100%;
height: auto;
border-radius: 12px;
border: 1px solid rgba(31, 58, 47, 0.2);
box-shadow: 0 12px 28px rgba(31, 58, 47, 0.18);
}
.gift-caption {
margin: 0.8rem 0 0;
font-weight: 700;
color: var(--forest);
}
.money-rain {
pointer-events: none;
position: absolute;
inset: -6px;
overflow: hidden;
}
.bill {
position: absolute;
width: 26px;
height: 12px;
top: -20px;
left: calc((var(--idx) * 8.2%) - 2%);
border-radius: 2px;
border: 1px solid rgba(43, 107, 49, 0.46);
background:
radial-gradient(circle at 50% 50%, rgba(247, 255, 240, 0.92) 0 28%, transparent 30%),
linear-gradient(180deg, #d7f7bd 0%, #9fd58f 100%);
opacity: 0;
}
.gift-reveal.is-active .bill {
animation: money-fall 4200ms linear forwards;
animation-delay: calc(var(--idx) * 120ms);
}
@keyframes gift-pop-in {
0% {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes money-fall {
0% {
opacity: 0;
transform: translate3d(0, -12px, 0) rotate(0deg);
}
10% {
opacity: 0.95;
}
100% {
opacity: 0;
transform: translate3d(-22px, 240px, 0) rotate(30deg);
}
}
.map-consent { .map-consent {
display: grid; display: grid;
gap: 0.7rem; gap: 0.7rem;
@@ -953,23 +1339,24 @@ input[type="file"]:focus {
} }
.map-embed-target:not(:empty) { .map-embed-target:not(:empty) {
margin-top: 0.2rem; margin-top: 0.35rem;
} }
.location-actions { .location-actions {
display: flex; display: flex;
justify-content: flex-start; justify-content: center;
margin-top: 0.55rem; margin-top: 0.55rem;
gap: 0.55rem; gap: 0.55rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.location-actions .btn { .location-actions .btn {
width: auto; width: min(100%, 320px);
min-width: 0; min-width: 240px;
white-space: nowrap; white-space: nowrap;
justify-content: center; justify-content: center;
padding: 0.58rem 0.88rem; padding: 0.72rem 1.2rem;
border-radius: 999px;
font-size: 0.98rem; font-size: 0.98rem;
} }
@@ -988,8 +1375,19 @@ input[type="file"]:focus {
font-size: 0.9rem; font-size: 0.9rem;
} }
.login-privacy-banner {
width: calc(100vw - 0.8rem);
bottom: 0.35rem;
border-radius: 12px;
padding: 0.7rem;
}
.login-privacy-btn {
width: 100%;
}
.location-actions { .location-actions {
justify-content: flex-start; justify-content: center;
} }
.dashboard-grid { .dashboard-grid {
@@ -1000,6 +1398,56 @@ input[type="file"]:focus {
min-height: 0; min-height: 0;
font-size: 1.08rem; font-size: 1.08rem;
} }
.schedule-item {
padding: 0.9rem;
}
.schedule-item h2 {
font-size: 1.18rem;
}
.gift-reveal {
padding: 0.75rem;
}
.route-modal {
padding: 0.6rem;
}
.route-modal-head {
align-items: center;
flex-direction: row;
}
.route-modal-close {
width: auto;
align-self: center;
}
.route-modal-body iframe {
min-height: 58vh;
}
.route-map-actions {
left: 1.15rem;
right: 1.15rem;
}
.route-open-btn {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.gift-reveal.is-active {
animation: none;
}
.gift-reveal.is-active .bill {
animation: none;
opacity: 0;
}
} }
@media (max-width: 380px) { @media (max-width: 380px) {

View File

@@ -10,18 +10,188 @@
</h1> </h1>
{% if page == 'schedule' %} {% if page == 'schedule' %}
<p>{{ t('schedule_text') }}</p> <p>{{ t('schedule_intro') }}</p>
<div class="schedule-timeline">
<article class="schedule-item" style="--schedule-card-image: url('{{ url_for('static', filename='assets/ankunft.png') }}'); --schedule-card-position: center 18%;">
<p class="schedule-time">15:00</p>
<h2>{{ t('schedule_arrival_title') }}</h2>
<p>{{ t('schedule_arrival_text') }}</p>
</article>
<article class="schedule-item" style="--schedule-card-image: url('{{ url_for('static', filename='assets/trauzermonie.png') }}'); --schedule-card-position: center 25%;">
<p class="schedule-time">15:30</p>
<h2>{{ t('schedule_ceremony_title') }}</h2>
<p>{{ t('schedule_ceremony_text') }}</p>
</article>
<article class="schedule-item" style="--schedule-card-image: url('{{ url_for('static', filename='assets/sektempfang.png') }}'); --schedule-card-position: center 32%;">
<p class="schedule-time">16:00</p>
<h2>{{ t('schedule_reception_title') }}</h2>
<p>{{ t('schedule_reception_text') }}</p>
</article>
<article class="schedule-item" style="--schedule-card-image: url('{{ url_for('static', filename='assets/buffet.png') }}'); --schedule-card-position: center 40%;">
<p class="schedule-time">18:00</p>
<h2>{{ t('schedule_buffet_title') }}</h2>
<p>{{ t('schedule_buffet_text') }}</p>
</article>
<article class="schedule-item" style="--schedule-card-image: url('{{ url_for('static', filename='assets/kuchen.png') }}'); --schedule-card-position: center 48%;">
<p class="schedule-time">22:00</p>
<h2>{{ t('schedule_cake_title') }}</h2>
<p>{{ t('schedule_cake_text') }}</p>
</article>
<article class="schedule-item" style="--schedule-card-image: url('{{ url_for('static', filename='assets/party.png') }}'); --schedule-card-position: center 56%;">
<p class="schedule-time">{{ t('schedule_afterwards_time') }}</p>
<h2>{{ t('schedule_party_title') }}</h2>
<p>{{ t('schedule_party_text') }}</p>
</article>
</div>
{% elif page == 'hotels' %} {% elif page == 'hotels' %}
<p>{{ t('hotels_text') }}</p> <p>{{ t('hotels_intro') }}</p>
<div class="hotel-list">
{% for hotel in hotels %}
<article class="hotel-card">
<h2>{{ hotel.name }}</h2>
<p class="hotel-address">{{ hotel.address }}</p>
<p>{{ hotel.description }}</p>
<div class="hotel-badges">
<span>{{ hotel.drive_badge }}</span>
<span>{{ hotel.walk_badge }}</span>
</div>
<div class="hotel-actions">
<a class="btn" href="{{ hotel.website_url }}" target="_blank" rel="noopener">{{ t('hotel_visit_website') }}</a>
<a
class="btn btn-ghost"
href="{{ hotel.drive_route_url }}"
target="_blank"
rel="noopener"
data-route-popup
data-route-src="{{ hotel.drive_route_embed_url }}"
>{{ t('hotel_route_drive') }}</a>
<a
class="btn btn-ghost"
href="{{ hotel.walk_route_url }}"
target="_blank"
rel="noopener"
data-route-popup
data-route-src="{{ hotel.walk_route_embed_url }}"
>{{ t('hotel_route_walk') }}</a>
</div>
</article>
{% endfor %}
</div>
<div class="route-modal" data-route-modal hidden>
<div class="route-modal-backdrop" data-route-close></div>
<section class="route-modal-panel" role="dialog" aria-modal="true" aria-label="{{ t('hotel_route_modal_title') }}">
<header class="route-modal-head">
<h2>{{ t('hotel_route_modal_title') }}</h2>
<button class="btn btn-ghost route-modal-close" type="button" data-route-close>{{ t('hotel_route_modal_close') }}</button>
</header>
<div class="route-modal-body">
<iframe title="{{ t('hotel_route_modal_title') }}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen data-route-frame></iframe>
<div class="route-map-actions">
<a class="btn route-open-btn" href="#" target="_blank" rel="noopener" data-route-open-external>{{ t('hotel_route_open_maps') }}</a>
</div>
</div>
</section>
</div>
<script>
(() => {
const modal = document.querySelector("[data-route-modal]");
if (!modal) return;
const frame = modal.querySelector("[data-route-frame]");
const externalLink = modal.querySelector("[data-route-open-external]");
const closeNodes = modal.querySelectorAll("[data-route-close]");
const triggerNodes = document.querySelectorAll("[data-route-popup][data-route-src]");
if (!frame || !externalLink || triggerNodes.length === 0) return;
const closeModal = () => {
modal.hidden = true;
document.body.classList.remove("has-route-modal");
frame.src = "";
};
triggerNodes.forEach((node) => {
node.addEventListener("click", (event) => {
event.preventDefault();
const src = node.getAttribute("data-route-src");
const href = node.getAttribute("href");
if (!src) return;
frame.src = src;
externalLink.href = href || "#";
modal.hidden = false;
document.body.classList.add("has-route-modal");
});
});
closeNodes.forEach((node) => {
node.addEventListener("click", closeModal);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !modal.hidden) {
closeModal();
}
});
})();
</script>
{% elif page == 'taxi' %} {% elif page == 'taxi' %}
<div class="taxi-coming-soon">
<img
src="{{ url_for('static', filename='assets/bauarbeiter-sticker.png') }}"
alt="{{ t('taxi_sticker_alt') }}"
loading="lazy"
decoding="async"
>
</div>
<p>{{ t('taxi_text') }}</p> <p>{{ t('taxi_text') }}</p>
{% elif page == 'gifts' %} {% elif page == 'gifts' %}
<p>{{ t('gifts_text') }}</p> <section class="gift-fun" data-gift-fun>
<p class="gift-lead">{{ t('gifts_teaser') }}</p>
<button class="btn gift-reveal-btn" type="button" data-gift-reveal>{{ t('gifts_reveal_button') }}</button>
<div class="gift-reveal" hidden data-gift-reveal-panel>
<div class="gift-image-wrap">
<img
src="{{ url_for('static', filename='assets/money-pile.svg') }}"
alt="{{ t('gifts_image_alt') }}"
loading="lazy"
decoding="async"
>
</div>
<p class="gift-caption">{{ t('gifts_caption') }}</p>
<p>{{ t('gifts_text') }}</p>
<div class="money-rain" aria-hidden="true">
<span class="bill" style="--idx: 1;"></span>
<span class="bill" style="--idx: 2;"></span>
<span class="bill" style="--idx: 3;"></span>
<span class="bill" style="--idx: 4;"></span>
<span class="bill" style="--idx: 5;"></span>
<span class="bill" style="--idx: 6;"></span>
<span class="bill" style="--idx: 7;"></span>
<span class="bill" style="--idx: 8;"></span>
<span class="bill" style="--idx: 9;"></span>
<span class="bill" style="--idx: 10;"></span>
<span class="bill" style="--idx: 11;"></span>
<span class="bill" style="--idx: 12;"></span>
</div>
</div>
</section>
<script>
(() => {
const root = document.querySelector("[data-gift-fun]");
if (!root) return;
const button = root.querySelector("[data-gift-reveal]");
const panel = root.querySelector("[data-gift-reveal-panel]");
if (!button || !panel) return;
button.addEventListener("click", () => {
panel.hidden = false;
panel.classList.add("is-active");
button.setAttribute("disabled", "disabled");
}, { once: true });
})();
</script>
{% elif page == 'location' %} {% elif page == 'location' %}
<p><strong>{{ location_name }}</strong></p> <h2>{{ t('location_story_title') }}</h2>
<p>{{ location_address }}</p> <p>{{ t('location_story_text') }}</p>
<div class="map-wrap map-consent" data-map-consent> <div class="map-wrap map-consent" data-map-consent>
<p>{{ t('maps_privacy_notice') }}</p>
<button <button
class="map-preview" class="map-preview"
type="button" type="button"
@@ -34,6 +204,7 @@
</button> </button>
<div class="map-embed-target" data-map-embed-target></div> <div class="map-embed-target" data-map-embed-target></div>
<div class="location-actions"> <div class="location-actions">
<a class="btn btn-ghost" href="{{ location_route_url }}" target="_blank" rel="noopener" data-location-route-live>{{ t('route_from_current') }}</a>
<a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a> <a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a>
</div> </div>
</div> </div>
@@ -46,6 +217,8 @@
const previewButton = wrapper.querySelector(".map-preview"); const previewButton = wrapper.querySelector(".map-preview");
const target = wrapper.querySelector("[data-map-embed-target]"); const target = wrapper.querySelector("[data-map-embed-target]");
const src = {{ google_maps_embed_url|tojson }}; const src = {{ google_maps_embed_url|tojson }};
const destination = {{ location_address|tojson }};
const liveRouteLink = wrapper.querySelector("[data-location-route-live]");
let loaded = false; let loaded = false;
const loadMap = () => { const loadMap = () => {
@@ -66,6 +239,38 @@
loadButtons.forEach((button) => { loadButtons.forEach((button) => {
button.addEventListener("click", loadMap); button.addEventListener("click", loadMap);
}); });
if (liveRouteLink) {
liveRouteLink.addEventListener("click", (event) => {
event.preventDefault();
const fallback = liveRouteLink.getAttribute("href");
const openFallback = () => {
if (fallback) {
window.open(fallback, "_blank", "noopener");
}
};
if (!navigator.geolocation) {
openFallback();
return;
}
navigator.geolocation.getCurrentPosition(
({ coords }) => {
const origin = `${coords.latitude},${coords.longitude}`;
const query = new URLSearchParams({
api: "1",
origin,
destination,
travelmode: "driving",
});
window.open(`https://www.google.com/maps/dir/?${query.toString()}`, "_blank", "noopener");
},
() => openFallback(),
{ enableHighAccuracy: true, timeout: 4500, maximumAge: 120000 }
);
});
}
})(); })();
</script> </script>
{% endif %} {% endif %}

View File

@@ -23,4 +23,28 @@
</form> </form>
</section> </section>
</div> </div>
<aside class="login-privacy-banner" data-login-privacy-banner>
<p class="login-privacy-title">{{ t('login_privacy_title') }}</p>
<p class="login-privacy-text">
{{ t('login_privacy_text') }}
<a href="{{ url_for('datenschutz') }}">{{ t('privacy') }}</a>
</p>
<button class="btn login-privacy-btn" type="button" data-login-privacy-accept>{{ t('login_privacy_accept') }}</button>
</aside>
<script>
(() => {
const banner = document.querySelector("[data-login-privacy-banner]");
const acceptBtn = document.querySelector("[data-login-privacy-accept]");
if (!banner || !acceptBtn) return;
const storageKey = "login_privacy_banner_v1_ack";
if (localStorage.getItem(storageKey) === "1") {
banner.hidden = true;
return;
}
acceptBtn.addEventListener("click", () => {
localStorage.setItem(storageKey, "1");
banner.hidden = true;
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -5,9 +5,10 @@
{% if wedding_date %} {% if wedding_date %}
<p class="hero-kicker">{{ wedding_date }}</p> <p class="hero-kicker">{{ wedding_date }}</p>
{% endif %} {% endif %}
<h1>{{ t('hero_headline') }}</h1> <h1>{{ welcome_headline or t('hero_headline') }}</h1>
<p>{{ t('hero_text') }}</p> <p>{{ welcome_text or t('hero_text') }}</p>
<a class="btn hero-cta" href="{{ url_for('guest_area') }}">{{ t('to_guest_area') }}</a> <a class="btn hero-cta" href="{{ url_for('guest_area') }}">{{ t('to_guest_area') }}</a>
</div> </div>
</section> </section>
<p class="hero-hint-below">{{ welcome_hint }}</p>
{% endblock %} {% endblock %}

Binary file not shown.