From df53420f3b139e7788371e2b8c279c67d6095c9e Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 20:52:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(seo):=20Zweisprachige=20Meta-Tags=20und=20?= =?UTF-8?q?hreflang=20f=C3=BCr=20DE/EN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEO-Texte in i18n, dynamische Meta-Updates beim Sprachwechsel, hreflang-Links und ?lng-Parameter; PWA-Manifest zweisprachig. Co-authored-by: Cursor --- client/index.html | 5 ++- client/src/i18n/index.ts | 6 ++- client/src/i18n/locales/de.json | 7 ++++ client/src/i18n/locales/en.json | 7 ++++ client/src/utils/seo.ts | 70 +++++++++++++++++++++++++++++++++ client/vite.config.ts | 4 +- 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 client/src/utils/seo.ts diff --git a/client/index.html b/client/index.html index 83454c3..f35383a 100644 --- a/client/index.html +++ b/client/index.html @@ -5,11 +5,14 @@ - + + + + diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index c3f8496..62d306d 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -3,6 +3,7 @@ import { initReactI18next } from 'react-i18next' import LanguageDetector from 'i18next-browser-languagedetector' import enTranslation from './locales/en.json' import deTranslation from './locales/de.json' +import { initSeo } from '../utils/seo.js' i18n .use(LanguageDetector) @@ -17,9 +18,12 @@ i18n escapeValue: false // React already escapes values (prevents XSS) }, detection: { - order: ['localStorage', 'navigator'], + order: ['querystring', 'localStorage', 'navigator'], + lookupQuerystring: 'lng', caches: ['localStorage'] } }) +initSeo(i18n) + export default i18n diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 3aa36ed..c465669 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -332,6 +332,7 @@ "color_scheme_dark": "Dunkel", "share_title": "Logbuch teilen (Schreibgeschützt)", "share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).", + "share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.", "share_enable": "Öffentlichen Link aktivieren", "share_copied": "Link kopiert!", "share_copy_btn": "Link kopieren", @@ -560,6 +561,12 @@ "body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!" } } + }, + "seo": { + "title": "Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)", + "description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA.", + "keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung", + "ogImageAlt": "Kapteins Daagbok Logo" } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 6e00af9..93a3add 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -332,6 +332,7 @@ "color_scheme_dark": "Dark", "share_title": "Share Logbook (Read-Only)", "share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).", + "share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.", "share_enable": "Enable Public Link", "share_copied": "Link copied!", "share_copy_btn": "Copy Link", @@ -560,6 +561,12 @@ "body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!" } } + }, + "seo": { + "title": "Kapteins Daagbok – Free Digital Yacht Logbook (Ad-Free)", + "description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.", + "keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA", + "ogImageAlt": "Kapteins Daagbok logo" } } } diff --git a/client/src/utils/seo.ts b/client/src/utils/seo.ts new file mode 100644 index 0000000..114115b --- /dev/null +++ b/client/src/utils/seo.ts @@ -0,0 +1,70 @@ +import type { i18n as I18nInstance } from 'i18next' + +const SITE_ORIGIN = 'https://kapteins-daagbok.eu' + +export type SeoLang = 'de' | 'en' + +let i18nRef: I18nInstance | null = null + +export function normalizeSeoLang(lng: string): SeoLang { + return lng.startsWith('de') ? 'de' : 'en' +} + +function setMeta(attr: 'name' | 'property', key: string, content: string) { + let el = document.querySelector(`meta[${attr}="${key}"]`) + if (!el) { + el = document.createElement('meta') + el.setAttribute(attr, key) + document.head.appendChild(el) + } + el.setAttribute('content', content) +} + +function syncLanguageUrl(lang: SeoLang) { + const url = new URL(window.location.href) + url.searchParams.set('lng', lang) + const next = `${url.pathname}${url.search}${url.hash}` + window.history.replaceState({}, '', next) +} + +export function updatePageSeo(lng?: string) { + if (!i18nRef?.isInitialized) return + + const lang = normalizeSeoLang(lng ?? i18nRef.language) + document.documentElement.lang = lang + + const title = i18nRef.t('seo.title') + document.title = title + + const description = i18nRef.t('seo.description') + const keywords = i18nRef.t('seo.keywords') + const imageAlt = i18nRef.t('seo.ogImageAlt') + + setMeta('name', 'description', description) + setMeta('name', 'keywords', keywords) + setMeta('property', 'og:title', title) + setMeta('property', 'og:description', description) + setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US') + setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE') + setMeta('name', 'twitter:title', title) + setMeta('name', 'twitter:description', description) + setMeta('property', 'og:image:alt', imageAlt) + setMeta('name', 'twitter:image:alt', imageAlt) + + syncLanguageUrl(lang) +} + +export function initSeo(i18n: I18nInstance) { + i18nRef = i18n + i18n.on('initialized', () => updatePageSeo()) + i18n.on('languageChanged', (lng) => updatePageSeo(lng)) + if (i18n.isInitialized) { + updatePageSeo() + } +} + +export function hreflangUrl(lang: SeoLang): string { + return `${SITE_ORIGIN}/?lng=${lang}` +} + +export const seoSiteOrigin = SITE_ORIGIN diff --git a/client/vite.config.ts b/client/vite.config.ts index 0d64bbe..362d4ab 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -49,7 +49,9 @@ export default defineConfig({ manifest: { name: 'Kapteins Daagbok', short_name: 'Daagbok', - description: 'Free, ad-free maritime logbook with E2E encryption and Passkeys', + lang: 'de', + description: + 'Digitales Yacht-Logbuch — E2E-verschlüsselt, offline-fähig. Digital yacht logbook — E2E encrypted, offline-capable PWA.', theme_color: '#1e293b', background_color: '#0f172a', display: 'standalone',