Add Scandinavian i18n (da/sv/nb) via DeepL pipeline.

Integrate new locale bundles, language cycling in the UI, SEO hreflang tags, and localized beta flyer HTML variants with scripts for batch translation and key validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 15:53:43 +02:00
co-authored by Cursor
parent 2e656dc6b2
commit 3749f87c1d
30 changed files with 3975 additions and 42 deletions
+3 -3
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
@@ -209,8 +210,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
i18n.changeLanguage(getNextLanguage(i18n.language))
}
const copyToClipboard = () => {
@@ -596,7 +596,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<button
type="button"
+3 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
@@ -48,8 +49,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
i18n.changeLanguage(getNextLanguage(i18n.language))
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
@@ -87,7 +87,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</header>
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
@@ -308,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
i18n.changeLanguage(getNextLanguage(i18n.language))
}
if (recoveryPhrase) {
@@ -511,7 +512,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</div>
+2 -2
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
@@ -193,8 +194,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
i18n.changeLanguage(getNextLanguage(i18n.language))
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
+9 -9
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
@@ -48,9 +49,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
if (!res.ok) {
if (res.status === 410) {
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
}
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
}
const data = await res.json()
@@ -136,15 +137,14 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
i18n.changeLanguage(getNextLanguage(i18n.language))
}
if (loading) {
return (
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Ship className="header-logo spin" size={48} />
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
<p>{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
</div>
)
}
@@ -153,10 +153,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}</h2>
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
{isGermanLocale(i18n.language) ? 'Erneut versuchen' : 'Retry'}
</button>
</div>
)
@@ -173,7 +173,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
<h2>{logbookTitle}</h2>
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
<span>{isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
</p>
</div>
</div>
@@ -181,7 +181,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
<div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</header>