new feat übersetzer und faq

This commit is contained in:
2026-07-24 15:16:10 +00:00
parent 11b0d20c42
commit 45b37597a9
14 changed files with 1205 additions and 51 deletions

View File

@@ -53,6 +53,7 @@ ALLOWED_MIME_TYPES = {
} }
LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"} LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"}
ONBOARDING_VERSION = 2
DEFAULT_INVITATION_GROUPS = [ DEFAULT_INVITATION_GROUPS = [
{ {
@@ -330,8 +331,8 @@ TEXTS = {
"hero_headline_with_group": "Willkommen zu unserer Hochzeit, {name}", "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_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_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_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. Bitte gebt eure 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", "to_guest_area": "Zum Gästebereich",
"schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.", "schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.",
"schedule_intro": "Hier findet ihr den Ablauf für unseren Hochzeitstag.", "schedule_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_teaser": "Wir würden uns über diese Geschenke sehr freuen...",
"gifts_reveal_button": "Wunsch aufdecken", "gifts_reveal_button": "Wunsch aufdecken",
"gifts_image_alt": "Ein großer Haufen Geldscheine", "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_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.", "gifts_text": "Also: Money! Ein finanzieller Beitrag zu unserer Reise nach der Hochzeit ist herzlich willkommen.",
"gallery_uploaded_by": "von {name}", "gallery_uploaded_by": "von {name}",
@@ -409,6 +411,57 @@ TEXTS = {
"flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.", "flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"back": "Zurück", "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 gehts",
"tour_restart": "Einführung erneut ansehen",
"countdown_button_label": "Countdown bis zur Hochzeit", "countdown_button_label": "Countdown bis zur Hochzeit",
"countdown_until": "Noch", "countdown_until": "Noch",
"countdown_started": "Die Feier hat begonnen", "countdown_started": "Die Feier hat begonnen",
@@ -474,8 +527,8 @@ TEXTS = {
"hero_headline_with_group": "Welcome to our wedding, {name}", "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_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_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_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. 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", "to_guest_area": "Open guest area",
"schedule_text": "3:00 PM ceremony, 5:00 PM reception, 7:00 PM dinner.", "schedule_text": "3:00 PM ceremony, 5:00 PM reception, 7:00 PM dinner.",
"schedule_intro": "Here is the schedule for our wedding day.", "schedule_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_teaser": "We would be very happy to receive these gifts...",
"gifts_reveal_button": "Reveal wish", "gifts_reveal_button": "Reveal wish",
"gifts_image_alt": "A big pile of cash", "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_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.", "gifts_text": "So: Money! A financial contribution to our trip after the wedding is very welcome.",
"gallery_uploaded_by": "by {name}", "gallery_uploaded_by": "by {name}",
@@ -553,6 +607,57 @@ TEXTS = {
"flash_admin_only": "This area is available to admins only.", "flash_admin_only": "This area is available to admins only.",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"back": "Back", "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": "Lets go",
"tour_restart": "View introduction again",
"countdown_button_label": "Wedding countdown", "countdown_button_label": "Wedding countdown",
"countdown_until": "Countdown", "countdown_until": "Countdown",
"countdown_started": "The celebration has started", "countdown_started": "The celebration has started",
@@ -767,6 +872,7 @@ def init_db() -> None:
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'guest', role TEXT NOT NULL DEFAULT 'guest',
onboarding_completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) )
""" """
@@ -811,6 +917,8 @@ def init_db() -> None:
group_cols = table_columns(conn, "groups") group_cols = table_columns(conn, "groups")
if "role" not in group_cols: if "role" not in group_cols:
conn.execute("ALTER TABLE groups ADD COLUMN role TEXT NOT NULL DEFAULT 'guest'") 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() conn.commit()
@@ -947,14 +1055,38 @@ def welcome():
is_single_guest = member_count <= 1 is_single_guest = member_count <= 1
welcome_headline = t("hero_headline_with_group").format(name=session.get("group_name", "")) 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") 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( return render_template(
"welcome.html", "welcome.html",
welcome_headline=welcome_headline, welcome_headline=welcome_headline,
welcome_text=welcome_text, welcome_text=welcome_text,
welcome_hint=t("hero_app_hint_single") if is_single_guest else t("hero_app_hint"), 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("/gaestebereich")
@app.get("/gästebereich") @app.get("/gästebereich")
@login_required @login_required
@@ -1002,6 +1134,21 @@ def dashboard():
return redirect(url_for("guest_area")) 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"]) @app.route("/rsvp", methods=["GET", "POST"])
@login_required @login_required
def rsvp(): def rsvp():

Binary file not shown.

View File

@@ -36,5 +36,4 @@
<text x="280" y="69" text-anchor="middle" font-size="28" font-family="Arial, sans-serif" fill="#2f5e35">$</text> <text x="280" y="69" text-anchor="middle" font-size="28" font-family="Arial, sans-serif" fill="#2f5e35">$</text>
</g> </g>
</g> </g>
<text x="480" y="90" text-anchor="middle" font-size="54" font-family="Arial, sans-serif" fill="#f0db8e">Gift Fund</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

53
backend/static/faq.js Normal file
View File

@@ -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);
});
})();

View File

@@ -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);
}
});
})();

View File

@@ -697,7 +697,17 @@ input[type="file"]:focus {
fill: currentColor; 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; display: inline-flex;
align-items: center; align-items: center;
gap: 0.38rem; gap: 0.38rem;
@@ -816,6 +826,437 @@ input[type="file"]:focus {
0 1px 0 rgba(255, 255, 255, 0.5) inset; 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 { .gallery-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1172,6 +1613,20 @@ input[type="file"]:focus {
padding: 0.74rem 1.2rem; 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 { body.has-route-modal {
overflow: hidden; overflow: hidden;
} }
@@ -1296,6 +1751,7 @@ body.has-route-modal {
} }
.gift-image-wrap { .gift-image-wrap {
position: relative;
max-width: min(640px, 100%); max-width: min(640px, 100%);
margin: 0 auto; margin: 0 auto;
} }
@@ -1309,6 +1765,18 @@ body.has-route-modal {
box-shadow: 0 12px 28px rgba(31, 58, 47, 0.18); 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 { .gift-caption {
margin: 0.8rem 0 0; margin: 0.8rem 0 0;
font-weight: 700; font-weight: 700;
@@ -1528,6 +1996,33 @@ body.has-route-modal {
} }
@media (max-width: 640px) { @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 { .container {
margin: 1.15rem auto 1.25rem; margin: 1.15rem auto 1.25rem;
} }
@@ -1609,9 +2104,62 @@ body.has-route-modal {
.route-open-btn { .route-open-btn {
width: 100%; 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) { @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 { .gift-reveal.is-active {
animation: none; animation: none;
} }

View File

@@ -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);
}
});
})();

View File

@@ -7,14 +7,15 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css', v='20260321a') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='styles.css', v='20260724v') }}" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div> <div>
<a class="brand" href="{{ url_for('welcome') if guest_name else url_for('landing') }}">{{ t('brand') }}</a> <a class="brand" href="{{ url_for('welcome') if guest_name else url_for('landing') }}">{{ t('brand') }}</a>
</div> </div>
<div class="toolbar"> <div class="toolbar {{ 'toolbar-authenticated' if guest_name else 'toolbar-login' }}">
<div class="toolbar-row toolbar-row-top">
<form method="post" action="{{ url_for('set_lang', code='de') }}"> <form method="post" action="{{ url_for('set_lang', code='de') }}">
<button class="btn btn-ghost" type="submit">DE</button> <button class="btn btn-ghost" type="submit">DE</button>
</form> </form>
@@ -22,6 +23,26 @@
<button class="btn btn-ghost" type="submit">EN</button> <button class="btn btn-ghost" type="submit">EN</button>
</form> </form>
{% if guest_name %} {% if guest_name %}
<button
class="btn btn-ghost toolbar-nav-btn toolbar-translate-btn"
type="button"
aria-label="{{ t('translator') }}"
title="{{ t('translator') }}"
data-translator-open
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 5.5A8.4 8.4 0 0 0 6.5 3.4 8.6 8.6 0 0 0 2 4.7v14.1a.75.75 0 0 0 1.02.7A8.1 8.1 0 0 1 6.5 18.6a7.1 7.1 0 0 1 4.98 2 .75.75 0 0 0 1.04 0 7.1 7.1 0 0 1 4.98-2 8.1 8.1 0 0 1 3.48.9.75.75 0 0 0 1.02-.7V4.7a8.6 8.6 0 0 0-4.5-1.3A8.4 8.4 0 0 0 12 5.5zm-1 12.9a8.7 8.7 0 0 0-4.5-1.3 9.6 9.6 0 0 0-3 .5V5.7a7.4 7.4 0 0 1 3-.8A6.9 6.9 0 0 1 11 6.7zm9.5-.8a9.6 9.6 0 0 0-3-.5 8.7 8.7 0 0 0-4.5 1.3V6.7a6.9 6.9 0 0 1 4.5-1.8 7.4 7.4 0 0 1 3 .8z" />
</svg>
</button>
<a class="btn btn-ghost toolbar-nav-btn toolbar-faq-btn" href="{{ url_for('faq') }}" aria-label="{{ t('faq') }}" title="{{ t('faq') }}">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2a10 10 0 1 0 10 10A10.01 10.01 0 0 0 12 2zm.15 16.25a1.25 1.25 0 1 1 1.25-1.25 1.25 1.25 0 0 1-1.25 1.25zm1.75-7.05-.75.48a1.36 1.36 0 0 0-.65 1.17v.4h-2v-.4a3.34 3.34 0 0 1 1.58-2.85l.75-.48a1.75 1.75 0 1 0-2.68-1.48h-2a3.75 3.75 0 1 1 5.75 3.16z" />
</svg>
</a>
{% endif %}
</div>
{% if guest_name %}
<div class="toolbar-row toolbar-row-bottom">
<div <div
class="toolbar-timer" class="toolbar-timer"
data-countdown-target="{{ wedding_countdown_iso }}" data-countdown-target="{{ wedding_countdown_iso }}"
@@ -62,7 +83,6 @@
<p class="toolbar-timer-subline" data-countdown-subline>{{ t('countdown_subline') }}</p> <p class="toolbar-timer-subline" data-countdown-subline>{{ t('countdown_subline') }}</p>
</div> </div>
</div> </div>
<div class="toolbar-session-actions">
<a class="btn btn-ghost toolbar-nav-btn" href="{{ url_for('guest_area') }}" aria-label="{{ t('dashboard') }}" title="{{ t('dashboard') }}"> <a class="btn btn-ghost toolbar-nav-btn" href="{{ url_for('guest_area') }}" aria-label="{{ t('dashboard') }}" title="{{ t('dashboard') }}">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h5v8H3v-8zm7 0h11v8H10v-8z" /> <path d="M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h5v8H3v-8zm7 0h11v8H10v-8z" />
@@ -76,6 +96,50 @@
</div> </div>
</header> </header>
{% if guest_name %}
<dialog
class="translator-dialog"
data-translator-dialog
data-source-lang="{{ 'de' if lang == 'de' else 'en' }}"
aria-labelledby="translator-title"
>
<div class="translator-sheet">
<div class="translator-head">
<div>
<h2 id="translator-title">{{ t('translator') }}</h2>
</div>
</div>
<div class="translator-direction" aria-label="{{ t('translator_direction') }}">
<span data-translator-source>{{ t('language_german') if lang == 'de' else t('language_english') }}</span>
<button class="translator-swap" type="button" data-translator-swap aria-label="{{ t('translator_swap') }}"></button>
<span data-translator-target>{{ t('language_english') if lang == 'de' else t('language_german') }}</span>
</div>
<label class="translator-label" for="translator-text">{{ t('translator_input_label') }}</label>
<textarea
id="translator-text"
class="translator-input"
rows="4"
maxlength="500"
placeholder="{{ t('translator_placeholder') }}"
data-translator-input
></textarea>
<div class="translator-quick-list" aria-label="{{ t('translator_quick_phrases') }}">
<button type="button" data-translator-phrase data-de="Schön, dich kennenzulernen." data-en="Nice to meet you.">{{ t('translator_phrase_meet') }}</button>
<button type="button" data-translator-phrase data-de="Möchtest du etwas trinken?" data-en="Would you like something to drink?">{{ t('translator_phrase_drink') }}</button>
<button type="button" data-translator-phrase data-de="Wo ist die Toilette?" data-en="Where is the restroom?">{{ t('translator_phrase_restroom') }}</button>
</div>
<p class="translator-privacy">{{ t('translator_privacy') }}</p>
<button class="btn translator-submit" type="button" data-translator-submit disabled>
{{ t('translator_submit') }} <span aria-hidden="true"></span>
</button>
</div>
</dialog>
{% endif %}
<main class="container"> <main class="container">
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
@@ -95,5 +159,6 @@
</footer> </footer>
<script src="{{ url_for('static', filename='dashboard-transition.js', v='20260303a') }}" defer></script> <script src="{{ url_for('static', filename='dashboard-transition.js', v='20260303a') }}" defer></script>
<script src="{{ url_for('static', filename='countdown.js', v='20260303c') }}" defer></script> <script src="{{ url_for('static', filename='countdown.js', v='20260303c') }}" defer></script>
<script src="{{ url_for('static', filename='translator.js', v='20260724b') }}" defer></script>
</body> </body>
</html> </html>

View File

@@ -169,6 +169,23 @@
</p> </p>
</article> </article>
<article class="legal-section" id="deepl-translation">
<h2>Translation with DeepL</h2>
<p>
The protected guest area provides an optional link to the external translation service DeepL, operated by DeepL
SE, Maarweg 165, 50825 Cologne, Germany. No connection to DeepL is established merely by opening this website.
</p>
<p>
Only after you click “Translate with DeepL” will the text you entered and technical connection data, such as your
IP address, be transmitted directly from your browser to DeepL. The text is not sent to or stored by our web
server. Please do not enter names, contact details, confidential information, or other personal data.
</p>
<p>
After the link is opened, DeepL's privacy policy and terms apply:
<a href="https://www.deepl.com/privacy" target="_blank" rel="noopener noreferrer">DeepL privacy policy</a>.
</p>
</article>
<article class="legal-section" id="photo-upload"> <article class="legal-section" id="photo-upload">
<h2>Photo Uploads</h2> <h2>Photo Uploads</h2>
<p> <p>
@@ -384,6 +401,25 @@
</p> </p>
</article> </article>
<article class="legal-section" id="deepl-uebersetzung">
<h2>Übersetzung mit DeepL</h2>
<p>
Im geschützten Gästebereich wird eine optionale Weiterleitung zum externen Übersetzungsdienst DeepL angeboten.
Anbieter ist die DeepL SE, Maarweg 165, 50825 Köln. Allein durch das Öffnen dieser Website wird noch keine
Verbindung zu DeepL hergestellt.
</p>
<p>
Erst wenn Sie „Mit DeepL übersetzen“ anklicken, werden der von Ihnen eingegebene Text und technische
Verbindungsdaten, beispielsweise Ihre IP-Adresse, direkt durch Ihren Browser an DeepL übermittelt. Der Text wird
nicht an unseren Webserver gesendet oder dort gespeichert. Bitte geben Sie keine Namen, Kontaktdaten,
vertraulichen Informationen oder sonstigen personenbezogenen Daten ein.
</p>
<p>
Nach dem Öffnen des Links gelten die Datenschutzbestimmungen und Nutzungsbedingungen von DeepL:
<a href="https://www.deepl.com/privacy" target="_blank" rel="noopener noreferrer">Datenschutz bei DeepL</a>.
</p>
</article>
<article class="legal-section" id="foto-upload"> <article class="legal-section" id="foto-upload">
<h2>Upload von Fotos durch Nutzer</h2> <h2>Upload von Fotos durch Nutzer</h2>
<p> <p>

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<section class="card faq-card">
<h1>{{ t('faq') }}</h1>
<p class="faq-intro">{{ t('faq_intro') }}</p>
<div class="faq-list">
{% for question_key, answer_key in faq_items %}
<details class="faq-item">
<summary>
<span>{{ t(question_key) }}</span>
<span class="faq-toggle" aria-hidden="true"></span>
</summary>
<div class="faq-answer">
<p>{{ t(answer_key) }}</p>
</div>
</details>
{% endfor %}
</div>
<a class="btn btn-ghost faq-tour-restart" href="{{ url_for('welcome', tour=1) }}">{{ t('tour_restart') }}</a>
</section>
<script src="{{ url_for('static', filename='faq.js', v='20260724a') }}" defer></script>
{% endblock %}

View File

@@ -63,7 +63,7 @@
<div class="hotel-actions"> <div class="hotel-actions">
<a class="btn" href="{{ hotel.website_url }}" target="_blank" rel="noopener">{{ t('hotel_visit_website') }}</a> <a class="btn" href="{{ hotel.website_url }}" target="_blank" rel="noopener">{{ t('hotel_visit_website') }}</a>
<a <a
class="btn btn-ghost" class="btn btn-ghost route-location-btn"
href="{{ hotel.current_route_url }}" href="{{ hotel.current_route_url }}"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
@@ -199,6 +199,7 @@
loading="lazy" loading="lazy"
decoding="async" decoding="async"
> >
<span class="gift-money-label">{{ t('gifts_money_label') }}</span>
</div> </div>
<p class="gift-caption">{{ t('gifts_caption') }}</p> <p class="gift-caption">{{ t('gifts_caption') }}</p>
<p>{{ t('gifts_text') }}</p> <p>{{ t('gifts_text') }}</p>
@@ -318,7 +319,7 @@
</button> </button>
<div class="map-embed-target" data-map-embed-target></div> <div class="map-embed-target" data-map-embed-target></div>
<div class="location-actions"> <div class="location-actions">
<a class="btn btn-ghost" href="{{ location_route_url }}" target="_blank" rel="noopener" data-location-route-live>{{ t('route_from_current') }}</a> <a class="btn btn-ghost route-location-btn" href="{{ location_route_url }}" target="_blank" rel="noopener" data-location-route-live>{{ t('route_from_current') }}</a>
<a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a> <a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a>
</div> </div>
</div> </div>

View File

@@ -11,4 +11,87 @@
</div> </div>
</section> </section>
<p class="hero-hint-below">{{ welcome_hint }}</p> <p class="hero-hint-below">{{ welcome_hint }}</p>
{% if show_onboarding %}
<dialog
class="onboarding-dialog"
data-onboarding-dialog
data-complete-url="{{ url_for('complete_onboarding') }}"
data-progress-template="{{ t('tour_progress') }}"
aria-labelledby="onboarding-title"
>
<div class="onboarding-shell">
<div class="onboarding-progress" aria-hidden="true">
{% for _ in range(6) %}<span data-onboarding-dot></span>{% endfor %}
</div>
<p class="onboarding-step-label" data-onboarding-progress></p>
<h2 id="onboarding-title">{{ t('tour_title') }}</h2>
<div class="onboarding-slides">
<section class="onboarding-slide" data-onboarding-slide>
<span class="onboarding-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h5v8H3v-8zm7 0h11v8H10v-8z" />
</svg>
</span>
<h3>{{ t('tour_dashboard_title') }}</h3>
<p>{{ t('tour_dashboard_text') }}</p>
</section>
<section class="onboarding-slide" data-onboarding-slide hidden>
<span class="onboarding-icon" aria-hidden="true"></span>
<h3>{{ t('tour_rsvp_title') }}</h3>
<p>{{ t('tour_rsvp_text') }}</p>
</section>
<section class="onboarding-slide" data-onboarding-slide hidden>
<span class="onboarding-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M9 4 7.6 6H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-3.6L15 4H9zm3 4.2A4.8 4.8 0 1 1 7.2 13 4.8 4.8 0 0 1 12 8.2zm0 2A2.8 2.8 0 1 0 14.8 13 2.8 2.8 0 0 0 12 10.2z" />
</svg>
</span>
<h3>{{ t('tour_photos_title') }}</h3>
<p>{{ t('tour_photos_text') }}</p>
</section>
<section class="onboarding-slide" data-onboarding-slide hidden>
<span class="onboarding-icon onboarding-icon-route" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<circle cx="11" cy="13" r="7.5" />
<circle cx="11" cy="13" r="3.5" />
<path d="M20.5 3.5 11 13M17 7l.2-3.6M17 7l3.6-.2" />
</svg>
</span>
<h3>{{ t('tour_routes_title') }}</h3>
<p>{{ t('tour_routes_text') }}</p>
</section>
<section class="onboarding-slide" data-onboarding-slide hidden>
<span class="onboarding-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M12 5.5A8.4 8.4 0 0 0 6.5 3.4 8.6 8.6 0 0 0 2 4.7v14.1a.75.75 0 0 0 1.02.7A8.1 8.1 0 0 1 6.5 18.6a7.1 7.1 0 0 1 4.98 2 .75.75 0 0 0 1.04 0 7.1 7.1 0 0 1 4.98-2 8.1 8.1 0 0 1 3.48.9.75.75 0 0 0 1.02-.7V4.7a8.6 8.6 0 0 0-4.5-1.3A8.4 8.4 0 0 0 12 5.5zm-1 12.9a8.7 8.7 0 0 0-4.5-1.3 9.6 9.6 0 0 0-3 .5V5.7a7.4 7.4 0 0 1 3-.8A6.9 6.9 0 0 1 11 6.7zm9.5-.8a9.6 9.6 0 0 0-3-.5 8.7 8.7 0 0 0-4.5 1.3V6.7a6.9 6.9 0 0 1 4.5-1.8 7.4 7.4 0 0 1 3 .8z" />
</svg>
</span>
<h3>{{ t('tour_translator_title') }}</h3>
<p>{{ t('tour_translator_text') }}</p>
</section>
<section class="onboarding-slide" data-onboarding-slide hidden>
<span class="onboarding-icon" aria-hidden="true">?</span>
<h3>{{ t('tour_faq_title') }}</h3>
<p>{{ t('tour_faq_text') }}</p>
</section>
</div>
<div class="onboarding-actions">
<div>
<button class="btn btn-ghost" type="button" data-onboarding-back hidden>{{ t('tour_back') }}</button>
<button
class="btn"
type="button"
data-onboarding-next
data-next-label="{{ t('tour_next') }}"
data-finish-label="{{ t('tour_finish') }}"
>{{ t('tour_next') }}</button>
</div>
</div>
</div>
</dialog>
{% endif %}
<script src="{{ url_for('static', filename='onboarding.js', v='20260724a') }}" defer></script>
{% endblock %} {% endblock %}

Binary file not shown.