yay
@@ -10,4 +10,4 @@ RUN uv sync --frozen --no-dev
|
||||
COPY . .
|
||||
|
||||
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"]
|
||||
|
||||
232
backend/app.py
@@ -4,6 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from flask import (
|
||||
@@ -209,11 +210,65 @@ DEFAULT_INVITATION_GROUPS = [
|
||||
]
|
||||
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 = {
|
||||
"de": {
|
||||
"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_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",
|
||||
"group_name": "Benutzername",
|
||||
"group_password": "Passwort",
|
||||
@@ -247,17 +302,59 @@ TEXTS = {
|
||||
"location": "Location",
|
||||
"gifts": "Geschenke",
|
||||
"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_load_button": "Google Maps anzeigen",
|
||||
"privacy": "Datenschutz",
|
||||
"imprint": "Impressum",
|
||||
"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",
|
||||
"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.",
|
||||
"taxi_text": "Taxi-Service: 01234 / 567890 (24/7).",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_empty": "Noch keine Bilder vorhanden.",
|
||||
"gallery_image_alt": "Upload von {name}",
|
||||
@@ -305,6 +402,9 @@ TEXTS = {
|
||||
"brand": "Svenja & Dominic",
|
||||
"subtitle": "Welcome to our wedding app",
|
||||
"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",
|
||||
"group_name": "Username",
|
||||
"group_password": "Password",
|
||||
@@ -338,17 +438,59 @@ TEXTS = {
|
||||
"location": "Location",
|
||||
"gifts": "Gifts",
|
||||
"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_load_button": "Show Google Maps",
|
||||
"privacy": "Privacy",
|
||||
"imprint": "Imprint",
|
||||
"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",
|
||||
"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.",
|
||||
"taxi_text": "Taxi service: 01234 / 567890 (24/7).",
|
||||
"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.",
|
||||
"hotels_intro": "Here are hotel options nearby. Please book early and check current rates directly on each hotel website.",
|
||||
"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_empty": "No photos available yet.",
|
||||
"gallery_image_alt": "Uploaded by {name}",
|
||||
@@ -463,6 +605,62 @@ def get_wedding_countdown_iso() -> str:
|
||||
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
|
||||
def inject_common() -> dict:
|
||||
role = session.get("role", "guest")
|
||||
@@ -479,6 +677,7 @@ def inject_common() -> dict:
|
||||
"location_name": app.config["LOCATION_NAME"],
|
||||
"location_address": app.config["LOCATION_ADDRESS"],
|
||||
"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"],
|
||||
"wedding_date": app.config["WEDDING_DATE"],
|
||||
"wedding_countdown_iso": get_wedding_countdown_iso(),
|
||||
@@ -693,7 +892,21 @@ def set_lang(code: str):
|
||||
@app.get("/welcome")
|
||||
@login_required
|
||||
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")
|
||||
@@ -918,7 +1131,8 @@ def info(page: str):
|
||||
return redirect(url_for("guest_area"))
|
||||
if page == "gifts" and session.get("role") == "admin":
|
||||
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")
|
||||
|
||||
BIN
backend/static/assets/ankunft.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
backend/static/assets/bauarbeiter-sticker.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
backend/static/assets/buffet.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
backend/static/assets/kuchen.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
40
backend/static/assets/money-pile.svg
Normal 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 |
BIN
backend/static/assets/party.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
backend/static/assets/sektempfang.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
backend/static/assets/trauzermonie.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
@@ -101,6 +101,7 @@ h3 {
|
||||
width: 100%;
|
||||
padding: 1.1rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-overlay h1,
|
||||
@@ -120,6 +121,22 @@ h3 {
|
||||
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 {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(31, 58, 47, 0.11);
|
||||
@@ -131,6 +148,13 @@ h3 {
|
||||
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 {
|
||||
background:
|
||||
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);
|
||||
}
|
||||
|
||||
.login-intro {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-intro h1 {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: clamp(2rem, 4.2vw, 3.3rem);
|
||||
@@ -294,6 +322,48 @@ h3 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
@@ -902,6 +972,322 @@ input[type="file"]:focus {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
@@ -953,23 +1339,24 @@ input[type="file"]:focus {
|
||||
}
|
||||
|
||||
.map-embed-target:not(:empty) {
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.location-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
margin-top: 0.55rem;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.location-actions .btn {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: min(100%, 320px);
|
||||
min-width: 240px;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
padding: 0.58rem 0.88rem;
|
||||
padding: 0.72rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
@@ -988,8 +1375,19 @@ input[type="file"]:focus {
|
||||
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 {
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
@@ -1000,6 +1398,56 @@ input[type="file"]:focus {
|
||||
min-height: 0;
|
||||
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) {
|
||||
|
||||
@@ -10,18 +10,188 @@
|
||||
</h1>
|
||||
|
||||
{% 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' %}
|
||||
<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' %}
|
||||
<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>
|
||||
{% 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' %}
|
||||
<p><strong>{{ location_name }}</strong></p>
|
||||
<p>{{ location_address }}</p>
|
||||
<h2>{{ t('location_story_title') }}</h2>
|
||||
<p>{{ t('location_story_text') }}</p>
|
||||
<div class="map-wrap map-consent" data-map-consent>
|
||||
<p>{{ t('maps_privacy_notice') }}</p>
|
||||
<button
|
||||
class="map-preview"
|
||||
type="button"
|
||||
@@ -34,6 +204,7 @@
|
||||
</button>
|
||||
<div class="map-embed-target" data-map-embed-target></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +217,8 @@
|
||||
const previewButton = wrapper.querySelector(".map-preview");
|
||||
const target = wrapper.querySelector("[data-map-embed-target]");
|
||||
const src = {{ google_maps_embed_url|tojson }};
|
||||
const destination = {{ location_address|tojson }};
|
||||
const liveRouteLink = wrapper.querySelector("[data-location-route-live]");
|
||||
let loaded = false;
|
||||
|
||||
const loadMap = () => {
|
||||
@@ -66,6 +239,38 @@
|
||||
loadButtons.forEach((button) => {
|
||||
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>
|
||||
{% endif %}
|
||||
|
||||
@@ -23,4 +23,28 @@
|
||||
</form>
|
||||
</section>
|
||||
</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 %}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
{% if wedding_date %}
|
||||
<p class="hero-kicker">{{ wedding_date }}</p>
|
||||
{% endif %}
|
||||
<h1>{{ t('hero_headline') }}</h1>
|
||||
<p>{{ t('hero_text') }}</p>
|
||||
<h1>{{ welcome_headline or t('hero_headline') }}</h1>
|
||||
<p>{{ welcome_text or t('hero_text') }}</p>
|
||||
<a class="btn hero-cta" href="{{ url_for('guest_area') }}">{{ t('to_guest_area') }}</a>
|
||||
</div>
|
||||
</section>
|
||||
<p class="hero-hint-below">{{ welcome_hint }}</p>
|
||||
{% endblock %}
|
||||
|
||||