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 @@ - +
{{ t('brand') }}
-
-
- -
-
- -
- {% if guest_name %} -
+
+
+
+ +
+
+ +
+ {% if guest_name %} - + + + + {% endif %}
-
+ {% if guest_name %} +
+
+ + +
+ {% if guest_name %} + +
+
+
+

{{ t('translator') }}

+
+
+ +
+ {{ t('language_german') if lang == 'de' else t('language_english') }} + + {{ t('language_english') if lang == 'de' else t('language_german') }} +
+ + + + +
+ + + +
+ +

{{ t('translator_privacy') }}

+ +
+
+ {% endif %} +
{% with messages = get_flashed_messages() %} @@ -95,5 +159,6 @@ + diff --git a/backend/templates/datenschutz.html b/backend/templates/datenschutz.html index d2e9074..bc93ce5 100644 --- a/backend/templates/datenschutz.html +++ b/backend/templates/datenschutz.html @@ -169,6 +169,23 @@

+
+ + +