From dd3198cb791f27bc46c78943ddf69ead92522a3f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 2 Dec 2025 13:31:58 +0100 Subject: [PATCH] fixed language switching in the frontend --- app/Http/Controllers/LocaleController.php | 23 ++---- app/Http/Controllers/MarketingController.php | 2 +- app/Http/Kernel.php | 1 + app/Http/Middleware/HandleInertiaRequests.php | 19 ++--- app/Http/Middleware/SetLocaleFromRequest.php | 40 +++------- app/Support/LocaleConfig.php | 79 +++++++++++++++++++ public/lang/de/legal.json | 1 + public/lang/de/marketing.json | 3 +- resources/js/app.tsx | 23 +++++- resources/js/hooks/useLocalizedRoutes.ts | 76 +++--------------- resources/js/i18n.js | 31 -------- resources/js/i18n.ts | 23 ++++-- resources/js/layouts/mainWebsite.tsx | 20 +++-- .../js/lib/__tests__/localizedPath.test.ts | 29 +++++++ resources/js/lib/localizedPath.ts | 57 +++++++++++++ resources/js/pages/marketing/HowItWorks.tsx | 66 +++++++++++++--- resources/js/pages/marketing/Packages.tsx | 13 ++- resources/js/ssr.tsx | 14 +++- routes/web.php | 20 +---- .../Marketing/MarketingLocaleRoutingTest.php | 58 ++++++++++++++ 20 files changed, 395 insertions(+), 203 deletions(-) create mode 100644 app/Support/LocaleConfig.php delete mode 100644 resources/js/i18n.js create mode 100644 resources/js/lib/__tests__/localizedPath.test.ts create mode 100644 resources/js/lib/localizedPath.ts create mode 100644 tests/Feature/Marketing/MarketingLocaleRoutingTest.php diff --git a/app/Http/Controllers/LocaleController.php b/app/Http/Controllers/LocaleController.php index 4134ea6..8ea22b8 100644 --- a/app/Http/Controllers/LocaleController.php +++ b/app/Http/Controllers/LocaleController.php @@ -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()) { diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index d9065e0..4753a73 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -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, '/') : '/'; diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 7b26172..0db529c 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, ], diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 214bd3a..0b5290f 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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'), diff --git a/app/Http/Middleware/SetLocaleFromRequest.php b/app/Http/Middleware/SetLocaleFromRequest.php index 9dc6dea..dcbf574 100644 --- a/app/Http/Middleware/SetLocaleFromRequest.php +++ b/app/Http/Middleware/SetLocaleFromRequest.php @@ -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 - */ - 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)); - } } diff --git a/app/Support/LocaleConfig.php b/app/Support/LocaleConfig.php new file mode 100644 index 0000000..17bfafd --- /dev/null +++ b/app/Support/LocaleConfig.php @@ -0,0 +1,79 @@ + + */ + 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 + */ + 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; + } +} diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index ef2f0f1..aa24c03 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -1,5 +1,6 @@ { "impressum": "Impressum", + "headline": "Rechtliches", "datenschutz": "Datenschutzerklärung", "impressum_title": "Impressum - Fotospiel", "datenschutz_title": "Datenschutzerklärung - Fotospiel", diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 6334cc0..39ec060 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -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" diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 78dd19c..7fcec3e 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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( - + + + diff --git a/resources/js/hooks/useLocalizedRoutes.ts b/resources/js/hooks/useLocalizedRoutes.ts index 5168d6d..2adee9e 100644 --- a/resources/js/hooks/useLocalizedRoutes.ts +++ b/resources/js/hooks/useLocalizedRoutes.ts @@ -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> = { - '/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 }; }; diff --git a/resources/js/i18n.js b/resources/js/i18n.js deleted file mode 100644 index 74c51bf..0000000 --- a/resources/js/i18n.js +++ /dev/null @@ -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; diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts index e03a478..8872200 100644 --- a/resources/js/i18n.ts +++ b/resources/js/i18n.ts @@ -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; \ No newline at end of file +export default i18n; diff --git a/resources/js/layouts/mainWebsite.tsx b/resources/js/layouts/mainWebsite.tsx index 8687286..6749a51 100644 --- a/resources/js/layouts/mainWebsite.tsx +++ b/resources/js/layouts/mainWebsite.tsx @@ -26,8 +26,7 @@ type PageProps = { const MarketingLayout: React.FC = ({ children, title }) => { const page = usePage(); 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 = ({ 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 ( +
+ + Lade Inhalte … +
+ ); + } + const marketing = page.props.translations?.marketing ?? {}; const getString = (key: string, fallback: string) => { @@ -132,11 +140,13 @@ const MarketingLayout: React.FC = ({ 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); + }, }); }; diff --git a/resources/js/lib/__tests__/localizedPath.test.ts b/resources/js/lib/__tests__/localizedPath.test.ts new file mode 100644 index 0000000..865dad7 --- /dev/null +++ b/resources/js/lib/__tests__/localizedPath.test.ts @@ -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'); + }); +}); diff --git a/resources/js/lib/localizedPath.ts b/resources/js/lib/localizedPath.ts new file mode 100644 index 0000000..ed27aad --- /dev/null +++ b/resources/js/lib/localizedPath.ts @@ -0,0 +1,57 @@ +export type LocaleRewriteMap = Record>; + +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}`; +}; diff --git a/resources/js/pages/marketing/HowItWorks.tsx b/resources/js/pages/marketing/HowItWorks.tsx index 0347457..522328c 100644 --- a/resources/js/pages/marketing/HowItWorks.tsx +++ b/resources/js/pages/marketing/HowItWorks.tsx @@ -38,10 +38,30 @@ const iconByUseCase: Record = { }; 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 ( + + +
+

Lade Inhalte …

+
+
+ ); + } + + 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 ( @@ -116,7 +162,7 @@ const HowItWorks: React.FC = () => {
- {hero.stats.map((stat) => ( + {heroStats.map((stat) => ( diff --git a/resources/js/pages/marketing/Packages.tsx b/resources/js/pages/marketing/Packages.tsx index 4b67dcc..c43c785 100644 --- a/resources/js/pages/marketing/Packages.tsx +++ b/resources/js/pages/marketing/Packages.tsx @@ -1016,7 +1016,7 @@ const PackageDetailGrid: React.FC = ({
- {/* Details overlay */} +{/* Details overlay */} {selectedPackage && ( isMobile ? ( @@ -1047,8 +1047,15 @@ const PackageDetailGrid: React.FC = ({ 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; diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx index 6b75e55..759bd47 100644 --- a/resources/js/ssr.tsx +++ b/resources/js/ssr.tsx @@ -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 ; + const locale = (props.initialPage?.props as Record | undefined)?.locale as string | undefined; + + if (locale && i18n.language !== locale) { + i18n.changeLanguage(locale); + } + + return ( + + + + ); }, }), ); diff --git a/routes/web.php b/routes/web.php index 4fbb0b4..dd317b3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/Marketing/MarketingLocaleRoutingTest.php b/tests/Feature/Marketing/MarketingLocaleRoutingTest.php new file mode 100644 index 0000000..199fb45 --- /dev/null +++ b/tests/Feature/Marketing/MarketingLocaleRoutingTest.php @@ -0,0 +1,58 @@ +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') + ); + } +}