diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc
index 952a63a..5c33334 100644
Binary files a/backend/__pycache__/app.cpython-312.pyc and b/backend/__pycache__/app.cpython-312.pyc differ
diff --git a/backend/app.py b/backend/app.py
index d8d059b..9626c19 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -53,6 +53,7 @@ ALLOWED_MIME_TYPES = {
}
LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"}
+ONBOARDING_VERSION = 2
DEFAULT_INVITATION_GROUPS = [
{
@@ -330,8 +331,8 @@ TEXTS = {
"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_single": "In dieser Webapp kannst du zu- oder absagen, Fotos hochladen, die Galerie ansehen und alle wichtigen Infos rund um den Tag finden. Bitte gib deine Zu- oder Absage möglichst bald ab.",
- "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.",
+ "hero_app_hint_single": "In dieser Webapp kannst du zu- oder absagen, Fotos hochladen, die Galerie ansehen und alle wichtigen Infos rund um den Tag finden. Im Header findest du außerdem unsere FAQ und eine Deutsch-Englisch-Übersetzungshilfe. Bitte gib deine Zu- oder Absage möglichst bald ab.",
+ "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. Im Header findet ihr außerdem unsere FAQ und eine Deutsch-Englisch-Übersetzungshilfe. 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.",
@@ -373,6 +374,7 @@ TEXTS = {
"gifts_teaser": "Wir würden uns über diese Geschenke sehr freuen...",
"gifts_reveal_button": "Wunsch aufdecken",
"gifts_image_alt": "Ein großer Haufen Geldscheine",
+ "gifts_money_label": "Moneten",
"gifts_caption": "Trommelwirbel... in unserer 70-Quadratmeter-Wohnung ist leider kein Platz mehr für Materielles.",
"gifts_text": "Also: Money! Ein finanzieller Beitrag zu unserer Reise nach der Hochzeit ist herzlich willkommen.",
"gallery_uploaded_by": "von {name}",
@@ -409,6 +411,57 @@ TEXTS = {
"flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.",
"dashboard": "Dashboard",
"back": "Zurück",
+ "faq": "Häufig gestellte Fragen",
+ "faq_intro": "Hier findet ihr Antworten auf die wichtigsten Fragen rund um unsere Hochzeit.",
+ "faq_deadline_question": "Bis wann sollen wir zu- oder absagen?",
+ "faq_deadline_answer": "Bitte gebt uns bis spätestens 4. August 2026 über den Bereich „Zu-/Absage“ Bescheid.",
+ "faq_change_question": "Kann ich meine Antwort später noch ändern?",
+ "faq_change_answer": "Ja, ihr könnt eure Zu- oder Absage jederzeit bis zum Ende der Rückmeldefrist anpassen.",
+ "faq_when_where_question": "Wann und wo findet die Hochzeit statt?",
+ "faq_when_where_answer": "Unsere Hochzeit beginnt um 15:30 Uhr auf der Hochheimer Terrasse. Weitere Informationen findet ihr auf der Location-Seite.",
+ "faq_parking_question": "Gibt es Parkmöglichkeiten?",
+ "faq_parking_answer": "In der Hochheimer Altstadt und in der näheren Umgebung gibt es viele Parkmöglichkeiten. Plant bitte etwas Zeit für die Parkplatzsuche und den anschließenden Fußweg ein.",
+ "faq_hotel_question": "Wo können wir übernachten?",
+ "faq_hotel_answer": "Für eine möglichst kurze Anreise empfehlen wir euch eine Unterkunft direkt in Hochheim oder in der näheren Umgebung. Eine Auswahl findet ihr auf unserer Hotel-Seite.",
+ "faq_upload_question": "Wie funktioniert der Foto-Upload?",
+ "faq_upload_answer": "Über den Bereich „Upload“ könnt ihr Fotos direkt von eurem Smartphone oder Computer hochladen.",
+ "faq_visibility_question": "Wer kann die hochgeladenen Bilder sehen?",
+ "faq_visibility_answer": "Die Bilder sind ausschließlich für eingeloggte Hochzeitsgäste sichtbar. Eure eigenen Uploads könnt ihr selbst wieder löschen.",
+ "translator": "Übersetzungshilfe",
+ "translator_kicker": "Deutsch & Englisch",
+ "translator_close": "Übersetzungshilfe schließen",
+ "translator_direction": "Übersetzungsrichtung",
+ "translator_swap": "Übersetzungsrichtung wechseln",
+ "translator_input_label": "Was möchtest du übersetzen?",
+ "translator_placeholder": "Kurzen Text eingeben …",
+ "translator_quick_phrases": "Schnelle Sätze",
+ "translator_phrase_meet": "Kennenlernen",
+ "translator_phrase_drink": "Getränk anbieten",
+ "translator_phrase_restroom": "Toilette finden",
+ "translator_privacy": "Der Text wird erst nach dem Klick an DeepL übertragen. Bitte keine Namen, Kontaktdaten oder vertraulichen Inhalte eingeben.",
+ "translator_submit": "Mit DeepL übersetzen",
+ "translator_policy": "Datenschutz bei DeepL",
+ "language_german": "Deutsch",
+ "language_english": "Englisch",
+ "tour_title": "Willkommen in unserer Hochzeits-App",
+ "tour_progress": "Schritt {current} von {total}",
+ "tour_dashboard_title": "Alles auf einen Blick",
+ "tour_dashboard_text": "Im Dashboard findet ihr alle wichtigen Bereiche und Informationen für unseren Hochzeitstag.",
+ "tour_rsvp_title": "Zu- oder Absage",
+ "tour_rsvp_text": "Gebt die Rückmeldung für jedes Mitglied eurer Einladung bequem über den Bereich „Zu-/Absage“ ab.",
+ "tour_photos_title": "Fotos & Galerie",
+ "tour_photos_text": "Ladet eure Fotos direkt vom Smartphone hoch, entdeckt die gemeinsame Galerie und ladet Erinnerungen herunter.",
+ "tour_routes_title": "Hotels, Location & Routen",
+ "tour_routes_text": "Bei den Hotels und der Location könnt ihr Routen mit Google Maps öffnen. Karten werden erst nach eurem Klick geladen.",
+ "tour_translator_title": "Übersetzungshilfe",
+ "tour_translator_text": "Über das Buch im Header könnt ihr kurze deutsche oder englische Texte für die Übersetzung mit DeepL vorbereiten.",
+ "tour_faq_title": "Häufig gestellte Fragen",
+ "tour_faq_text": "Das Fragezeichen im Header führt euch jederzeit zu den wichtigsten Antworten rund um die Hochzeit.",
+ "tour_back": "Zurück",
+ "tour_next": "Weiter",
+ "tour_skip": "Überspringen",
+ "tour_finish": "Los geht’s",
+ "tour_restart": "Einführung erneut ansehen",
"countdown_button_label": "Countdown bis zur Hochzeit",
"countdown_until": "Noch",
"countdown_started": "Die Feier hat begonnen",
@@ -474,8 +527,8 @@ TEXTS = {
"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_single": "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.",
- "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.",
+ "hero_app_hint_single": "In this web app, you can send your RSVP, upload photos, view the gallery, and find all important details for the day. The header also includes our FAQ and a German-English translation helper. Please submit your RSVP soon.",
+ "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. The header also includes our FAQ and a German-English translation helper. 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.",
@@ -517,6 +570,7 @@ TEXTS = {
"gifts_teaser": "We would be very happy to receive these gifts...",
"gifts_reveal_button": "Reveal wish",
"gifts_image_alt": "A big pile of cash",
+ "gifts_money_label": "Moolah",
"gifts_caption": "Drum roll... in our 70-square-meter apartment, we sadly have no room left for material things.",
"gifts_text": "So: Money! A financial contribution to our trip after the wedding is very welcome.",
"gallery_uploaded_by": "by {name}",
@@ -553,6 +607,57 @@ TEXTS = {
"flash_admin_only": "This area is available to admins only.",
"dashboard": "Dashboard",
"back": "Back",
+ "faq": "Frequently Asked Questions",
+ "faq_intro": "Here you will find answers to the most important questions about our wedding.",
+ "faq_deadline_question": "When do we need to RSVP by?",
+ "faq_deadline_answer": "Please let us know whether you will be attending by 4 August 2026 in the “RSVP” section.",
+ "faq_change_question": "Can I change my response later?",
+ "faq_change_answer": "Yes, you can update your RSVP at any time before the response deadline.",
+ "faq_when_where_question": "When and where is the wedding taking place?",
+ "faq_when_where_answer": "Our wedding begins at 3:30 PM at Hochheimer Terrasse. You can find more information on the location page.",
+ "faq_parking_question": "Is parking available?",
+ "faq_parking_answer": "There are plenty of parking options in Hochheim's old town and the surrounding area. Please allow some extra time to find a space and walk to the venue.",
+ "faq_hotel_question": "Where can we stay overnight?",
+ "faq_hotel_answer": "For the shortest possible journey, we recommend staying in Hochheim or nearby. You can find a selection on our hotels page.",
+ "faq_upload_question": "How does the photo upload work?",
+ "faq_upload_answer": "You can upload photos directly from your smartphone or computer in the “Upload” section.",
+ "faq_visibility_question": "Who can see the uploaded photos?",
+ "faq_visibility_answer": "The photos are visible only to logged-in wedding guests. You can delete your own uploads at any time.",
+ "translator": "Translation helper",
+ "translator_kicker": "English & German",
+ "translator_close": "Close translation helper",
+ "translator_direction": "Translation direction",
+ "translator_swap": "Switch translation direction",
+ "translator_input_label": "What would you like to translate?",
+ "translator_placeholder": "Enter a short text …",
+ "translator_quick_phrases": "Quick phrases",
+ "translator_phrase_meet": "Meeting someone",
+ "translator_phrase_drink": "Offering a drink",
+ "translator_phrase_restroom": "Finding the restroom",
+ "translator_privacy": "Your text is sent to DeepL only after you click the button. Please do not enter names, contact details, or confidential information.",
+ "translator_submit": "Translate with DeepL",
+ "translator_policy": "DeepL privacy policy",
+ "language_german": "German",
+ "language_english": "English",
+ "tour_title": "Welcome to our wedding app",
+ "tour_progress": "Step {current} of {total}",
+ "tour_dashboard_title": "Everything at a glance",
+ "tour_dashboard_text": "The dashboard brings together all the important sections and information for our wedding day.",
+ "tour_rsvp_title": "RSVP",
+ "tour_rsvp_text": "Submit a response for every member of your invitation conveniently in the “RSVP” section.",
+ "tour_photos_title": "Photos & gallery",
+ "tour_photos_text": "Upload photos directly from your phone, explore the shared gallery, and download your favourite memories.",
+ "tour_routes_title": "Hotels, location & routes",
+ "tour_routes_text": "You can open Google Maps routes from the hotel and location pages. Maps load only after you click.",
+ "tour_translator_title": "Translation helper",
+ "tour_translator_text": "Use the book in the header to prepare short German or English texts for translation with DeepL.",
+ "tour_faq_title": "Frequently asked questions",
+ "tour_faq_text": "The question mark in the header takes you to the most important answers about the wedding at any time.",
+ "tour_back": "Back",
+ "tour_next": "Next",
+ "tour_skip": "Skip",
+ "tour_finish": "Let’s go",
+ "tour_restart": "View introduction again",
"countdown_button_label": "Wedding countdown",
"countdown_until": "Countdown",
"countdown_started": "The celebration has started",
@@ -767,6 +872,7 @@ def init_db() -> None:
name TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'guest',
+ onboarding_completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
@@ -811,6 +917,8 @@ def init_db() -> None:
group_cols = table_columns(conn, "groups")
if "role" not in group_cols:
conn.execute("ALTER TABLE groups ADD COLUMN role TEXT NOT NULL DEFAULT 'guest'")
+ if "onboarding_completed" not in group_cols:
+ conn.execute("ALTER TABLE groups ADD COLUMN onboarding_completed INTEGER NOT NULL DEFAULT 0")
conn.commit()
@@ -947,14 +1055,38 @@ def welcome():
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")
+ onboarding_row = db.execute(
+ "SELECT onboarding_completed FROM groups WHERE id = ?",
+ (int(session["group_id"]),),
+ ).fetchone()
+ completed_onboarding_version = (
+ int(onboarding_row["onboarding_completed"] or 0) if onboarding_row else 0
+ )
+ show_onboarding = (
+ request.args.get("tour") == "1"
+ or completed_onboarding_version < ONBOARDING_VERSION
+ )
return render_template(
"welcome.html",
welcome_headline=welcome_headline,
welcome_text=welcome_text,
welcome_hint=t("hero_app_hint_single") if is_single_guest else t("hero_app_hint"),
+ show_onboarding=show_onboarding,
)
+@app.post("/onboarding/complete")
+@login_required
+def complete_onboarding():
+ db = get_db()
+ db.execute(
+ "UPDATE groups SET onboarding_completed = ? WHERE id = ?",
+ (ONBOARDING_VERSION, int(session["group_id"])),
+ )
+ db.commit()
+ return ("", 204)
+
+
@app.get("/gaestebereich")
@app.get("/gästebereich")
@login_required
@@ -1002,6 +1134,21 @@ def dashboard():
return redirect(url_for("guest_area"))
+@app.get("/faq")
+@login_required
+def faq():
+ faq_items = [
+ ("faq_deadline_question", "faq_deadline_answer"),
+ ("faq_change_question", "faq_change_answer"),
+ ("faq_when_where_question", "faq_when_where_answer"),
+ ("faq_parking_question", "faq_parking_answer"),
+ ("faq_hotel_question", "faq_hotel_answer"),
+ ("faq_upload_question", "faq_upload_answer"),
+ ("faq_visibility_question", "faq_visibility_answer"),
+ ]
+ return render_template("faq.html", faq_items=faq_items)
+
+
@app.route("/rsvp", methods=["GET", "POST"])
@login_required
def rsvp():
diff --git a/backend/app.sqlite3 b/backend/app.sqlite3
index 0214a38..93b14bb 100644
Binary files a/backend/app.sqlite3 and b/backend/app.sqlite3 differ
diff --git a/backend/static/assets/money-pile.svg b/backend/static/assets/money-pile.svg
index 7848c48..7836b2c 100644
--- a/backend/static/assets/money-pile.svg
+++ b/backend/static/assets/money-pile.svg
@@ -36,5 +36,4 @@
$
- Gift Fund
diff --git a/backend/static/faq.js b/backend/static/faq.js
new file mode 100644
index 0000000..13d1e11
--- /dev/null
+++ b/backend/static/faq.js
@@ -0,0 +1,53 @@
+(() => {
+ function initFaqItem(item) {
+ const summary = item.querySelector("summary");
+ const answer = item.querySelector(".faq-answer");
+ if (!summary || !answer) {
+ return;
+ }
+
+ let isAnimating = false;
+
+ summary.addEventListener("click", (event) => {
+ event.preventDefault();
+ if (isAnimating) {
+ return;
+ }
+
+ if (!item.open) {
+ item.open = true;
+ item.classList.remove("is-closing");
+ item.classList.add("is-opening");
+ const finishOpen = () => {
+ item.classList.remove("is-opening");
+ isAnimating = false;
+ };
+ isAnimating = true;
+ answer.addEventListener("animationend", finishOpen, { once: true });
+ window.setTimeout(
+ finishOpen,
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 360,
+ );
+ return;
+ }
+
+ isAnimating = true;
+ item.classList.remove("is-opening");
+ item.classList.add("is-closing");
+ const finishClose = () => {
+ item.classList.remove("is-closing");
+ item.open = false;
+ isAnimating = false;
+ };
+ answer.addEventListener("animationend", finishClose, { once: true });
+ window.setTimeout(
+ finishClose,
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 360,
+ );
+ });
+ }
+
+ document.addEventListener("DOMContentLoaded", () => {
+ document.querySelectorAll(".faq-item").forEach(initFaqItem);
+ });
+})();
diff --git a/backend/static/onboarding.js b/backend/static/onboarding.js
new file mode 100644
index 0000000..4ce307a
--- /dev/null
+++ b/backend/static/onboarding.js
@@ -0,0 +1,81 @@
+(() => {
+ function initOnboarding(dialog) {
+ const slides = Array.from(dialog.querySelectorAll("[data-onboarding-slide]"));
+ const dots = Array.from(dialog.querySelectorAll("[data-onboarding-dot]"));
+ const progress = dialog.querySelector("[data-onboarding-progress]");
+ const backButton = dialog.querySelector("[data-onboarding-back]");
+ const nextButton = dialog.querySelector("[data-onboarding-next]");
+ const completeUrl = dialog.dataset.completeUrl;
+ const progressTemplate = dialog.dataset.progressTemplate || "Step {current} of {total}";
+
+ if (!slides.length || !progress || !backButton || !nextButton || !completeUrl) {
+ return;
+ }
+
+ let currentIndex = 0;
+ let completionSent = false;
+
+ const markComplete = () => {
+ if (completionSent) {
+ return;
+ }
+ completionSent = true;
+ window.fetch(completeUrl, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "X-Requested-With": "XMLHttpRequest" },
+ keepalive: true,
+ }).catch(() => {
+ completionSent = false;
+ });
+ };
+
+ const finish = () => {
+ markComplete();
+ dialog.close();
+ };
+
+ const render = () => {
+ slides.forEach((slide, index) => {
+ slide.hidden = index !== currentIndex;
+ });
+ dots.forEach((dot, index) => {
+ dot.classList.toggle("is-active", index <= currentIndex);
+ });
+ progress.textContent = progressTemplate
+ .replace("{current}", String(currentIndex + 1))
+ .replace("{total}", String(slides.length));
+ backButton.hidden = currentIndex === 0;
+ const isLast = currentIndex === slides.length - 1;
+ nextButton.textContent = isLast ? nextButton.dataset.finishLabel : nextButton.dataset.nextLabel;
+ };
+
+ backButton.addEventListener("click", () => {
+ currentIndex = Math.max(0, currentIndex - 1);
+ render();
+ });
+
+ nextButton.addEventListener("click", () => {
+ if (currentIndex === slides.length - 1) {
+ finish();
+ return;
+ }
+ currentIndex += 1;
+ render();
+ });
+
+ dialog.addEventListener("cancel", (event) => {
+ event.preventDefault();
+ });
+
+ render();
+ dialog.showModal();
+ }
+
+ document.addEventListener("DOMContentLoaded", () => {
+ const dialog = document.querySelector("[data-onboarding-dialog]");
+ if (dialog) {
+ initOnboarding(dialog);
+ }
+ });
+})();
diff --git a/backend/static/styles.css b/backend/static/styles.css
index de7cd6f..47cee9f 100644
--- a/backend/static/styles.css
+++ b/backend/static/styles.css
@@ -697,7 +697,17 @@ input[type="file"]:focus {
fill: currentColor;
}
-.toolbar-session-actions {
+.toolbar-faq-btn svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+.toolbar-translate-btn svg {
+ width: 1.02rem;
+ height: 1.02rem;
+}
+
+.toolbar-row {
display: inline-flex;
align-items: center;
gap: 0.38rem;
@@ -816,6 +826,437 @@ input[type="file"]:focus {
0 1px 0 rgba(255, 255, 255, 0.5) inset;
}
+.faq-card {
+ width: min(760px, 100%);
+ margin-inline: auto;
+ padding: clamp(1.2rem, 3vw, 2rem);
+}
+
+.faq-card h1 {
+ margin-bottom: 0.55rem;
+}
+
+.faq-intro {
+ width: min(56ch, 100%);
+ margin: 0 auto 1.4rem;
+ color: rgba(31, 26, 23, 0.76);
+ line-height: 1.5;
+}
+
+.faq-list {
+ display: grid;
+ gap: 0.7rem;
+ text-align: left;
+}
+
+.faq-item {
+ overflow: hidden;
+ border: 1px solid rgba(31, 58, 47, 0.14);
+ border-radius: 14px;
+ background: linear-gradient(180deg, #fffdf9 0%, #fbf3e6 100%);
+ box-shadow: 0 5px 14px rgba(31, 58, 47, 0.07);
+}
+
+.faq-item summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem;
+ color: var(--forest);
+ font-weight: 650;
+ cursor: pointer;
+ list-style: none;
+}
+
+.faq-item summary::-webkit-details-marker {
+ display: none;
+}
+
+.faq-item summary:focus-visible {
+ outline: 2px solid var(--gold);
+ outline-offset: -3px;
+}
+
+.faq-toggle {
+ position: relative;
+ width: 1rem;
+ height: 1rem;
+ flex: 0 0 1rem;
+}
+
+.faq-toggle::before,
+.faq-toggle::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0.85rem;
+ height: 2px;
+ border-radius: 2px;
+ background: var(--gold);
+ transform: translate(-50%, -50%);
+ transition: transform 0.2s ease;
+}
+
+.faq-toggle::after {
+ transform: translate(-50%, -50%) rotate(90deg);
+}
+
+.faq-item[open] .faq-toggle::after {
+ transform: translate(-50%, -50%) rotate(0deg);
+}
+
+.faq-answer {
+ padding: 0 1rem 1rem;
+ color: rgba(31, 26, 23, 0.8);
+ line-height: 1.55;
+}
+
+.faq-answer p {
+ margin: 0;
+}
+
+.faq-item.is-opening .faq-answer {
+ animation: faq-slide-in 300ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
+}
+
+.faq-item.is-closing .faq-answer {
+ animation: faq-slide-out 260ms cubic-bezier(0.55, 0.06, 0.68, 0.19) both;
+}
+
+@keyframes faq-slide-in {
+ from {
+ opacity: 0;
+ transform: translateX(2.5rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes faq-slide-out {
+ from {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(2.5rem);
+ }
+}
+
+.translator-dialog {
+ width: min(92vw, 520px);
+ max-height: min(88vh, 720px);
+ padding: 0;
+ overflow: hidden;
+ color: var(--ink);
+ border: 1px solid rgba(31, 58, 47, 0.16);
+ border-radius: 22px;
+ background: var(--card);
+ box-shadow: 0 24px 70px rgba(18, 31, 24, 0.28);
+}
+
+.translator-dialog::backdrop {
+ background: rgba(18, 31, 24, 0);
+}
+
+.translator-dialog.is-opening {
+ animation: translator-slide-up 320ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
+}
+
+.translator-dialog.is-opening::backdrop {
+ animation: translator-backdrop-in 320ms ease-out both;
+}
+
+.translator-dialog.is-closing {
+ animation: translator-slide-down 280ms cubic-bezier(0.55, 0.06, 0.68, 0.19) both;
+}
+
+.translator-dialog.is-closing::backdrop {
+ animation: translator-backdrop-out 280ms ease-in both;
+}
+
+@keyframes translator-slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(3.5rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes translator-slide-down {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(3.5rem);
+ }
+}
+
+@keyframes translator-backdrop-in {
+ from {
+ background: rgba(18, 31, 24, 0);
+ }
+ to {
+ background: rgba(18, 31, 24, 0.22);
+ }
+}
+
+@keyframes translator-backdrop-out {
+ from {
+ background: rgba(18, 31, 24, 0.22);
+ }
+ to {
+ background: rgba(18, 31, 24, 0);
+ }
+}
+
+.translator-sheet {
+ max-height: min(88vh, 720px);
+ padding: clamp(1.1rem, 4vw, 1.6rem);
+ overflow-y: auto;
+ text-align: center;
+}
+
+.translator-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 1rem;
+}
+
+.translator-head h2 {
+ margin-bottom: 0.8rem;
+}
+
+.translator-direction {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ gap: 0.55rem;
+ margin-bottom: 1rem;
+ color: var(--forest);
+ font-weight: 650;
+ text-align: center;
+}
+
+.translator-direction > span {
+ padding: 0.55rem 0.7rem;
+ border: 1px solid rgba(31, 58, 47, 0.14);
+ border-radius: 999px;
+ background: rgba(178, 137, 70, 0.09);
+}
+
+.translator-swap {
+ width: 2.35rem;
+ height: 2.35rem;
+ border: 0;
+ border-radius: 50%;
+ color: #fff;
+ background: var(--forest);
+ font-size: 1.1rem;
+ cursor: pointer;
+}
+
+.translator-label {
+ display: block;
+ margin-bottom: 0.35rem;
+ font-weight: 650;
+}
+
+.translator-input {
+ width: 100%;
+ min-height: 7.5rem;
+ resize: vertical;
+ padding: 0.85rem;
+ border: 1px solid rgba(31, 58, 47, 0.22);
+ border-radius: 14px;
+ color: var(--ink);
+ background: #fff;
+ font: inherit;
+ line-height: 1.45;
+ text-align: center;
+}
+
+.translator-input:focus {
+ outline: 2px solid rgba(178, 137, 70, 0.55);
+ border-color: var(--gold);
+}
+
+.translator-quick-list {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.45rem;
+ margin-top: 0.7rem;
+}
+
+.translator-quick-list button {
+ padding: 0.38rem 0.65rem;
+ border: 1px solid rgba(31, 58, 47, 0.16);
+ border-radius: 999px;
+ color: var(--forest);
+ background: rgba(255, 253, 248, 0.92);
+ font: inherit;
+ font-size: 0.86rem;
+ cursor: pointer;
+}
+
+.translator-privacy {
+ margin: 1rem 0 0.8rem;
+ color: rgba(31, 26, 23, 0.7);
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+
+.translator-submit {
+ width: 100%;
+ border-radius: 999px;
+}
+
+.onboarding-dialog {
+ width: min(92vw, 560px);
+ max-height: min(88vh, 720px);
+ padding: 0;
+ overflow: hidden;
+ color: var(--ink);
+ border: 1px solid rgba(31, 58, 47, 0.16);
+ border-radius: 24px;
+ background: var(--card);
+ box-shadow: 0 26px 72px rgba(18, 31, 24, 0.3);
+}
+
+.onboarding-dialog::backdrop {
+ background: rgba(18, 31, 24, 0.5);
+ backdrop-filter: blur(4px);
+}
+
+.onboarding-shell {
+ max-height: min(88vh, 720px);
+ padding: clamp(1.25rem, 4vw, 1.8rem);
+ overflow-y: auto;
+ text-align: center;
+}
+
+.onboarding-progress {
+ display: flex;
+ justify-content: center;
+ gap: 0.38rem;
+ margin-bottom: 0.55rem;
+}
+
+.onboarding-progress span {
+ width: 1.4rem;
+ height: 0.28rem;
+ border-radius: 999px;
+ background: rgba(31, 58, 47, 0.14);
+}
+
+.onboarding-progress span.is-active {
+ background: var(--gold);
+}
+
+.onboarding-step-label {
+ margin: 0 0 0.3rem;
+ color: var(--gold-text);
+ font-size: 0.82rem;
+ font-weight: 650;
+}
+
+.onboarding-shell > h2 {
+ margin-bottom: 1rem;
+ color: var(--forest);
+}
+
+.onboarding-slide {
+ min-height: 14rem;
+ padding: 1.15rem;
+ border: 1px solid rgba(31, 58, 47, 0.12);
+ border-radius: 18px;
+ background: linear-gradient(180deg, #fffdf8 0%, #fdf5e8 100%);
+ box-shadow: 0 8px 20px rgba(31, 58, 47, 0.08);
+}
+
+.onboarding-slide[hidden] {
+ display: none;
+}
+
+.onboarding-icon {
+ display: grid;
+ place-items: center;
+ width: 3.4rem;
+ height: 3.4rem;
+ margin: 0 auto 0.8rem;
+ border-radius: 50%;
+ color: #fff;
+ background: linear-gradient(180deg, #2a4a3b 0%, var(--forest) 100%);
+ box-shadow: 0 7px 16px rgba(31, 58, 47, 0.2);
+ font-size: 1.35rem;
+ font-weight: 700;
+}
+
+.onboarding-icon svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ fill: currentColor;
+}
+
+.onboarding-icon-route svg {
+ width: 1.65rem;
+ height: 1.65rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.onboarding-slide h3 {
+ margin-bottom: 0.55rem;
+ color: var(--forest);
+ font-size: 1.35rem;
+}
+
+.onboarding-slide p {
+ max-width: 44ch;
+ margin: 0 auto;
+ color: var(--gold-text);
+ line-height: 1.55;
+}
+
+.onboarding-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.65rem;
+ margin-top: 1rem;
+}
+
+.onboarding-actions > div {
+ display: flex;
+ gap: 0.5rem;
+ margin-left: auto;
+}
+
+.onboarding-actions .btn {
+ border-radius: 999px;
+}
+
+.faq-tour-restart {
+ display: flex;
+ width: fit-content;
+ margin: 1.2rem auto 0;
+ border-radius: 999px;
+}
+
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1172,6 +1613,20 @@ input[type="file"]:focus {
padding: 0.74rem 1.2rem;
}
+.route-location-btn.btn-ghost {
+ color: #174ea6;
+ border-color: #a8c7fa;
+ background: linear-gradient(180deg, #f2f7ff 0%, #e8f0fe 100%);
+ box-shadow: 0 5px 12px rgba(66, 133, 244, 0.12);
+}
+
+.route-location-btn.btn-ghost:hover {
+ color: #123d82;
+ border-color: #7eaff8;
+ background: linear-gradient(180deg, #edf5ff 0%, #dce9fd 100%);
+ box-shadow: 0 7px 16px rgba(66, 133, 244, 0.2);
+}
+
body.has-route-modal {
overflow: hidden;
}
@@ -1296,6 +1751,7 @@ body.has-route-modal {
}
.gift-image-wrap {
+ position: relative;
max-width: min(640px, 100%);
margin: 0 auto;
}
@@ -1309,6 +1765,18 @@ body.has-route-modal {
box-shadow: 0 12px 28px rgba(31, 58, 47, 0.18);
}
+.gift-money-label {
+ position: absolute;
+ top: 7%;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #f0db8e;
+ font-family: Arial, sans-serif;
+ font-size: clamp(1.35rem, 5.6vw, 2.25rem);
+ line-height: 1;
+ white-space: nowrap;
+}
+
.gift-caption {
margin: 0.8rem 0 0;
font-weight: 700;
@@ -1528,6 +1996,33 @@ body.has-route-modal {
}
@media (max-width: 640px) {
+ .toolbar {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.28rem;
+ width: 13.2rem;
+ }
+
+ .toolbar-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0;
+ }
+
+ .toolbar-login {
+ width: auto;
+ align-items: flex-end;
+ }
+
+ .toolbar-login .toolbar-row {
+ width: auto;
+ justify-content: flex-end;
+ gap: 0.38rem;
+ }
+
.container {
margin: 1.15rem auto 1.25rem;
}
@@ -1609,9 +2104,62 @@ body.has-route-modal {
.route-open-btn {
width: 100%;
}
+
+ .translator-dialog {
+ width: 100%;
+ max-width: none;
+ max-height: 88vh;
+ margin: auto 0 0;
+ border-radius: 22px 22px 0 0;
+ }
+
+ .translator-sheet {
+ max-height: 88vh;
+ padding-bottom: max(1.2rem, env(safe-area-inset-bottom));
+ }
+
+ .onboarding-dialog {
+ width: 100%;
+ max-width: none;
+ max-height: 90vh;
+ margin: auto 0 0;
+ border-radius: 24px 24px 0 0;
+ }
+
+ .onboarding-shell {
+ max-height: 90vh;
+ padding-bottom: max(1.25rem, env(safe-area-inset-bottom));
+ }
+
+ .onboarding-slide {
+ min-height: 12.5rem;
+ }
+
+ .onboarding-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
+
+ .onboarding-actions > div,
+ .onboarding-actions .btn {
+ width: 100%;
+ }
}
@media (prefers-reduced-motion: reduce) {
+ .translator-dialog.is-opening,
+ .translator-dialog.is-closing,
+ .faq-item.is-opening .faq-answer,
+ .faq-item.is-closing .faq-answer {
+ animation: none;
+ }
+
+ .translator-dialog.is-opening::backdrop,
+ .translator-dialog.is-closing::backdrop {
+ animation: none;
+ background: rgba(18, 31, 24, 0.14);
+ }
+
.gift-reveal.is-active {
animation: none;
}
diff --git a/backend/static/translator.js b/backend/static/translator.js
new file mode 100644
index 0000000..fd907fd
--- /dev/null
+++ b/backend/static/translator.js
@@ -0,0 +1,117 @@
+(() => {
+ function initTranslator(dialog) {
+ const openButton = document.querySelector("[data-translator-open]");
+ const swapButton = dialog.querySelector("[data-translator-swap]");
+ const submitButton = dialog.querySelector("[data-translator-submit]");
+ const input = dialog.querySelector("[data-translator-input]");
+ const sourceLabel = dialog.querySelector("[data-translator-source]");
+ const targetLabel = dialog.querySelector("[data-translator-target]");
+ const phraseButtons = dialog.querySelectorAll("[data-translator-phrase]");
+
+ if (!openButton || !swapButton || !submitButton || !input || !sourceLabel || !targetLabel) {
+ return;
+ }
+
+ let sourceLang = dialog.dataset.sourceLang === "en" ? "en" : "de";
+ const labels = {
+ de: sourceLang === "de" ? sourceLabel.textContent : targetLabel.textContent,
+ en: sourceLang === "en" ? sourceLabel.textContent : targetLabel.textContent,
+ };
+
+ const renderDirection = () => {
+ const targetLang = sourceLang === "de" ? "en" : "de";
+ sourceLabel.textContent = labels[sourceLang];
+ targetLabel.textContent = labels[targetLang];
+ };
+
+ const updateSubmit = () => {
+ submitButton.disabled = input.value.trim().length === 0;
+ };
+
+ let isClosing = false;
+
+ const close = () => {
+ if (!dialog.open || isClosing) {
+ return;
+ }
+ isClosing = true;
+ dialog.classList.remove("is-opening");
+ dialog.classList.add("is-closing");
+
+ const finishClose = () => {
+ if (!dialog.open) {
+ return;
+ }
+ dialog.classList.remove("is-closing");
+ dialog.close();
+ isClosing = false;
+ openButton.focus();
+ };
+
+ dialog.addEventListener("animationend", finishClose, { once: true });
+ window.setTimeout(
+ finishClose,
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 360,
+ );
+ };
+
+ openButton.addEventListener("click", () => {
+ dialog.showModal();
+ dialog.classList.remove("is-closing");
+ dialog.classList.add("is-opening");
+ window.requestAnimationFrame(() => input.focus());
+ });
+
+ dialog.addEventListener("click", (event) => {
+ if (event.target === dialog) {
+ close();
+ }
+ });
+ dialog.addEventListener("cancel", (event) => {
+ event.preventDefault();
+ close();
+ });
+
+ swapButton.addEventListener("click", () => {
+ sourceLang = sourceLang === "de" ? "en" : "de";
+ renderDirection();
+ phraseButtons.forEach((button) => {
+ input.value = input.value === button.dataset.de || input.value === button.dataset.en
+ ? button.dataset[sourceLang]
+ : input.value;
+ });
+ updateSubmit();
+ input.focus();
+ });
+
+ phraseButtons.forEach((button) => {
+ button.addEventListener("click", () => {
+ input.value = button.dataset[sourceLang] || "";
+ updateSubmit();
+ input.focus();
+ });
+ });
+
+ input.addEventListener("input", updateSubmit);
+ submitButton.addEventListener("click", () => {
+ const text = input.value.trim();
+ if (!text) {
+ return;
+ }
+ const targetLang = sourceLang === "de" ? "en" : "de";
+ const url = `https://www.deepl.com/translator#${sourceLang}/${targetLang}/${encodeURIComponent(text)}`;
+ window.open(url, "_blank", "noopener,noreferrer");
+ close();
+ });
+
+ renderDirection();
+ updateSubmit();
+ }
+
+ document.addEventListener("DOMContentLoaded", () => {
+ const dialog = document.querySelector("[data-translator-dialog]");
+ if (dialog) {
+ initTranslator(dialog);
+ }
+ });
+})();
diff --git a/backend/templates/base.html b/backend/templates/base.html
index e21fd93..d234857 100644
--- a/backend/templates/base.html
+++ b/backend/templates/base.html
@@ -7,62 +7,82 @@
-
+