fixed language switching in the frontend
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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, '/') : '/';
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
79
app/Support/LocaleConfig.php
Normal file
79
app/Support/LocaleConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"impressum": "Impressum",
|
||||
"headline": "Rechtliches",
|
||||
"datenschutz": "Datenschutzerklärung",
|
||||
"impressum_title": "Impressum - Fotospiel",
|
||||
"datenschutz_title": "Datenschutzerklärung - Fotospiel",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
<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', {
|
||||
const localizedPath = (path: string | null | undefined, targetLocale?: string) =>
|
||||
buildLocalizedPath(
|
||||
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}`;
|
||||
};
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1047,8 +1047,15 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
|
||||
const handleDetailAutoFocus = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// 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>
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
58
tests/Feature/Marketing/MarketingLocaleRoutingTest.php
Normal file
58
tests/Feature/Marketing/MarketingLocaleRoutingTest.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user