fixed language switching in the frontend

This commit is contained in:
Codex Agent
2025-12-02 13:31:58 +01:00
parent 28539754a7
commit dd3198cb79
20 changed files with 395 additions and 203 deletions

View File

@@ -11,9 +11,28 @@ import { Toaster } from 'react-hot-toast';
import { ConsentProvider } from './contexts/consent';
import CookieBanner from '@/components/consent/CookieBanner';
import React from 'react';
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const LocaleSync: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// usePage is only available inside Inertia-provided tree; guard for SSR/raw mounts
try {
const { props } = usePage<{ locale?: string }>();
useEffect(() => {
if (props.locale && i18n.language !== props.locale) {
i18n.changeLanguage(props.locale);
}
}, [props.locale]);
} catch (error) {
// noop will be hydrated once Inertia provides context
}
return <>{children}</>;
};
createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName,
resolve: (name) =>
@@ -46,7 +65,9 @@ createInertiaApp({
root.render(
<ConsentProvider>
<I18nextProvider i18n={i18n}>
<App {...props} />
<LocaleSync>
<App {...props} />
</LocaleSync>
<CookieBanner />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider>

View File

@@ -1,75 +1,21 @@
import { usePage } from '@inertiajs/react';
import { buildLocalizedPath, defaultLocaleRewrites } from '@/lib/localizedPath';
import { useLocale } from './useLocale';
type LocalizedPathInput = string | null | undefined;
export const useLocalizedRoutes = () => {
const { props } = usePage<{ supportedLocales?: string[] }>();
const { props } = usePage<{ supportedLocales?: string[]; defaultLocale?: string }>();
const locale = useLocale();
const supportedLocales = props.supportedLocales ?? [];
const defaultLocale = props.defaultLocale ?? 'de';
const fallbackLocale = (() => {
if (locale && supportedLocales.includes(locale)) {
return locale;
}
if (supportedLocales.length > 0) {
return supportedLocales[0];
}
return 'de';
})();
const pathRewrites: Record<string, Record<string, string>> = {
'/kontakt': { en: '/contact' },
'/contact': { de: '/kontakt' },
'/so-funktionierts': { en: '/how-it-works' },
'/how-it-works': { de: '/so-funktionierts' },
'/anlaesse': { en: '/occasions' },
'/anlaesse/hochzeit': { en: '/occasions/wedding' },
'/anlaesse/geburtstag': { en: '/occasions/birthday' },
'/anlaesse/firmenevent': { en: '/occasions/corporate-event' },
'/anlaesse/konfirmation': { en: '/occasions/confirmation' },
'/occasions/wedding': { de: '/anlaesse/hochzeit' },
'/occasions/birthday': { de: '/anlaesse/geburtstag' },
'/occasions/corporate-event': { de: '/anlaesse/firmenevent' },
'/occasions/confirmation': { de: '/anlaesse/konfirmation' },
};
const rewriteForLocale = (path: string, targetLocale: string): string => {
const key = path === '' ? '/' : path;
const normalizedKey = key.startsWith('/') ? key : `/${key}`;
const rewrites = pathRewrites[normalizedKey] ?? {};
return rewrites[targetLocale] ?? normalizedKey;
};
const localizedPath = (path: LocalizedPathInput, targetLocale?: string) => {
if (typeof path !== 'string' || path.trim().length === 0) {
console.error('[useLocalizedRoutes] Invalid path input detected', {
path,
locale,
stack: new Error().stack,
});
return `/${fallbackLocale}`;
}
const nextLocale = targetLocale && supportedLocales.includes(targetLocale)
? targetLocale
: fallbackLocale;
const trimmed = path.trim();
const [rawPath, rawQuery] = trimmed.split('?');
const normalizedPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const rewritten = rewriteForLocale(normalizedPath, nextLocale);
const base = rewritten === '/' ? `/${nextLocale}` : `/${nextLocale}${rewritten}`;
const sanitisedBase = base.replace(/\/{2,}/g, '/');
const query = rawQuery ? `?${rawQuery}` : '';
return `${sanitisedBase}${query}`;
};
const localizedPath = (path: string | null | undefined, targetLocale?: string) =>
buildLocalizedPath(
path,
targetLocale ?? locale,
supportedLocales,
defaultLocale,
defaultLocaleRewrites,
);
return { localizedPath };
};

View File

@@ -1,31 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
const isDev = typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV);
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'de',
debug: isDev,
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/lang/{{lng}}/{{ns}}.json',
},
ns: ['marketing', 'auth', 'profile', 'common', 'legal'],
defaultNS: 'marketing',
supportedLngs: ['de', 'en'],
detection: {
order: ['path', 'cookie', 'localStorage', 'htmlTag', 'subdomain'],
lookupFromPathIndex: 0,
caches: ['cookie'],
},
});
export default i18n;

View File

@@ -1,22 +1,29 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n.on('languageChanged', (lng) => {
console.log('i18n languageChanged event:', lng);
console.trace('languageChanged trace for', lng);
});
const supportedLngs = ['de', 'en'];
const fallbackLng = 'de';
const detection = {
order: ['path', 'localStorage', 'cookie', 'htmlTag', 'navigator'],
lookupFromPathIndex: 0,
caches: ['localStorage'],
};
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
lng: localStorage.getItem('i18nextLng') || 'de',
fallbackLng: 'de',
supportedLngs: ['de', 'en'],
fallbackLng,
supportedLngs,
ns: ['marketing', 'auth', 'common', 'legal'],
defaultNS: 'marketing',
debug: import.meta.env.DEV,
load: 'languageOnly',
detection,
interpolation: {
escapeValue: false,
},
@@ -32,4 +39,4 @@ i18n
},
});
export default i18n;
export default i18n;

View File

@@ -26,8 +26,7 @@ type PageProps = {
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
const page = usePage<PageProps>();
const { url } = page;
const { t } = useTranslation('marketing');
const i18n = useTranslation();
const { t, i18n, ready } = useTranslation(['marketing', 'common', 'legal', 'auth']);
const { locale, analytics, supportedLocales = ['de', 'en'], appUrl, auth } = page.props;
const user = auth?.user ?? null;
const { localizedPath } = useLocalizedRoutes();
@@ -94,11 +93,20 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
};
useEffect(() => {
if (locale && i18n.i18n.language !== locale) {
i18n.i18n.changeLanguage(locale);
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
}, [locale, i18n]);
if (!ready) {
return (
<div className="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-50 flex items-center justify-center">
<Head title="Fotospiel" />
<span className="text-sm text-gray-500 dark:text-gray-400">Lade Inhalte </span>
</div>
);
}
const marketing = page.props.translations?.marketing ?? {};
const getString = (key: string, fallback: string) => {
@@ -132,11 +140,13 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
const targetPath = localizedPath(relativePath, nextLocale);
const targetUrl = `${targetPath}${rawQuery ? `?${rawQuery}` : ''}`;
i18n.i18n.changeLanguage(nextLocale);
setMobileMenuOpen(false);
router.visit(targetUrl, {
replace: true,
preserveState: false,
onSuccess: () => {
i18n.changeLanguage(nextLocale);
},
});
};

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { buildLocalizedPath, defaultLocaleRewrites } from '../localizedPath';
describe('buildLocalizedPath', () => {
const supported = ['de', 'en'];
it('prefixes path with locale', () => {
expect(buildLocalizedPath('/packages', 'en', supported)).toBe('/en/packages');
expect(buildLocalizedPath('/packages', 'de', supported)).toBe('/de/packages');
});
it('applies rewrite rules between locales', () => {
expect(buildLocalizedPath('/kontakt', 'en', supported, 'de', defaultLocaleRewrites)).toBe('/en/contact');
expect(buildLocalizedPath('/contact', 'de', supported, 'de', defaultLocaleRewrites)).toBe('/de/kontakt');
});
it('preserves query strings', () => {
expect(buildLocalizedPath('/contact?ref=ad', 'de', supported)).toBe('/de/kontakt?ref=ad');
});
it('falls back to default locale when target not supported', () => {
expect(buildLocalizedPath('/demo', 'fr', supported)).toBe('/de/demo');
});
it('handles empty or invalid paths gracefully', () => {
expect(buildLocalizedPath('', 'en', supported)).toBe('/de');
expect(buildLocalizedPath(undefined, 'en', supported)).toBe('/de');
});
});

View File

@@ -0,0 +1,57 @@
export type LocaleRewriteMap = Record<string, Record<string, string>>;
export const defaultLocaleRewrites: LocaleRewriteMap = {
'/': {},
'/kontakt': { en: '/contact' },
'/contact': { de: '/kontakt' },
'/so-funktionierts': { en: '/how-it-works' },
'/how-it-works': { de: '/so-funktionierts' },
'/anlaesse': { en: '/occasions' },
'/anlaesse/hochzeit': { en: '/occasions/wedding' },
'/anlaesse/geburtstag': { en: '/occasions/birthday' },
'/anlaesse/firmenevent': { en: '/occasions/corporate-event' },
'/anlaesse/konfirmation': { en: '/occasions/confirmation' },
'/occasions/wedding': { de: '/anlaesse/hochzeit' },
'/occasions/birthday': { de: '/anlaesse/geburtstag' },
'/occasions/corporate-event': { de: '/anlaesse/firmenevent' },
'/occasions/confirmation': { de: '/anlaesse/konfirmation' },
};
const sanitizePath = (input: string): string => {
if (!input || input.trim().length === 0) {
return '/';
}
const withLeading = input.startsWith('/') ? input : `/${input}`;
const withoutTrailing = withLeading.replace(/\/{2,}/g, '/');
return withoutTrailing;
};
export const buildLocalizedPath = (
path: string | null | undefined,
targetLocale: string | undefined,
supportedLocales: string[],
defaultLocale = 'de',
rewrites: LocaleRewriteMap = defaultLocaleRewrites,
): string => {
const fallbackLocale = supportedLocales.length > 0 ? supportedLocales[0] : defaultLocale;
const nextLocale = targetLocale && supportedLocales.includes(targetLocale)
? targetLocale
: fallbackLocale;
if (typeof path !== 'string' || path.trim().length === 0) {
return `/${fallbackLocale}`;
}
const trimmed = path.trim();
const [rawPath, rawQuery] = trimmed.split('?');
const normalizedPath = sanitizePath(rawPath);
const rewritesForPath = rewrites[normalizedPath] ?? {};
const rewrittenPath = rewritesForPath[nextLocale] ?? normalizedPath;
const base = rewrittenPath === '/' ? `/${nextLocale}` : `/${nextLocale}${rewrittenPath}`;
const sanitisedBase = base.replace(/\/{2,}/g, '/');
const query = rawQuery ? `?${rawQuery}` : '';
return `${sanitisedBase}${query}`;
};

View File

@@ -38,10 +38,30 @@ const iconByUseCase: Record<string, React.ReactNode> = {
};
const HowItWorks: React.FC = () => {
const { t } = useTranslation('marketing');
const { t, ready } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const hero = t('how_it_works_page.hero', { returnObjects: true }) as {
if (!ready) {
return (
<MarketingLayout title="Fotospiel">
<Head title="Fotospiel" />
<div className="container mx-auto px-4 py-16 text-center text-gray-600 dark:text-gray-300">
<p className="text-lg">Lade Inhalte </p>
</div>
</MarketingLayout>
);
}
const hero = t('how_it_works_page.hero', {
returnObjects: true,
defaultValue: {
title: 'So funktioniert die Fotospiel App',
subtitle: '',
primaryCta: '',
secondaryCta: '',
stats: [],
},
}) as {
title: string;
subtitle: string;
primaryCta: string;
@@ -49,41 +69,67 @@ const HowItWorks: React.FC = () => {
stats: HeroStat[];
};
const experience = t('how_it_works_page.experience', { returnObjects: true }) as {
const experience = t('how_it_works_page.experience', {
returnObjects: true,
defaultValue: {
host: { label: '', intro: '', steps: [], callouts: [] },
guest: { label: '', intro: '', steps: [], callouts: [] },
},
}) as {
host: ExperienceGroup;
guest: ExperienceGroup;
};
const pillars = t('how_it_works_page.pillars', { returnObjects: true }) as Array<{
const pillars = t('how_it_works_page.pillars', {
returnObjects: true,
defaultValue: [],
}) as Array<{
title: string;
description: string;
}>;
const timeline = t('how_it_works_page.timeline', { returnObjects: true }) as TimelineItem[];
const timeline = t('how_it_works_page.timeline', {
returnObjects: true,
defaultValue: [],
}) as TimelineItem[];
const useCases = t('how_it_works_page.use_cases', { returnObjects: true }) as {
const useCases = t('how_it_works_page.use_cases', {
returnObjects: true,
defaultValue: { title: '', description: '', tabs: [] },
}) as {
title: string;
description: string;
tabs: UseCase[];
};
const checklist = t('how_it_works_page.checklist', { returnObjects: true }) as {
const checklist = t('how_it_works_page.checklist', {
returnObjects: true,
defaultValue: { title: '', items: [], cta: '' },
}) as {
title: string;
items: string[];
cta: string;
};
const faq = t('how_it_works_page.faq', { returnObjects: true }) as {
const faq = t('how_it_works_page.faq', {
returnObjects: true,
defaultValue: { title: '', items: [] },
}) as {
title: string;
items: FaqItem[];
};
const support = t('how_it_works_page.support', { returnObjects: true }) as {
const support = t('how_it_works_page.support', {
returnObjects: true,
defaultValue: { title: '', description: '', cta: '' },
}) as {
title: string;
description: string;
cta: string;
};
const heroStats = Array.isArray(hero.stats) ? hero.stats : [];
return (
<MarketingLayout title={hero.title}>
<Head title={hero.title} />
@@ -116,7 +162,7 @@ const HowItWorks: React.FC = () => {
</div>
<div className="flex-1">
<div className="grid gap-4 sm:grid-cols-3">
{hero.stats.map((stat) => (
{heroStats.map((stat) => (
<Card key={stat.label} className="border-pink-100/70 shadow-none dark:border-pink-900/40">
<CardHeader className="pb-2">
<CardTitle className="text-2xl font-semibold text-pink-600 dark:text-pink-300">

View File

@@ -1016,7 +1016,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
</div>
</section>
{/* Details overlay */}
{/* Details overlay */}
{selectedPackage && (
isMobile ? (
<Sheet open={open} onOpenChange={setOpen}>
@@ -1047,8 +1047,15 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
const handleDetailAutoFocus = (event: Event) => {
event.preventDefault();
dialogScrollRef.current?.scrollTo({ top: 0 });
dialogHeadingRef.current?.focus();
// Guard in case refs are not in scope when autofocusing
if (typeof dialogScrollRef !== 'undefined') {
dialogScrollRef.current?.scrollTo({ top: 0 });
}
if (typeof dialogHeadingRef !== 'undefined') {
dialogHeadingRef.current?.focus();
}
};
Packages.layout = (page: React.ReactNode) => page;

View File

@@ -2,6 +2,8 @@ import { createInertiaApp } from '@inertiajs/react';
import createServer from '@inertiajs/react/server';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -12,7 +14,17 @@ createServer((page) =>
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup: ({ App, props }) => {
return <App {...props} />;
const locale = (props.initialPage?.props as Record<string, unknown> | undefined)?.locale as string | undefined;
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
return (
<I18nextProvider i18n={i18n}>
<App {...props} />
</I18nextProvider>
);
},
}),
);