fixed language switching in the frontend
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
29
resources/js/lib/__tests__/localizedPath.test.ts
Normal file
29
resources/js/lib/__tests__/localizedPath.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
57
resources/js/lib/localizedPath.ts
Normal file
57
resources/js/lib/localizedPath.ts
Normal 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}`;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user