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

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Support\LocaleConfig;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Session;
@@ -11,23 +12,13 @@ class LocaleController extends Controller
public function set(Request $request)
{
$locale = $request->input('locale');
$supportedLocales = array_values(array_unique(array_filter([
config('app.locale'),
config('app.fallback_locale'),
...array_filter(array_map(
static fn ($value) => trim((string) $value),
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
)),
])));
$supportedLocales = LocaleConfig::normalized();
$canonical = LocaleConfig::canonicalize($locale);
if (empty($supportedLocales)) {
$supportedLocales = ['de', 'en'];
}
if (in_array($locale, $supportedLocales)) {
App::setLocale($locale);
Session::put('locale', $locale);
Session::put('preferred_locale', $locale);
if (in_array($canonical, $supportedLocales, true)) {
App::setLocale($canonical);
Session::put('locale', $canonical);
Session::put('preferred_locale', $canonical);
}
if ($request->expectsJson()) {

View File

@@ -105,7 +105,7 @@ class MarketingController extends Controller
public function contactView(Request $request)
{
$locale = app()->getLocale();
$locale = \App\Support\LocaleConfig::canonicalize((string) ($request->route('locale') ?? app()->getLocale()));
$secondSegment = $request->segment(2);
$slug = $secondSegment ? '/'.trim((string) $secondSegment, '/') : '/';

View File

@@ -36,6 +36,7 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\SetLocaleFromRequest::class,
\App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
],

View File

@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use App\Support\LocaleConfig;
class HandleInertiaRequests extends Middleware
{
@@ -38,19 +39,10 @@ class HandleInertiaRequests extends Middleware
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
$supportedLocales = collect(explode(',', (string) env('APP_SUPPORTED_LOCALES', 'de,en')))
->map(fn ($l) => trim((string) $l))
->filter()
->unique()
->values()
->all();
$supportedLocales = LocaleConfig::normalized();
$defaultLocale = LocaleConfig::canonicalize($supportedLocales[0] ?? null);
if (empty($supportedLocales)) {
$supportedLocales = array_values(array_unique(array_filter([
config('app.locale'),
config('app.fallback_locale'),
])));
}
$currentLocale = LocaleConfig::canonicalize($request->route('locale') ?? $request->segment(1) ?? app()->getLocale());
return [
...parent::share($request),
@@ -60,9 +52,10 @@ class HandleInertiaRequests extends Middleware
'user' => $request->user(),
],
'supportedLocales' => $supportedLocales,
'defaultLocale' => $defaultLocale,
'appUrl' => rtrim(config('app.url'), '/'),
'sidebarOpen' => $request->cookie('sidebar_state', 'false') === 'true',
'locale' => app()->getLocale(),
'locale' => $currentLocale,
'translations' => [
'marketing' => __('marketing'),
'auth' => __('auth'),

View File

@@ -6,16 +6,18 @@ use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Session;
use App\Support\LocaleConfig;
class SetLocaleFromRequest
{
public function handle(Request $request, Closure $next)
{
$supportedLocales = $this->supportedLocales();
$supportedLocales = LocaleConfig::normalized();
$locale = (string) $request->route('locale');
$locale = (string) ($request->route('locale') ?? $request->segment(1));
$normalizedLocale = LocaleConfig::canonicalize($locale);
if (! $locale || ! in_array($locale, $supportedLocales, true)) {
if (! $locale || ! in_array($normalizedLocale, $supportedLocales, true)) {
$preferred = Session::get('preferred_locale');
if ($preferred && in_array($preferred, $supportedLocales, true)) {
@@ -27,35 +29,11 @@ class SetLocaleFromRequest
return $next($request);
}
App::setLocale($locale);
Session::put('preferred_locale', $locale);
Session::put('locale', $locale);
$request->attributes->set('preferred_locale', $locale);
App::setLocale($normalizedLocale);
Session::put('preferred_locale', $normalizedLocale);
Session::put('locale', $normalizedLocale);
$request->attributes->set('preferred_locale', $normalizedLocale);
return $next($request);
}
/**
* @return array<int, string>
*/
private function supportedLocales(): array
{
$configured = array_filter(array_map(
static fn ($value) => trim((string) $value),
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
));
if (empty($configured)) {
$configured = array_filter([
config('app.locale'),
config('app.fallback_locale'),
]);
}
if (empty($configured)) {
$configured = ['de', 'en'];
}
return array_values(array_unique($configured));
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Support;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class LocaleConfig
{
/**
* Return configured locales from env/config as provided (may include region codes).
* @return array<int, string>
*/
public static function configured(): array
{
$configured = array_filter(array_map(
static fn ($value) => trim((string) $value),
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
));
$baseLocales = array_filter([
config('app.locale'),
config('app.fallback_locale'),
]);
$fallback = ['de', 'en'];
return array_values(array_unique([
...$configured,
...$baseLocales,
...$fallback,
]));
}
/**
* Return normalized short codes (language only, lowercase).
* @return array<int, string>
*/
public static function normalized(): array
{
return collect(static::configured())
->map(fn (string $code) => Str::of($code)->lower()->before('-')->before('_')->toString())
->filter()
->unique()
->values()
->all();
}
/**
* Pattern for route constraints: accept both configured and normalized variants.
*/
public static function routePattern(): string
{
$all = collect(array_merge(static::configured(), static::normalized()))
->map(fn (string $code) => preg_quote($code, '/'))
->unique()
->values()
->all();
return implode('|', $all);
}
/**
* Canonicalize any incoming locale to a normalized short code if supported, otherwise fallback.
*/
public static function canonicalize(?string $locale): string
{
$normalized = static::normalized();
$fallback = Arr::first($normalized, default: 'de');
if (! $locale) {
return $fallback;
}
$short = Str::of($locale)->lower()->before('-')->before('_')->toString();
return in_array($short, $normalized, true) ? $short : $fallback;
}
}

View File

@@ -1,5 +1,6 @@
{
"impressum": "Impressum",
"headline": "Rechtliches",
"datenschutz": "Datenschutzerklärung",
"impressum_title": "Impressum - Fotospiel",
"datenschutz_title": "Datenschutzerklärung - Fotospiel",

View File

@@ -402,7 +402,8 @@
},
"footer": {
"company": "S.E.B. Fotografie",
"rights_reserved": "Alle Rechte vorbehalten"
"rights_reserved": "Alle Rechte vorbehalten",
"social": "Social"
},
"register": {
"free": "Kostenlos"

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,
},

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>
);
},
}),
);

View File

@@ -17,6 +17,7 @@ use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Http\Controllers\TenantAdminAuthController;
use App\Http\Controllers\TenantAdminGoogleController;
use App\Models\Package;
use App\Support\LocaleConfig;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -24,20 +25,8 @@ use Inertia\Inertia;
require __DIR__.'/auth.php';
require __DIR__.'/settings.php';
$configuredLocales = array_filter(array_map(
static fn ($value) => trim((string) $value),
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
));
if (empty($configuredLocales)) {
$configuredLocales = array_filter([
config('app.locale'),
config('app.fallback_locale'),
]);
}
$supportedLocales = array_values(array_unique($configuredLocales ?: ['de', 'en']));
$localePattern = implode('|', $supportedLocales);
$supportedLocales = LocaleConfig::normalized();
$localePattern = LocaleConfig::routePattern();
$rewritePath = static function (string $path, string $locale): string {
$normalized = '/'.ltrim($path, '/');
@@ -82,9 +71,6 @@ $determinePreferredLocale = static function (Request $request) use ($supportedLo
Route::prefix('{locale}')
->where(['locale' => $localePattern])
->middleware([
\App\Http\Middleware\SetLocaleFromRequest::class,
])
->group(function () {
Route::get('/', [MarketingController::class, 'index'])->name('marketing.home');

View File

@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Marketing;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class MarketingLocaleRoutingTest extends TestCase
{
use RefreshDatabase;
public function test_home_route_accepts_german_locale_prefix(): void
{
$response = $this->get('/de');
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/Home')
->where('locale', 'de')
->where('supportedLocales.0', 'de')
);
}
public function test_home_route_accepts_english_locale_prefix(): void
{
$response = $this->get('/en');
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/Home')
->where('locale', 'en')
->where('supportedLocales.1', 'en')
);
}
public function test_contact_route_respects_locale_and_component(): void
{
$response = $this->get('/en/contact');
// Debug response headers for redirect source during development
// @phpstan-ignore-next-line
// var_dump($response->headers->all());
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/Kontakt')
->where('locale', 'en')
);
$responseDe = $this->get('/de/kontakt');
$responseDe->assertOk();
$responseDe->assertInertia(fn (Assert $page) => $page
->component('marketing/Kontakt')
->where('locale', 'de')
);
}
}