Improve marketing language switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-22 09:07:46 +01:00
parent 23193a3452
commit b9d91c8f40
5 changed files with 194 additions and 33 deletions

View File

@@ -504,6 +504,10 @@
"contact": "Kontakt",
"discover_packages": "Packages entdecken",
"language": "Sprache",
"language_de": "Deutsch",
"language_en": "English",
"language_changed": "{{language}} ausgewählt",
"language": "Sprache",
"open_menu": "Menü öffnen",
"close_menu": "Menü schließen",
"cta_demo": "Jetzt ausprobieren",

View File

@@ -494,6 +494,10 @@
"contact": "Contact",
"discover_packages": "Discover Packages",
"language": "Language",
"language_de": "Deutsch",
"language_en": "English",
"language_changed": "{{language}} selected",
"language": "Language",
"open_menu": "Open menu",
"close_menu": "Close menu",
"cta_demo": "Try it now",

View File

@@ -8,6 +8,7 @@ import { useAppearance } from '@/hooks/use-appearance';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Sun, Moon, Languages, LayoutDashboard, LogOut, LogIn } from 'lucide-react';
import toast from 'react-hot-toast';
interface MarketingLayoutProps {
children: React.ReactNode;
@@ -136,9 +137,44 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
return acc;
}, {});
const orderedLocales = useMemo(() => {
const preferredOrder = ['de', 'en'];
return [...supportedLocales].sort((a, b) => {
const aIndex = preferredOrder.indexOf(a);
const bIndex = preferredOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
}, [supportedLocales]);
const resolveLanguageLabel = (code: string) => {
if (code === 'de') {
return t('nav.language_de', 'Deutsch');
}
if (code === 'en') {
return t('nav.language_en', 'English');
}
return code.toUpperCase();
};
const languageOptions = useMemo(() => orderedLocales.map((code) => ({
code,
label: resolveLanguageLabel(code),
})), [orderedLocales, t]);
const useInlineLocaleToggle = supportedLocales.length === 2;
const handleLocaleChange = (nextLocale: string) => {
if (nextLocale === activeLocale) {
setMobileMenuOpen(false);
return;
}
const targetPath = localizedPath(relativePath, nextLocale);
const targetUrl = `${targetPath}${rawQuery ? `?${rawQuery}` : ''}`;
const nextLabel = resolveLanguageLabel(nextLocale);
setMobileMenuOpen(false);
router.visit(targetUrl, {
@@ -146,10 +182,47 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
preserveState: false,
onSuccess: () => {
i18n.changeLanguage(nextLocale);
toast.success(t('nav.language_changed', '{{language}} selected', { language: nextLabel }));
},
});
};
const FlagDe = () => (
<svg
aria-hidden="true"
className="h-3.5 w-5 shrink-0 rounded-[3px] shadow-sm ring-1 ring-black/10"
viewBox="0 0 5 3"
>
<rect width="5" height="1" y="0" fill="#000000" />
<rect width="5" height="1" y="1" fill="#dd0000" />
<rect width="5" height="1" y="2" fill="#ffce00" />
</svg>
);
const FlagEn = () => (
<svg
aria-hidden="true"
className="h-3.5 w-5 shrink-0 rounded-[3px] shadow-sm ring-1 ring-black/10"
viewBox="0 0 60 30"
>
<rect width="60" height="30" fill="#012169" />
<path d="M0 0L60 30M60 0L0 30" stroke="#ffffff" strokeWidth="6" />
<path d="M0 0L60 30M60 0L0 30" stroke="#c8102e" strokeWidth="3" />
<path d="M30 0V30M0 15H60" stroke="#ffffff" strokeWidth="10" />
<path d="M30 0V30M0 15H60" stroke="#c8102e" strokeWidth="6" />
</svg>
);
const resolveFlag = (code: string) => {
if (code === 'de') {
return <FlagDe />;
}
if (code === 'en') {
return <FlagEn />;
}
return null;
};
return (
<>
<Head>
@@ -232,6 +305,36 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
))}
</nav>
<div className="flex items-center gap-2">
{useInlineLocaleToggle && (
<div
className="hidden items-center rounded-full border border-gray-200 bg-white p-1 shadow-sm md:flex dark:border-gray-700 dark:bg-gray-900"
role="radiogroup"
aria-label={t('nav.language', 'Sprache')}
>
{languageOptions.map((option) => {
const isActive = option.code === activeLocale;
return (
<button
key={option.code}
type="button"
role="radio"
aria-checked={isActive}
aria-label={`${t('nav.language', 'Sprache')}: ${option.label}`}
onClick={() => handleLocaleChange(option.code)}
className={[
'inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900',
isActive
? 'bg-pink-500 text-white shadow-sm'
: 'text-gray-600 hover:bg-rose-50 hover:text-pink-600 dark:text-gray-100 dark:hover:bg-gray-800 dark:hover:text-pink-300',
].join(' ')}
>
{resolveFlag(option.code)}
<span className="sr-only">{option.label}</span>
</button>
);
})}
</div>
)}
<Button
asChild
className="hidden rounded-full bg-pink-500 px-5 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-pink-600 font-sans-marketing md:inline-flex"
@@ -266,6 +369,8 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
<span>{themeLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!useInlineLocaleToggle && (
<>
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">
{t('nav.language', 'Sprache')}
</DropdownMenuLabel>
@@ -277,11 +382,13 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
className="flex items-center gap-2 font-sans-marketing dark:text-gray-100"
>
<Languages className="h-4 w-4" />
<span>{code.toUpperCase()}</span>
<span>{resolveLanguageLabel(code)}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
</>
)}
{user ? (
<>
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">
@@ -394,6 +501,42 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
</Button>
</div>
<div className="pt-2">
{useInlineLocaleToggle ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{t('nav.language', 'Sprache')}
</p>
<div
role="radiogroup"
aria-label={t('nav.language', 'Sprache')}
className="grid grid-cols-2 gap-2"
>
{languageOptions.map((option) => {
const isActive = option.code === activeLocale;
return (
<button
key={option.code}
type="button"
role="radio"
aria-checked={isActive}
aria-label={`${t('nav.language', 'Sprache')}: ${option.label}`}
onClick={() => handleLocaleChange(option.code)}
className={[
'flex items-center justify-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900',
isActive
? 'border-pink-500 bg-pink-500 text-white shadow-sm'
: 'border-gray-200 bg-white text-gray-700 hover:border-pink-200 hover:bg-rose-50 hover:text-pink-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-pink-400 dark:hover:bg-gray-800 dark:hover:text-pink-300',
].join(' ')}
>
{resolveFlag(option.code)}
<span>{option.label}</span>
</button>
);
})}
</div>
</div>
) : (
<>
<label htmlFor="marketing-language-select-mobile" className="sr-only">
{t('nav.language', 'Sprache')}
</label>
@@ -405,10 +548,12 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
>
{supportedLocales.map((code) => (
<option key={code} value={code}>
{code.toUpperCase()}
{resolveLanguageLabel(code)}
</option>
))}
</select>
</>
)}
</div>
</div>
</div>

View File

@@ -421,7 +421,11 @@
"blog": "Blog",
"packages": "Pakete",
"contact": "Kontakt",
"discover_packages": "Pakete entdecken"
"discover_packages": "Pakete entdecken",
"language": "Sprache",
"language_de": "Deutsch",
"language_en": "English",
"language_changed": "{{language}} ausgewählt"
},
"footer": {
"company": "S.E.B. Fotografie",

View File

@@ -421,7 +421,11 @@
"blog": "Blog",
"packages": "Packages",
"contact": "Contact",
"discover_packages": "Discover Packages"
"discover_packages": "Discover Packages",
"language": "Language",
"language_de": "Deutsch",
"language_en": "English",
"language_changed": "{{language}} selected"
},
"footer": {
"company": "S.E.B. Fotografie",