Add German/English language toggle for the storefront

Auto-detects the visitor's browser language on first visit, with a
manual dropdown switcher (flag icons) in the header and mobile menu.
Legal pages, the admin panel, and DB-driven content stay German-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-06 21:51:32 +02:00
parent ca4d3a2079
commit e282a981a6
20 changed files with 912 additions and 272 deletions

View file

@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useCookieConsent } from '../context/CookieConsentContext.jsx';
import { useLanguage } from '../context/LanguageContext.jsx';
function SettingsModal({ onClose }) {
const { consent, saveCustom } = useCookieConsent();
const [external, setExternal] = useState(consent.external);
const { t } = useLanguage();
return (
<div
@ -15,31 +17,29 @@ function SettingsModal({ onClose }) {
className="w-full max-w-md rounded-xl bg-white p-6 dark:bg-neutral-900"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-1 text-lg font-bold text-neutral-900 dark:text-white">Cookie-Einstellungen</h2>
<h2 className="mb-1 text-lg font-bold text-neutral-900 dark:text-white">{t('cookieConsent.modalTitle')}</h2>
<p className="mb-4 text-sm text-neutral-600 dark:text-neutral-300">
Lege fest, welche Kategorien du zulassen möchtest. Mehr dazu in unserer{' '}
{t('cookieConsent.modalIntro')}{' '}
<Link to="/datenschutz" className="underline hover:text-brand-500" onClick={onClose}>
Datenschutzerklärung
{t('cookieConsent.privacyPolicy')}
</Link>
.
</p>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3 rounded-lg border border-neutral-200 p-3 dark:border-neutral-700">
<div>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">Notwendig</p>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{t('cookieConsent.necessaryTitle')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Für den Betrieb der Website erforderlich (z. B. Login-Session, Theme- und Cookie-Einstellung).
Kann nicht deaktiviert werden.
{t('cookieConsent.necessaryDesc')}
</p>
</div>
<input type="checkbox" checked disabled className="mt-1 h-4 w-4 shrink-0" />
</div>
<div className="flex items-start justify-between gap-3 rounded-lg border border-neutral-200 p-3 dark:border-neutral-700">
<div>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">Externe Medien</p>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{t('cookieConsent.externalTitle')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Google Maps und YouTube-Videos. Beim Laden werden Daten (u. a. deine IP-Adresse) an Google
übertragen.
{t('cookieConsent.externalDesc')}
</p>
</div>
<input
@ -56,14 +56,14 @@ function SettingsModal({ onClose }) {
onClick={onClose}
className="rounded-md border border-neutral-300 px-4 py-2 text-sm dark:border-neutral-700"
>
Abbrechen
{t('cookieConsent.cancel')}
</button>
<button
type="button"
onClick={() => saveCustom({ external })}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Auswahl speichern
{t('cookieConsent.saveSelection')}
</button>
</div>
</div>
@ -73,6 +73,7 @@ function SettingsModal({ onClose }) {
export default function CookieConsentBanner() {
const { decided, settingsOpen, acceptAll, rejectNonEssential, openSettings, closeSettings } = useCookieConsent();
const { t } = useLanguage();
return (
<>
@ -80,10 +81,9 @@ export default function CookieConsentBanner() {
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-neutral-200 bg-white/95 p-4 shadow-lg backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/95 sm:p-5">
<div className="mx-auto flex max-w-6xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-neutral-600 dark:text-neutral-300">
Wir verwenden nur technisch notwendige Cookies. Für Google Maps und YouTube-Videos benötigen wir
zusätzlich deine Zustimmung, da dabei Daten an Google übertragen werden. Mehr dazu in unserer{' '}
{t('cookieConsent.bannerText')} {t('cookieConsent.moreInfo')}{' '}
<Link to="/datenschutz" className="underline hover:text-brand-500">
Datenschutzerklärung
{t('cookieConsent.privacyPolicy')}
</Link>
.
</p>
@ -93,21 +93,21 @@ export default function CookieConsentBanner() {
onClick={openSettings}
className="rounded-md border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-700"
>
Einstellungen
{t('cookieConsent.settings')}
</button>
<button
type="button"
onClick={rejectNonEssential}
className="rounded-md border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-700"
>
Nur notwendige
{t('cookieConsent.onlyNecessary')}
</button>
<button
type="button"
onClick={acceptAll}
className="rounded-md bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Alle akzeptieren
{t('cookieConsent.acceptAll')}
</button>
</div>
</div>

View file

@ -2,24 +2,20 @@ import { Link, useLocation } from 'react-router-dom';
import DynamicIcon from './DynamicIcon.jsx';
import { useCookieConsent } from '../context/CookieConsentContext.jsx';
import { useSiteSettings } from '../context/SiteSettingsContext.jsx';
import { useLanguage } from '../context/LanguageContext.jsx';
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amorbach');
const DIRECTIONS_URL = `https://www.google.com/maps/dir/?api=1&destination=${SHOP_ADDRESS_ENCODED}`;
const HOURS = [
{ days: 'Montag Freitag', from: 9 * 60, to: 18 * 60, label: '9:0018:00 Uhr' },
{ days: 'Samstag', from: 10 * 60, to: 13 * 60, label: '10:0013:00 Uhr' },
{ days: 'Sonntag', from: null, to: null, label: 'Geschlossen' },
];
function isOpenNow() {
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
const day = now.getDay(); // 0 = Sonntag, 1-5 = Mo-Fr, 6 = Sa
const todayHours = day === 0 ? HOURS[2] : day === 6 ? HOURS[1] : HOURS[0];
return todayHours.from !== null && minutes >= todayHours.from && minutes < todayHours.to;
if (day === 0) return false;
if (day === 6) return minutes >= 10 * 60 && minutes < 13 * 60;
return minutes >= 9 * 60 && minutes < 18 * 60;
}
export default function Footer() {
@ -31,6 +27,13 @@ export default function Footer() {
const { pathname } = useLocation();
const isHome = pathname === '/';
const { phone, contact_email: contactEmail } = useSiteSettings();
const { t } = useLanguage();
const HOURS = [
{ key: 'monFri', days: t('footer.days.monFri'), label: t('contactPage.hoursWeek') },
{ key: 'sat', days: t('footer.days.sat'), label: t('contactPage.hoursSat') },
{ key: 'sun', days: t('footer.days.sun'), label: t('footer.closed') },
];
return (
<footer
@ -53,14 +56,14 @@ export default function Footer() {
className="mt-4 flex items-center justify-center gap-1.5 rounded-md bg-brand-500 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-600"
>
<DynamicIcon name="navigation" className="h-3.5 w-3.5" />
Route planen
{t('footer.routePlan')}
</a>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-3 flex items-center gap-2">
<DynamicIcon name="phone" className="h-4 w-4 text-brand-500" />
<p className="font-semibold text-neutral-900 dark:text-white">Kontakt</p>
<p className="font-semibold text-neutral-900 dark:text-white">{t('footer.contactHeading')}</p>
</div>
<p>
<a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a>
@ -72,7 +75,7 @@ export default function Footer() {
to="/kontakt"
className="mt-4 flex items-center justify-center gap-1.5 rounded-md border border-neutral-300 px-3 py-2 text-xs font-semibold text-neutral-700 hover:border-brand-500 hover:text-brand-600 dark:border-neutral-700 dark:text-neutral-200"
>
Kontaktformular
{t('footer.contactForm')}
</Link>
</div>
@ -80,7 +83,7 @@ export default function Footer() {
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<DynamicIcon name="clock" className="h-4 w-4 text-brand-500" />
<p className="font-semibold text-neutral-900 dark:text-white">Öffnungszeiten</p>
<p className="font-semibold text-neutral-900 dark:text-white">{t('footer.hoursHeading')}</p>
</div>
<span
className={`flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
@ -90,12 +93,12 @@ export default function Footer() {
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${open ? 'bg-green-500' : 'bg-neutral-400'}`} />
{open ? 'Geöffnet' : 'Geschlossen'}
{open ? t('footer.open') : t('footer.closed')}
</span>
</div>
<dl className="space-y-1.5">
{HOURS.map((row) => (
<div key={row.days} className="flex items-center justify-between gap-3">
<div key={row.key} className="flex items-center justify-between gap-3">
<dt className="text-neutral-500 dark:text-neutral-400">{row.days}</dt>
<dd className="font-medium text-neutral-700 dark:text-neutral-200">{row.label}</dd>
</div>
@ -105,16 +108,16 @@ export default function Footer() {
</div>
<div className="mx-auto mt-8 max-w-6xl border-t border-neutral-200 px-4 pt-6 text-center text-xs text-neutral-500 dark:border-neutral-800 dark:text-neutral-400 sm:px-6">
<p className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<span>© {new Date().getFullYear()} HifiPlanet. Alle Angaben ohne Gewähr.</span>
<span>{t('footer.copyright')(new Date().getFullYear())}</span>
<span>·</span>
<Link to="/impressum" className="hover:text-brand-500">Impressum</Link>
<Link to="/impressum" className="hover:text-brand-500">{t('footer.imprint')}</Link>
<span>·</span>
<Link to="/datenschutz" className="hover:text-brand-500">Datenschutz</Link>
<Link to="/datenschutz" className="hover:text-brand-500">{t('footer.privacy')}</Link>
<span>·</span>
<Link to="/agb" className="hover:text-brand-500">AGB</Link>
<Link to="/agb" className="hover:text-brand-500">{t('footer.terms')}</Link>
<span>·</span>
<button type="button" onClick={openSettings} className="hover:text-brand-500">
Cookie-Einstellungen
{t('footer.cookieSettings')}
</button>
</p>
</div>

View file

@ -1,7 +1,9 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useLanguage } from '../context/LanguageContext.jsx';
export default function Lightbox({ photos, index, onClose, onNavigate }) {
const { t } = useLanguage();
useEffect(() => {
if (index == null) return undefined;
const handleKey = (e) => {
@ -21,7 +23,7 @@ export default function Lightbox({ photos, index, onClose, onNavigate }) {
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 p-4" onClick={onClose}>
<button
onClick={(e) => { stop(e); onClose(); }}
aria-label="Schließen"
aria-label={t('lightbox.close')}
className="absolute right-4 top-4 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-7 w-7">
@ -32,7 +34,7 @@ export default function Lightbox({ photos, index, onClose, onNavigate }) {
{photos.length > 1 && (
<button
onClick={(e) => { stop(e); onNavigate(-1); }}
aria-label="Vorheriges Bild"
aria-label={t('lightbox.previous')}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white sm:left-4"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-8 w-8">
@ -51,7 +53,7 @@ export default function Lightbox({ photos, index, onClose, onNavigate }) {
{photos.length > 1 && (
<button
onClick={(e) => { stop(e); onNavigate(1); }}
aria-label="Nächstes Bild"
aria-label={t('lightbox.next')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white sm:right-4"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-8 w-8">

View file

@ -1,3 +1,5 @@
import { useLanguage } from '../context/LanguageContext.jsx';
const WrenchIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-10 w-10">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.7 6.3a4 4 0 0 0-5.5 4.5L3 17l2 2 6.2-6.2a4 4 0 0 0 4.5-5.5l-2.6 2.6-2-2 2.6-2.6Z" />
@ -5,14 +7,15 @@ const WrenchIcon = () => (
);
export default function MaintenanceNotice({ message, fullScreen = false }) {
const { t } = useLanguage();
const content = (
<div className="mx-auto max-w-md text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400">
<WrenchIcon />
</div>
<h1 className="mb-2 text-xl font-bold text-neutral-900 dark:text-white">Wartungsarbeiten</h1>
<h1 className="mb-2 text-xl font-bold text-neutral-900 dark:text-white">{t('maintenance.title')}</h1>
<p className="text-neutral-600 dark:text-neutral-300">
{message || 'Dieser Bereich wird gerade aktualisiert. Bitte schau in Kürze wieder vorbei.'}
{message || t('maintenance.defaultMessage')}
</p>
</div>
);

View file

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import ThemeToggle from './ThemeToggle.jsx';
import DynamicIcon from './DynamicIcon.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { useSiteSettings } from '../context/SiteSettingsContext.jsx';
import { useLanguage } from '../context/LanguageContext.jsx';
import logo from '../assets/logo.png';
const SHOP_URL = 'https://www.audio4cars.de/';
@ -14,13 +15,134 @@ const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
const ITEM_DELAY_MS = 70;
const FLY_DURATION_MS = 500;
function FlagDE({ className }) {
return (
<svg viewBox="0 0 3 2" className={className} aria-hidden="true">
<rect width="3" height="0.667" y="0" fill="#000" />
<rect width="3" height="0.667" y="0.667" fill="#D00" />
<rect width="3" height="0.666" y="1.334" fill="#FFCE00" />
</svg>
);
}
function FlagGB({ className }) {
return (
<svg viewBox="0 0 60 30" className={className} aria-hidden="true">
<clipPath id="fgb-t">
<path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z" />
</clipPath>
<path d="M0,0 v30 h60 v-30 z" fill="#00247d" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#fgb-t)" stroke="#cf142b" strokeWidth="4" />
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10" />
<path d="M30,0 v30 M0,15 h60" stroke="#cf142b" strokeWidth="6" />
</svg>
);
}
const LANGUAGE_OPTIONS = [
{ code: 'de', label: 'Deutsch', Flag: FlagDE },
{ code: 'en', label: 'English', Flag: FlagGB },
];
function LanguageSwitcher({ overHero = false, variant = 'header' }) {
const { language, setLanguage, t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
const current = LANGUAGE_OPTIONS.find((opt) => opt.code === language) || LANGUAGE_OPTIONS[0];
const isHeader = variant === 'header';
useEffect(() => {
if (!isOpen) return undefined;
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) setIsOpen(false);
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
return (
<div className="relative" ref={containerRef}>
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={t('languageToggle.label')}
className={
isHeader
? `flex items-center gap-1 rounded-full p-1.5 transition ${overHero ? 'hover:bg-white/10' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'}`
: 'flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600'
}
>
<span
className={`block h-4 w-6 shrink-0 overflow-hidden rounded-[3px] ring-1 ${
isHeader && overHero ? 'ring-white/50' : 'ring-neutral-300 dark:ring-neutral-600'
}`}
>
<current.Flag className="h-full w-full" />
</span>
{!isHeader && current.label}
{isHeader && (
<svg
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className={`h-3 w-3 transition-transform ${overHero ? 'text-white/70' : 'text-neutral-400'} ${isOpen ? 'rotate-180' : ''}`}
>
<path fillRule="evenodd" clipRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" />
</svg>
)}
</button>
{isOpen && (
<div
role="listbox"
className={`absolute z-50 mt-2 min-w-[9rem] overflow-hidden rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-900 ${
isHeader ? 'right-0' : 'left-0'
}`}
>
{LANGUAGE_OPTIONS.map((opt) => (
<button
key={opt.code}
type="button"
role="option"
aria-selected={opt.code === language}
onClick={() => {
setLanguage(opt.code);
setIsOpen(false);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
opt.code === language ? 'font-semibold text-brand-600 dark:text-brand-400' : 'text-neutral-700 dark:text-neutral-200'
}`}
>
<span className="block h-4 w-6 shrink-0 overflow-hidden rounded-[3px] ring-1 ring-neutral-300 dark:ring-neutral-600">
<opt.Flag className="h-full w-full" />
</span>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
export default function Navbar() {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
const { user } = useAuth();
const { phone, whatsapp } = useSiteSettings();
const totalItems = whatsapp ? 8 : 7;
const { t } = useLanguage();
const totalItems = whatsapp ? 9 : 8;
const location = useLocation();
const isHome = location.pathname === '/';
const [scrolled, setScrolled] = useState(!isHome);
@ -74,8 +196,8 @@ export default function Navbar() {
const AdminIcon = user && (
<Link
to="/admin"
aria-label="Zum Admin-Panel"
title="Zum Admin-Panel"
aria-label={t('nav.adminPanel')}
title={t('nav.adminPanel')}
className={`rounded-full p-2 transition ${
transparent ? 'text-white hover:bg-white/10' : 'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800'
}`}
@ -96,7 +218,7 @@ export default function Navbar() {
<div className="flex items-center justify-self-start">
<button
onClick={() => setOpen(true)}
aria-label="Menü öffnen"
aria-label={t('nav.openMenu')}
className={`rounded-md p-2 transition ${
transparent ? 'text-white hover:bg-white/10' : 'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800'
}`}
@ -113,6 +235,7 @@ export default function Navbar() {
<div className="flex items-center justify-self-end gap-1">
{AdminIcon}
{!open && <LanguageSwitcher overHero={transparent} variant="header" />}
<ThemeToggle overHero={transparent} />
</div>
</div>
@ -126,32 +249,36 @@ export default function Navbar() {
<div className="relative mx-auto flex min-h-full max-w-6xl flex-col items-start justify-center gap-5 py-24 pl-6 pr-8 sm:pl-8">
<button
onClick={() => setOpen(false)}
aria-label="Menü schließen"
aria-label={t('nav.closeMenu')}
className="absolute left-6 top-8 flex items-center gap-2 text-neutral-600 hover:text-brand-600 sm:left-8 sm:top-[65px]"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wide">Schließen</span>
<span className="text-xs font-semibold uppercase tracking-wide">{t('nav.close')}</span>
</button>
<Link to="/fahrzeuge" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(0)}>
Fahrzeuge
{t('nav.vehicles')}
</Link>
<Link to="/leistungen" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(1)}>
Leistungen
{t('nav.services')}
</Link>
<Link to="/galerie" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(2)}>
Galerie
{t('nav.gallery')}
</Link>
<Link to="/kontakt" onClick={() => setOpen(false)} className={`text-3xl font-extrabold tracking-tight text-neutral-900 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(3)}>
Kontakt
{t('nav.contact')}
</Link>
<div className={`h-px w-16 bg-brand-500/60 ${flyIn()}`} style={flyInStyle(4)} />
<div className={flyIn()} style={flyInStyle(5)}>
<LanguageSwitcher variant="menu" />
</div>
{phone && (
<a href={`tel:${digitsOnly(phone)}`} className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(5)}>
<a href={`tel:${digitsOnly(phone)}`} className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`} style={flyInStyle(6)}>
<DynamicIcon name="phone" className="h-4 w-4" />
{phone}
</a>
@ -162,10 +289,10 @@ export default function Navbar() {
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`}
style={flyInStyle(6)}
style={flyInStyle(7)}
>
<DynamicIcon name="message-circle" className="h-4 w-4" />
WhatsApp
{t('nav.whatsapp')}
</a>
)}
<a
@ -173,10 +300,10 @@ export default function Navbar() {
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600 hover:text-brand-600 ${flyIn()}`}
style={flyInStyle(whatsapp ? 7 : 6)}
style={flyInStyle(whatsapp ? 8 : 7)}
>
<DynamicIcon name="shopping-bag" className="h-4 w-4" />
Zum Shop
{t('nav.shop')}
</a>
</div>
</div>,

View file

@ -8,6 +8,7 @@ import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx';
import CookieConsentBanner from './CookieConsentBanner.jsx';
import { MaintenanceProvider } from '../context/MaintenanceContext.jsx';
import { SiteSettingsProvider } from '../context/SiteSettingsContext.jsx';
import { LanguageProvider } from '../context/LanguageContext.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api/client.js';
@ -49,10 +50,15 @@ export default function PublicLayout() {
const isLegalPage = LEGAL_PATHS.includes(location.pathname);
if (status.global.enabled && !bypass && !isLegalPage) {
return <MaintenanceNotice message={status.global.message} fullScreen />;
return (
<LanguageProvider>
<MaintenanceNotice message={status.global.message} fullScreen />
</LanguageProvider>
);
}
return (
<LanguageProvider>
<MaintenanceProvider value={{ ...status, bypass }}>
<SiteSettingsProvider value={siteSettings}>
<div className="page-texture flex min-h-screen flex-col text-neutral-900 dark:text-neutral-100">
@ -67,5 +73,6 @@ export default function PublicLayout() {
</div>
</SiteSettingsProvider>
</MaintenanceProvider>
</LanguageProvider>
);
}

View file

@ -1,12 +1,14 @@
import { useTheme } from '../context/ThemeContext.jsx';
import { useLanguage } from '../context/LanguageContext.jsx';
export default function ThemeToggle({ overHero = false }) {
const { theme, toggleTheme } = useTheme();
const { t } = useLanguage();
return (
<button
onClick={toggleTheme}
aria-label="Theme umschalten"
aria-label={t('themeToggle.label')}
className={`rounded-full p-2 transition ${
overHero ? 'text-white hover:bg-white/10' : 'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800'
}`}

View file

@ -0,0 +1,56 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { dictionaries } from '../i18n/index.js';
function detectDefaultLanguage() {
const stored = localStorage.getItem('hifi-lang');
if (stored === 'de' || stored === 'en') return stored;
const browserLang = (navigator.language || 'de').toLowerCase();
return browserLang.startsWith('de') ? 'de' : 'en';
}
function lookup(dict, key) {
return key.split('.').reduce((obj, part) => (obj == null ? undefined : obj[part]), dict);
}
// Default-Wert (nicht null) analog zu MaintenanceContext/SiteSettingsContext, damit
// Komponenten, die sowohl im öffentlichen Bereich (mit Provider) als auch im
// Admin-Panel (ohne Provider, bleibt bewusst Deutsch) verwendet werden - z. B.
// ThemeToggle - nicht abstürzen, wenn kein LanguageProvider im Baum ist.
const LanguageContext = createContext({
language: 'de',
setLanguage: () => {},
toggleLanguage: () => {},
t: (key) => {
const value = lookup(dictionaries.de, key);
return value !== undefined ? value : key;
},
});
export function LanguageProvider({ children }) {
const [language, setLanguage] = useState(detectDefaultLanguage);
useEffect(() => {
document.documentElement.setAttribute('lang', language);
localStorage.setItem('hifi-lang', language);
}, [language]);
const toggleLanguage = () => setLanguage((l) => (l === 'de' ? 'en' : 'de'));
const t = (key) => {
const dict = dictionaries[language] || dictionaries.de;
const value = lookup(dict, key);
if (value !== undefined) return value;
const fallback = lookup(dictionaries.de, key);
return fallback !== undefined ? fallback : key;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, toggleLanguage, t }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
return useContext(LanguageContext);
}

233
hifi-src/src/i18n/de.js Normal file
View file

@ -0,0 +1,233 @@
export default {
nav: {
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
close: 'Schließen',
adminPanel: 'Zum Admin-Panel',
vehicles: 'Fahrzeuge',
services: 'Leistungen',
gallery: 'Galerie',
contact: 'Kontakt',
whatsapp: 'WhatsApp',
shop: 'Zum Shop',
},
themeToggle: {
label: 'Theme umschalten',
},
languageToggle: {
label: 'Sprache auswählen',
},
footer: {
routePlan: 'Route planen',
contactHeading: 'Kontakt',
contactForm: 'Kontaktformular',
hoursHeading: 'Öffnungszeiten',
open: 'Geöffnet',
closed: 'Geschlossen',
days: {
monFri: 'Montag Freitag',
sat: 'Samstag',
sun: 'Sonntag',
},
copyright: (year) => `© ${year} HifiPlanet. Alle Angaben ohne Gewähr.`,
imprint: 'Impressum',
privacy: 'Datenschutz',
terms: 'AGB',
cookieSettings: 'Cookie-Einstellungen',
},
cookieConsent: {
bannerText: 'Wir verwenden nur technisch notwendige Cookies. Für Google Maps und YouTube-Videos benötigen wir zusätzlich deine Zustimmung, da dabei Daten an Google übertragen werden.',
moreInfo: 'Mehr dazu in unserer',
privacyPolicy: 'Datenschutzerklärung',
settings: 'Einstellungen',
onlyNecessary: 'Nur notwendige',
acceptAll: 'Alle akzeptieren',
modalTitle: 'Cookie-Einstellungen',
modalIntro: 'Lege fest, welche Kategorien du zulassen möchtest. Mehr dazu in unserer',
necessaryTitle: 'Notwendig',
necessaryDesc: 'Für den Betrieb der Website erforderlich (z. B. Login-Session, Theme- und Cookie-Einstellung). Kann nicht deaktiviert werden.',
externalTitle: 'Externe Medien',
externalDesc: 'Google Maps und YouTube-Videos. Beim Laden werden Daten (u. a. deine IP-Adresse) an Google übertragen.',
cancel: 'Abbrechen',
saveSelection: 'Auswahl speichern',
},
maintenance: {
title: 'Wartungsarbeiten',
defaultMessage: 'Dieser Bereich wird gerade aktualisiert. Bitte schau in Kürze wieder vorbei.',
},
lightbox: {
close: 'Schließen',
previous: 'Vorheriges Bild',
next: 'Nächstes Bild',
},
home: {
metaTitle: 'Car-Hifi Umbauten nach Maß',
metaDescription: 'HifiPlanet dein Car-Hifi Spezialist in Amorbach. Marke und Modell wählen, passende Sound-Pakete entdecken und unverbindlich anfragen.',
stickyCta: 'Bereit für deinen Sound-Umbau?',
selectVehicle: 'Fahrzeug auswählen',
getInTouch: 'Kontakt aufnehmen',
welcome: 'Willkommen bei HifiPlanet',
heroTitle: 'Audiophil aus Prinzip',
heroSubtitle: 'Dein Car-Hifi Spezialist für individuelle Sound-Umbauten. Wähle dein Fahrzeug, entdecke passende Pakete und frag unverbindlich an wir kümmern uns um den Rest.',
stats: [
{ value: 'Seit 2010', label: 'in Amorbach' },
{ value: '20+', label: '3D-Drucker im Haus' },
{ value: 'Eigene', label: 'CNC- & Laserwerkstatt' },
{ value: 'Bundesweit', label: 'anerkannter Car-Hifi-Spezialist' },
],
brandsHeading: 'Marken, mit denen wir arbeiten',
howItWorks: "So funktioniert's",
steps: [
{ title: 'Marke & Modell wählen', text: 'Finde dein Fahrzeug in wenigen Klicks.' },
{ title: 'Sound-Paket entdecken', text: 'Passende Pakete inkl. aktueller Preise auf einen Blick.' },
{ title: 'Unverbindlich anfragen', text: 'Wir melden uns zeitnah mit allen Details bei dir.' },
],
youtubeBadge: 'HifiPlanet auf YouTube',
youtubeTitle: 'Sieh dir unsere Umbauten in Aktion an',
youtubeText: 'Umbauten, Soundchecks und Verstärker-Tests aus unserer Werkstatt „Den besten Sound gibt es nicht ab Werk!"',
youtubeChecklist: ['Umbauten Schritt für Schritt', 'Soundchecks & Vergleiche', 'Verstärker- & Komponenten-Tests'],
youtubeCta: 'Zum YouTube-Kanal',
youtubeEmbedName: 'Das YouTube-Video',
heroImageAlt: 'Ground Zero Subwoofer und Endstufen mit violetter Ambiente-Beleuchtung',
craftImageAlt: 'Individueller Subwoofer-Bau',
adviceImageAlt: 'Endstufen-Einbau mit Ambiente-Beleuchtung',
moreImageAlt: 'Hochwertige Endstufe und Subwoofer, Studioaufnahme',
craftEyebrow: 'Handwerk',
craftTitle: 'Präzisionsarbeit in jedem Detail',
craftText: 'Ob eigens gefertigte Subwoofer-Gehäuse, integrierte Beleuchtung oder unsichtbar verlegte Kabelwege dank eigener CNC-, Laser- und 3D-Druck-Fertigung entstehen bei uns Lösungen, die es von der Stange nicht gibt.',
adviceEyebrow: 'Beratung',
adviceTitle: 'Von der ersten Idee bis zum letzten Schliff',
adviceText: 'Jedes Fahrzeug und jeder Anspruch ist anders. Deshalb beraten wir dich persönlich und unverbindlich vom dezenten Upgrade bis zum kompromisslosen High-End-System.',
insightsTitle: 'Einblicke in unsere Umbauten',
insightsText: 'Vom unauffälligen Sound-Upgrade bis zum aufwendigen Komplettumbau Handarbeit aus unserer Werkstatt in Amorbach.',
galleryAlts: [
'Individueller Subwoofer-Bau mit Ground Zero Bässen',
'Endstufen-Einbau mit violetter Ambiente-Beleuchtung',
'Hochtöner- und Mitteltöner-Einbau von Focal Utopia',
'Sony Navigationssystem mit Apple CarPlay im Cockpit',
'Verbauter Hochtöner im Kofferraum',
'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum',
],
reviewsRating: '5,0 von 181 Kunden bewertet',
reviewsText: 'Das sagen unsere Kunden über uns.',
reviewsLink: 'Alle Bewertungen auf Google ansehen →',
testimonials: [
{ name: 'Thomas K.', text: 'Über YouTube auf dieses Klang-Juwel gestoßen. Beratung und Empfehlungen sind erstklassig der Tesla-Vorführwagen hat meine Leidenschaft für guten Sound neu entfacht.' },
{ name: 'Andreas I.', text: 'Sehr professionelle Beratung und Service. Obwohl ich von VAG zu Mercedes gewechselt bin, wurde alles reibungslos angepasst. Die 3-Wege-Lautsprecher und zwei Subwoofer liefern außergewöhnliche Leistung.' },
{ name: 'Thomas F.', text: 'Kompletter Audi A4 B9 Umbau hat meine Erwartungen übertroffen. Von Anfang bis Ende professionell, mit tadelloser Verarbeitung und Klangqualität.' },
{ name: 'Drago G.', text: 'Beeindruckt von der CNC-Arbeit, die auf YouTube gezeigt wird. Angefangen mit 3D-gedruckten Lautsprecherringen für meinen Ford Mustang daraus wurde ein viel größeres Projekt.' },
{ name: 'Leonardo B.', text: 'Toller Service und meisterhafte Handwerkskunst. Saubere Installation, Leidenschaft in jedem Detail erkennbar.' },
{ name: 'Marcel S.', text: 'Unschlagbares Preis-Leistungs-Verhältnis. Das Team hat sich viel Zeit genommen, um genau die Lösung für mein Budget zu finden.' },
{ name: 'Julia W.', text: 'Endlich ein Betrieb, der auch Wohnmobile ernst nimmt. Die Soundanlage in unserem Camper klingt jetzt wie im Wohnzimmer.' },
{ name: 'Kevin R.', text: 'Dashcam und Alarmanlage in einem Termin sauber verbaut, keine sichtbaren Kabel. Absolute Empfehlung für alle, die Wert auf Verarbeitung legen.' },
{ name: 'Sabrina H.', text: 'Mein Oldtimer hat jetzt modernen Sound, ohne dass es dem Original-Look geschadet hätte. Genau das habe ich gesucht.' },
{ name: 'Niklas P.', text: 'Von der ersten Anfrage bis zum fertigen Umbau lief alles reibungslos. Ehrliche Beratung ohne Verkaufsdruck.' },
],
moreTitle: 'Mehr als nur Car-Hifi',
moreText: 'Neben individuellen Sound-Umbauten bieten wir CNC-Zerspanung, Lasertechnik, 3D-Druck und mehr alles aus einer Hand in unserer eigenen Werkstatt.',
moreCta: 'Alle Leistungen entdecken',
faqHeading: 'Häufig gestellte Fragen',
faqs: [
{ question: 'Was kostet eine Beratung?', answer: 'Beratung und Preisanfrage sind für dich komplett kostenlos und unverbindlich.' },
{ question: 'Muss ich mein Fahrzeug vorbeibringen?', answer: 'Für eine erste Einschätzung reicht oft deine Anfrage über die Website. Für den Einbau selbst vereinbaren wir gemeinsam einen Termin bei uns in Amorbach.' },
{ question: 'Wie lange dauert ein Umbau?', answer: 'Das hängt vom Umfang ab von einem einfachen Lautsprecher-Tausch in wenigen Stunden bis zum aufwendigen Komplettumbau über mehrere Tage.' },
{ question: 'Bietet ihr auch Lösungen für Leasingfahrzeuge?', answer: 'Ja, auf Wunsch bauen wir reversibel um, sodass dein Fahrzeug bei Rückgabe wieder in den Originalzustand versetzt werden kann.' },
{ question: 'Arbeitet ihr nur mit bestimmten Marken?', answer: 'Nein, wir sind herstellerunabhängig und wählen die Komponenten, die am besten zu deinem Anspruch und Budget passen.' },
{ question: 'Was ist, wenn mein Fahrzeug nicht gelistet ist?', answer: 'Kein Problem schreib uns einfach über das Kontaktformular, wir finden für jedes Fahrzeug eine passende Lösung.' },
],
},
vehicleSelect: {
metaTitle: 'Fahrzeug auswählen',
metaDescription: 'Wähle deine Fahrzeugmarke und dein Modell und entdecke passende Car-Hifi Sound-Pakete von HifiPlanet.',
title: 'Fahrzeug auswählen',
intro: 'Wähle zuerst deine Marke, dann dein Modell wir zeigen dir direkt die passenden Sound-Pakete.',
empty: 'Es sind noch keine Marken hinterlegt.',
},
brandPage: {
metaTitleFallback: 'Modell auswählen',
metaDescription: (brand) => `Wähle dein ${brand} Modell und entdecke passende Car-Hifi Sound-Pakete von HifiPlanet.`,
loading: 'Lädt…',
breadcrumbVehicles: 'Fahrzeuge',
titleSuffix: 'Modell wählen',
empty: 'Für diese Marke sind noch keine Modelle hinterlegt.',
},
modelPage: {
metaTitleFallback: 'Sound-Pakete',
metaTitle: (brand, model) => `${brand} ${model} Sound-Pakete`,
metaDescription: (brand, model) => `Car-Hifi Sound-Pakete für ${brand} ${model} inkl. aktueller Preise von HifiPlanet unverbindlich anfragen.`,
loading: 'Lädt…',
breadcrumbVehicles: 'Fahrzeuge',
titleSuffix: 'Sound-Pakete',
empty: 'Für dieses Modell sind noch keine Pakete hinterlegt.',
totalPrice: 'Gesamtpreis (ca.)',
productLoading: 'Produkt wird geladen…',
requestContact: 'Kontakt anfragen',
},
leistungen: {
metaTitle: 'Leistungen',
metaDescription: 'Car-Hifi, Wohnmobil & Caravan, Oldtimer, CNC-Zerspanung, Lasertechnik, 3D-Druck, Alarmanlagen und Dash Cams alles aus einer Hand bei HifiPlanet in Amorbach.',
title: 'Unsere Leistungen',
intro: 'Von individuellen Sound-Umbauten bis zur eigenen CNC- und 3D-Druck-Fertigung alles aus einer Hand in unserer Werkstatt in Amorbach.',
notListedTitle: 'Dein Projekt ist nicht dabei?',
notListedText: 'Sprich uns einfach an wir finden gemeinsam die passende Lösung.',
contact: 'Kontakt aufnehmen',
},
contactPage: {
metaTitle: 'Kontakt',
metaDescription: 'Kontaktiere HifiPlanet in Amorbach für dein individuelles Car-Hifi Projekt wir beraten dich gerne unverbindlich.',
title: 'Kontakt aufnehmen',
sentTitle: 'Danke für deine Anfrage!',
sentText: 'Wir melden uns so schnell wie möglich bei dir.',
contextIntro: 'Deine Anfrage bezieht sich auf:',
contextBrand: 'Marke',
contextModel: 'Modell',
contextPackage: 'Paket',
contextProduct: 'Produkt',
packagePrice: 'Paketpreis',
upgrades: 'Upgrades',
optionalUpgrades: 'Optionale Upgrades',
nameLabel: 'Name *',
emailLabel: 'E-Mail *',
phoneLabel: 'Telefon',
vinLabel: 'Fahrgestellnummer (FIN)',
vinPlaceholder: 'Optional hilft uns bei der genauen Einschätzung deines Fahrzeugs',
messageLabel: 'Nachricht',
sending: 'Wird gesendet…',
submit: 'Anfrage senden',
cardTitle: 'HifiPlanet Amorbach',
address: 'Adresse',
phone: 'Telefon',
email: 'E-Mail',
hours: 'Öffnungszeiten',
hoursWeek: 'MoFr: 9:0018:00 Uhr',
hoursSat: 'Sa: 10:0013:00 Uhr',
directionsTitle: 'Anfahrt',
mapEmbedName: 'Die Google-Maps-Karte',
routePlan: 'Route planen',
},
galleryOverview: {
metaTitle: 'Bildergalerie',
metaDescription: 'Einblicke in unsere Car-Hifi Umbauten nach Marke sortiert. Wähle eine Marke und entdecke die Projekte.',
title: 'Bildergalerie',
intro: 'Wähle eine Marke und entdecke unsere Umbauten im Detail.',
loading: 'Lädt…',
empty: 'Es sind noch keine Galerie-Marken hinterlegt.',
},
galleryBrandPage: {
metaTitleFallback: 'Bildergalerie',
metaTitle: (brand) => `${brand} Umbauten`,
metaDescription: (brand) => `Einblicke in unsere ${brand} Car-Hifi Umbauten.`,
loading: 'Lädt…',
breadcrumbGallery: 'Galerie',
titleSuffix: 'Projekt wählen',
empty: 'Für diese Marke sind noch keine Projekte hinterlegt.',
},
galleryProjectPage: {
metaTitleFallback: 'Bildergalerie',
metaDescription: (project) => `Fotos unseres ${project} Umbaus.`,
loading: 'Lädt…',
breadcrumbGallery: 'Galerie',
empty: 'Für dieses Projekt sind noch keine Fotos hinterlegt.',
enlargeImage: 'Bild vergrößern',
},
};

233
hifi-src/src/i18n/en.js Normal file
View file

@ -0,0 +1,233 @@
export default {
nav: {
openMenu: 'Open menu',
closeMenu: 'Close menu',
close: 'Close',
adminPanel: 'Go to admin panel',
vehicles: 'Vehicles',
services: 'Services',
gallery: 'Gallery',
contact: 'Contact',
whatsapp: 'WhatsApp',
shop: 'Visit shop',
},
themeToggle: {
label: 'Toggle theme',
},
languageToggle: {
label: 'Select language',
},
footer: {
routePlan: 'Get directions',
contactHeading: 'Contact',
contactForm: 'Contact form',
hoursHeading: 'Opening hours',
open: 'Open',
closed: 'Closed',
days: {
monFri: 'Monday Friday',
sat: 'Saturday',
sun: 'Sunday',
},
copyright: (year) => `© ${year} HifiPlanet. All information without guarantee.`,
imprint: 'Legal notice',
privacy: 'Privacy',
terms: 'Terms',
cookieSettings: 'Cookie settings',
},
cookieConsent: {
bannerText: 'We only use technically necessary cookies. For Google Maps and YouTube videos we need your additional consent, since data is transferred to Google in that case.',
moreInfo: 'More details in our',
privacyPolicy: 'privacy policy',
settings: 'Settings',
onlyNecessary: 'Necessary only',
acceptAll: 'Accept all',
modalTitle: 'Cookie settings',
modalIntro: 'Choose which categories you want to allow. More details in our',
necessaryTitle: 'Necessary',
necessaryDesc: 'Required to operate the website (e.g. login session, theme and cookie preference). Cannot be disabled.',
externalTitle: 'External media',
externalDesc: 'Google Maps and YouTube videos. Loading them transfers data (including your IP address) to Google.',
cancel: 'Cancel',
saveSelection: 'Save selection',
},
maintenance: {
title: 'Under maintenance',
defaultMessage: 'This area is currently being updated. Please check back shortly.',
},
lightbox: {
close: 'Close',
previous: 'Previous image',
next: 'Next image',
},
home: {
metaTitle: 'Custom Car-Hifi Installations',
metaDescription: 'HifiPlanet your car-hifi specialist in Amorbach, Germany. Choose your make and model, discover matching sound packages and get in touch, no obligation.',
stickyCta: 'Ready for your sound upgrade?',
selectVehicle: 'Select vehicle',
getInTouch: 'Get in touch',
welcome: 'Welcome to HifiPlanet',
heroTitle: 'Audiophile by principle',
heroSubtitle: 'Your car-hifi specialist for custom sound installations. Choose your vehicle, discover the right package and get in touch, no obligation we take care of the rest.',
stats: [
{ value: 'Since 2010', label: 'in Amorbach, Germany' },
{ value: '20+', label: '3D printers in-house' },
{ value: 'In-house', label: 'CNC & laser workshop' },
{ value: 'Nationwide', label: 'recognized car-hifi specialist' },
],
brandsHeading: 'Brands we work with',
howItWorks: 'How it works',
steps: [
{ title: 'Choose make & model', text: 'Find your vehicle in just a few clicks.' },
{ title: 'Discover a sound package', text: 'Matching packages with current prices at a glance.' },
{ title: 'Get in touch', text: "We'll get back to you shortly with all the details." },
],
youtubeBadge: 'HifiPlanet on YouTube',
youtubeTitle: 'See our installations in action',
youtubeText: 'Installations, sound checks and amplifier tests straight from our workshop "The best sound doesn\'t come from the factory!"',
youtubeChecklist: ['Installations step by step', 'Sound checks & comparisons', 'Amplifier & component tests'],
youtubeCta: 'Visit our YouTube channel',
youtubeEmbedName: 'The YouTube video',
heroImageAlt: 'Ground Zero subwoofer and amplifiers with purple ambient lighting',
craftImageAlt: 'Custom subwoofer build',
adviceImageAlt: 'Amplifier installation with ambient lighting',
moreImageAlt: 'High-end amplifier and subwoofer, studio shot',
craftEyebrow: 'Craftsmanship',
craftTitle: 'Precision work in every detail',
craftText: 'Whether custom-built subwoofer enclosures, integrated lighting or invisibly routed cabling our own CNC, laser and 3D-printing production means we can build solutions you simply can\'t buy off the shelf.',
adviceEyebrow: 'Consultation',
adviceTitle: 'From the first idea to the final touch',
adviceText: "Every vehicle and every requirement is different. That's why we advise you personally and without obligation from a subtle upgrade to an uncompromising high-end system.",
insightsTitle: 'A look at our installations',
insightsText: 'From subtle sound upgrades to elaborate full installations handcrafted in our workshop in Amorbach.',
galleryAlts: [
'Custom subwoofer build with Ground Zero bass drivers',
'Amplifier installation with purple ambient lighting',
'Tweeter and midrange installation by Focal Utopia',
'Sony navigation system with Apple CarPlay in the cockpit',
'Installed tweeter in the trunk',
'Custom subwoofer installation with LED lighting in the trunk',
],
reviewsRating: 'Rated 5.0 by 181 customers',
reviewsText: 'Here\'s what our customers say about us.',
reviewsLink: 'See all reviews on Google →',
testimonials: [
{ name: 'Thomas K.', text: "Found this gem through YouTube. Advice and recommendations are top-notch the Tesla demo car reignited my passion for great sound." },
{ name: 'Andreas I.', text: 'Very professional advice and service. Even though I switched from VAG to Mercedes, everything was adapted smoothly. The 3-way speakers and two subwoofers deliver outstanding performance.' },
{ name: 'Thomas F.', text: 'The complete Audi A4 B9 build exceeded my expectations. Professional from start to finish, with flawless workmanship and sound quality.' },
{ name: 'Drago G.', text: 'Impressed by the CNC work shown on YouTube. Started with 3D-printed speaker rings for my Ford Mustang it turned into a much bigger project.' },
{ name: 'Leonardo B.', text: 'Great service and masterful craftsmanship. Clean installation, passion visible in every detail.' },
{ name: 'Marcel S.', text: 'Unbeatable value for money. The team took a lot of time to find the exact solution for my budget.' },
{ name: 'Julia W.', text: 'Finally a shop that takes motorhomes seriously too. The sound system in our camper now sounds like a living room.' },
{ name: 'Kevin R.', text: 'Dash cam and alarm system installed cleanly in a single appointment, no visible cables. Highly recommended for anyone who values quality workmanship.' },
{ name: 'Sabrina H.', text: 'My classic car now has modern sound without compromising the original look. Exactly what I was looking for.' },
{ name: 'Niklas P.', text: 'From the first inquiry to the finished build, everything went smoothly. Honest advice with no sales pressure.' },
],
moreTitle: 'More than just car-hifi',
moreText: 'Besides custom sound installations, we also offer CNC machining, laser technology, 3D printing and more all from a single source in our own workshop.',
moreCta: 'Discover all our services',
faqHeading: 'Frequently asked questions',
faqs: [
{ question: 'How much does a consultation cost?', answer: 'A consultation and price estimate are completely free and non-binding.' },
{ question: 'Do I need to bring my vehicle in?', answer: 'For an initial assessment, your inquiry through the website is often enough. For the installation itself, we\'ll schedule an appointment together at our workshop in Amorbach.' },
{ question: 'How long does an installation take?', answer: 'It depends on the scope from a simple speaker swap in a few hours to an elaborate full installation over several days.' },
{ question: 'Do you offer solutions for leased vehicles?', answer: 'Yes, on request we install everything reversibly, so your vehicle can be returned to its original condition when handed back.' },
{ question: 'Do you only work with certain brands?', answer: "No, we're brand-independent and choose the components that best fit your needs and budget." },
{ question: "What if my vehicle isn't listed?", answer: "No problem just message us through the contact form, we'll find a suitable solution for any vehicle." },
],
},
vehicleSelect: {
metaTitle: 'Select your vehicle',
metaDescription: 'Choose your vehicle make and model and discover matching car-hifi sound packages from HifiPlanet.',
title: 'Select your vehicle',
intro: 'Choose your make first, then your model we\'ll show you the matching sound packages right away.',
empty: 'No brands have been added yet.',
},
brandPage: {
metaTitleFallback: 'Select a model',
metaDescription: (brand) => `Choose your ${brand} model and discover matching car-hifi sound packages from HifiPlanet.`,
loading: 'Loading…',
breadcrumbVehicles: 'Vehicles',
titleSuffix: 'Select a model',
empty: 'No models have been added for this brand yet.',
},
modelPage: {
metaTitleFallback: 'Sound packages',
metaTitle: (brand, model) => `${brand} ${model} sound packages`,
metaDescription: (brand, model) => `Car-hifi sound packages for the ${brand} ${model} including current prices get in touch with HifiPlanet, no obligation.`,
loading: 'Loading…',
breadcrumbVehicles: 'Vehicles',
titleSuffix: 'Sound packages',
empty: 'No packages have been added for this model yet.',
totalPrice: 'Total price (approx.)',
productLoading: 'Loading product…',
requestContact: 'Request contact',
},
leistungen: {
metaTitle: 'Services',
metaDescription: 'Car-hifi, motorhomes & caravans, classic cars, CNC machining, laser technology, 3D printing, alarm systems and dash cams all from a single source at HifiPlanet in Amorbach.',
title: 'Our services',
intro: 'From custom sound installations to our own CNC and 3D-printing production all from a single source in our workshop in Amorbach.',
notListedTitle: "Don't see your project?",
notListedText: "Just get in touch we'll find the right solution together.",
contact: 'Get in touch',
},
contactPage: {
metaTitle: 'Contact',
metaDescription: 'Get in touch with HifiPlanet in Amorbach for your custom car-hifi project we\'re happy to advise you, no obligation.',
title: 'Get in touch',
sentTitle: 'Thanks for your inquiry!',
sentText: "We'll get back to you as soon as possible.",
contextIntro: 'Your inquiry relates to:',
contextBrand: 'Brand',
contextModel: 'Model',
contextPackage: 'Package',
contextProduct: 'Product',
packagePrice: 'Package price',
upgrades: 'Upgrades',
optionalUpgrades: 'Optional upgrades',
nameLabel: 'Name *',
emailLabel: 'Email *',
phoneLabel: 'Phone',
vinLabel: 'Vehicle identification number (VIN)',
vinPlaceholder: 'Optional helps us assess your vehicle accurately',
messageLabel: 'Message',
sending: 'Sending…',
submit: 'Send inquiry',
cardTitle: 'HifiPlanet Amorbach',
address: 'Address',
phone: 'Phone',
email: 'Email',
hours: 'Opening hours',
hoursWeek: 'MonFri: 9:00 AM6:00 PM',
hoursSat: 'Sat: 10:00 AM1:00 PM',
directionsTitle: 'Directions',
mapEmbedName: 'The Google Maps map',
routePlan: 'Get directions',
},
galleryOverview: {
metaTitle: 'Photo gallery',
metaDescription: 'A look at our car-hifi installations sorted by brand. Choose a brand and explore the projects.',
title: 'Photo gallery',
intro: 'Choose a brand and explore our installations in detail.',
loading: 'Loading…',
empty: 'No gallery brands have been added yet.',
},
galleryBrandPage: {
metaTitleFallback: 'Photo gallery',
metaTitle: (brand) => `${brand} installations`,
metaDescription: (brand) => `A look at our ${brand} car-hifi installations.`,
loading: 'Loading…',
breadcrumbGallery: 'Gallery',
titleSuffix: 'Select a project',
empty: 'No projects have been added for this brand yet.',
},
galleryProjectPage: {
metaTitleFallback: 'Photo gallery',
metaDescription: (project) => `Photos of our ${project} installation.`,
loading: 'Loading…',
breadcrumbGallery: 'Gallery',
empty: 'No photos have been added for this project yet.',
enlargeImage: 'Enlarge image',
},
};

View file

@ -0,0 +1,4 @@
import de from './de.js';
import en from './en.js';
export const dictionaries = { de, en };

View file

@ -5,12 +5,14 @@ import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
export default function BrandPage() {
const { brandSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const maintenance = useMaintenance();
const { t } = useLanguage();
useEffect(() => {
if (maintenance.vehicles.enabled && !maintenance.bypass) return;
@ -21,10 +23,8 @@ export default function BrandPage() {
}, [brandSlug, maintenance.vehicles.enabled, maintenance.bypass]);
usePageMeta({
title: data ? `${data.brand.name} Modelle` : 'Modell auswählen',
description: data
? `Wähle dein ${data.brand.name} Modell und entdecke passende Car-Hifi Sound-Pakete von HifiPlanet.`
: undefined,
title: data ? `${data.brand.name} ${t('brandPage.titleSuffix')}` : t('brandPage.metaTitleFallback'),
description: data ? t('brandPage.metaDescription')(data.brand.name) : undefined,
path: `/fahrzeuge/${brandSlug}`,
});
@ -37,21 +37,21 @@ export default function BrandPage() {
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">Lädt</p>;
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">{t('brandPage.loading')}</p>;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link> / {data.brand.name}
<Link to="/fahrzeuge" className="hover:text-brand-500">{t('brandPage.breadcrumbVehicles')}</Link> / {data.brand.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">
{data.brand.name} Modell wählen
{data.brand.name} {t('brandPage.titleSuffix')}
</h1>
{data.models.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Für diese Marke sind noch keine Modelle hinterlegt.</p>
<p className="text-neutral-500 dark:text-neutral-400">{t('brandPage.empty')}</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">

View file

@ -4,9 +4,7 @@ import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
import { useLanguage } from '../../context/LanguageContext.jsx';
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
@ -15,6 +13,9 @@ const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amor
export default function ContactPage() {
const [params] = useSearchParams();
const { phone, contact_email: contactEmail } = useSiteSettings();
const { t, language } = useLanguage();
const formatPrice = (value) =>
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
const [form, setForm] = useState({ name: '', email: '', phone: '', vin: '', message: '' });
const [status, setStatus] = useState('idle');
const [error, setError] = useState('');
@ -41,8 +42,8 @@ export default function ContactPage() {
.reduce((sum, u) => sum + Number(u.price), 0);
usePageMeta({
title: 'Kontakt',
description: 'Kontaktiere HifiPlanet in Amorbach für dein individuelles Car-Hifi Projekt wir beraten dich gerne unverbindlich.',
title: t('contactPage.metaTitle'),
description: t('contactPage.metaDescription'),
path: '/kontakt',
});
@ -81,30 +82,30 @@ export default function ContactPage() {
return (
<div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">Kontakt aufnehmen</h1>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">{t('contactPage.title')}</h1>
<div className="grid gap-10 md:grid-cols-5">
<div className="md:col-span-3">
{status === 'sent' ? (
<div className="rounded-xl border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-2 text-xl font-bold text-neutral-900 dark:text-white">Danke für deine Anfrage!</h2>
<p className="text-neutral-600 dark:text-neutral-300">Wir melden uns so schnell wie möglich bei dir.</p>
<h2 className="mb-2 text-xl font-bold text-neutral-900 dark:text-white">{t('contactPage.sentTitle')}</h2>
<p className="text-neutral-600 dark:text-neutral-300">{t('contactPage.sentText')}</p>
</div>
) : (
<>
{hasContext && (
<div className="mb-6 rounded-lg border border-brand-200 bg-brand-50 p-4 text-sm text-brand-800 dark:border-brand-900 dark:bg-brand-900/20 dark:text-brand-200">
<p className="font-semibold">Deine Anfrage bezieht sich auf:</p>
<p className="font-semibold">{t('contactPage.contextIntro')}</p>
<ul className="mt-1 space-y-0.5">
{context.brand && <li>Marke: {context.brand}</li>}
{context.model && <li>Modell: {context.model}</li>}
{context.package && <li>Paket: {context.package}</li>}
{context.product && <li>Produkt: {context.product}</li>}
{context.brand && <li>{t('contactPage.contextBrand')}: {context.brand}</li>}
{context.model && <li>{t('contactPage.contextModel')}: {context.model}</li>}
{context.package && <li>{t('contactPage.contextPackage')}: {context.package}</li>}
{context.product && <li>{t('contactPage.contextProduct')}: {context.product}</li>}
</ul>
{packageTotal != null && (
<p className="mt-2 font-semibold">
Paketpreis: {formatPrice(packageTotal)}
{upgradesTotal > 0 && <> + {formatPrice(upgradesTotal)} Upgrades = {formatPrice(packageTotal + upgradesTotal)}</>}
{t('contactPage.packagePrice')}: {formatPrice(packageTotal)}
{upgradesTotal > 0 && <> + {formatPrice(upgradesTotal)} {t('contactPage.upgrades')} = {formatPrice(packageTotal + upgradesTotal)}</>}
</p>
)}
</div>
@ -112,7 +113,7 @@ export default function ContactPage() {
{upgrades.length > 0 && (
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="mb-3 text-sm font-semibold text-neutral-900 dark:text-white">Optionale Upgrades</p>
<p className="mb-3 text-sm font-semibold text-neutral-900 dark:text-white">{t('contactPage.optionalUpgrades')}</p>
<div className="space-y-2">
{upgrades.map((u) => (
<label key={u.id} className="flex cursor-pointer items-start gap-3 rounded-md border border-neutral-200 p-3 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
@ -135,7 +136,7 @@ export default function ContactPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Name *</label>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('contactPage.nameLabel')}</label>
<input
name="name"
required
@ -145,7 +146,7 @@ export default function ContactPage() {
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">E-Mail *</label>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('contactPage.emailLabel')}</label>
<input
type="email"
name="email"
@ -156,7 +157,7 @@ export default function ContactPage() {
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Telefon</label>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('contactPage.phoneLabel')}</label>
<input
name="phone"
value={form.phone}
@ -166,18 +167,18 @@ export default function ContactPage() {
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Fahrgestellnummer (FIN)
{t('contactPage.vinLabel')}
</label>
<input
name="vin"
value={form.vin}
onChange={handleChange}
placeholder="Optional hilft uns bei der genauen Einschätzung deines Fahrzeugs"
placeholder={t('contactPage.vinPlaceholder')}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Nachricht</label>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('contactPage.messageLabel')}</label>
<textarea
name="message"
rows={4}
@ -194,7 +195,7 @@ export default function ContactPage() {
disabled={status === 'sending'}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{status === 'sending' ? 'Wird gesendet…' : 'Anfrage senden'}
{status === 'sending' ? t('contactPage.sending') : t('contactPage.submit')}
</button>
</form>
</>
@ -203,32 +204,32 @@ export default function ContactPage() {
<div className="md:col-span-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-4 font-semibold text-neutral-900 dark:text-white">HifiPlanet Amorbach</h2>
<h2 className="mb-4 font-semibold text-neutral-900 dark:text-white">{t('contactPage.cardTitle')}</h2>
<dl className="space-y-3 text-sm text-neutral-600 dark:text-neutral-300">
<div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">Adresse</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.address')}</dt>
<dd>Boxbrunner Str. 20a, 63916 Amorbach</dd>
</div>
<div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">Telefon</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.phone')}</dt>
<dd><a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a></dd>
</div>
<div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">E-Mail</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.email')}</dt>
<dd><a href={`mailto:${contactEmail}`} className="hover:text-brand-500">{contactEmail}</a></dd>
</div>
<div>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">Öffnungszeiten</dt>
<dd>MoFr: 9:0018:00 Uhr</dd>
<dd>Sa: 10:0013:00 Uhr</dd>
<dt className="font-medium text-neutral-800 dark:text-neutral-100">{t('contactPage.hours')}</dt>
<dd>{t('contactPage.hoursWeek')}</dd>
<dd>{t('contactPage.hoursSat')}</dd>
</div>
</dl>
</div>
<div className="mt-6 rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-4 font-semibold text-neutral-900 dark:text-white">Anfahrt</h2>
<h2 className="mb-4 font-semibold text-neutral-900 dark:text-white">{t('contactPage.directionsTitle')}</h2>
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700">
<ExternalEmbed name="Die Google-Maps-Karte" className="h-[220px] w-full">
<ExternalEmbed name={t('contactPage.mapEmbedName')} className="h-[220px] w-full">
<iframe
title="HifiPlanet Amorbach Standort"
src={`https://www.google.com/maps?q=${SHOP_ADDRESS_ENCODED}&output=embed`}
@ -246,7 +247,7 @@ export default function ContactPage() {
rel="noopener noreferrer"
className="mt-4 flex w-full items-center justify-center gap-2 rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600"
>
Route planen
{t('contactPage.routePlan')}
</a>
</div>
</div>

View file

@ -2,19 +2,21 @@ import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import { useLanguage } from '../../context/LanguageContext.jsx';
export default function GalleryBrandPage() {
const { brandSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const { t } = useLanguage();
useEffect(() => {
api.get(`/gallery-brands/${brandSlug}/projects`).then(setData).catch((e) => setError(e.message));
}, [brandSlug]);
usePageMeta({
title: data ? `${data.brand.name} Umbauten` : 'Bildergalerie',
description: data ? `Einblicke in unsere ${data.brand.name} Car-Hifi Umbauten.` : undefined,
title: data ? t('galleryBrandPage.metaTitle')(data.brand.name) : t('galleryBrandPage.metaTitleFallback'),
description: data ? t('galleryBrandPage.metaDescription')(data.brand.name) : undefined,
path: `/galerie/${brandSlug}`,
});
@ -23,20 +25,20 @@ export default function GalleryBrandPage() {
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">Lädt</p>;
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">{t('galleryBrandPage.loading')}</p>;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
<Link to="/galerie" className="hover:text-brand-500">Galerie</Link> / {data.brand.name}
<Link to="/galerie" className="hover:text-brand-500">{t('galleryBrandPage.breadcrumbGallery')}</Link> / {data.brand.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">
{data.brand.name} Projekt wählen
{data.brand.name} {t('galleryBrandPage.titleSuffix')}
</h1>
{data.projects.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Für diese Marke sind noch keine Projekte hinterlegt.</p>
<p className="text-neutral-500 dark:text-neutral-400">{t('galleryBrandPage.empty')}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">

View file

@ -2,14 +2,16 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import { useLanguage } from '../../context/LanguageContext.jsx';
export default function GalleryOverview() {
const [brands, setBrands] = useState(null);
const [error, setError] = useState('');
const { t } = useLanguage();
usePageMeta({
title: 'Bildergalerie',
description: 'Einblicke in unsere Car-Hifi Umbauten nach Marke sortiert. Wähle eine Marke und entdecke die Projekte.',
title: t('galleryOverview.metaTitle'),
description: t('galleryOverview.metaDescription'),
path: '/galerie',
});
@ -19,15 +21,15 @@ export default function GalleryOverview() {
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">Bildergalerie</h1>
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">{t('galleryOverview.title')}</h1>
<p className="mb-8 text-neutral-600 dark:text-neutral-300">
Wähle eine Marke und entdecke unsere Umbauten im Detail.
{t('galleryOverview.intro')}
</p>
{error && <p className="text-red-600">{error}</p>}
{!brands && !error && <p className="text-neutral-500 dark:text-neutral-400">Lädt</p>}
{!brands && !error && <p className="text-neutral-500 dark:text-neutral-400">{t('galleryOverview.loading')}</p>}
{brands && brands.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Es sind noch keine Galerie-Marken hinterlegt.</p>
<p className="text-neutral-500 dark:text-neutral-400">{t('galleryOverview.empty')}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">

View file

@ -4,12 +4,14 @@ import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import Reveal from '../../components/Reveal.jsx';
import Lightbox from '../../components/Lightbox.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
export default function GalleryProjectPage() {
const { brandSlug, projectSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [openIndex, setOpenIndex] = useState(null);
const { t } = useLanguage();
useEffect(() => {
api
@ -19,8 +21,8 @@ export default function GalleryProjectPage() {
}, [brandSlug, projectSlug]);
usePageMeta({
title: data ? `${data.project.name} ${data.project.brand_name}` : 'Bildergalerie',
description: data ? `Fotos unseres ${data.project.name} Umbaus.` : undefined,
title: data ? `${data.project.name} ${data.project.brand_name}` : t('galleryProjectPage.metaTitleFallback'),
description: data ? t('galleryProjectPage.metaDescription')(data.project.name) : undefined,
path: `/galerie/${brandSlug}/${projectSlug}`,
});
@ -36,19 +38,19 @@ export default function GalleryProjectPage() {
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">Lädt</p>;
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">{t('galleryProjectPage.loading')}</p>;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
<Link to="/galerie" className="hover:text-brand-500">Galerie</Link> /{' '}
<Link to="/galerie" className="hover:text-brand-500">{t('galleryProjectPage.breadcrumbGallery')}</Link> /{' '}
<Link to={`/galerie/${brandSlug}`} className="hover:text-brand-500">{data.project.brand_name}</Link> / {data.project.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">{data.project.name}</h1>
{data.photos.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Für dieses Projekt sind noch keine Fotos hinterlegt.</p>
<p className="text-neutral-500 dark:text-neutral-400">{t('galleryProjectPage.empty')}</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
@ -57,7 +59,7 @@ export default function GalleryProjectPage() {
<button
onClick={() => setOpenIndex(i)}
className="block h-full w-full"
aria-label={photo.caption || 'Bild vergrößern'}
aria-label={photo.caption || t('galleryProjectPage.enlargeImage')}
>
<img
src={photo.image_path}

View file

@ -8,6 +8,7 @@ import StarRating from '../../components/StarRating.jsx';
import TestimonialSlider from '../../components/TestimonialSlider.jsx';
import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
import defaultHeroImage from '../../assets/photos/gallery-dash2.jpg';
import galleryAmp from '../../assets/photos/gallery-amp-purple.jpg';
import gallerySpeaker from '../../assets/photos/gallery-speaker.jpg';
@ -20,60 +21,25 @@ import heroHighlight from '../../assets/leistungen/hero-highlight.jpg';
const YOUTUBE_URL = 'https://www.youtube.com/@hifiplanet2812';
const YOUTUBE_VIDEO_ID = 'ostj2mKDVUc';
const stats = [
{ value: 'Seit 2010', label: 'in Amorbach' },
{ value: '20+', label: '3D-Drucker im Haus' },
{ value: 'Eigene', label: 'CNC- & Laserwerkstatt' },
{ value: 'Bundesweit', label: 'anerkannter Car-Hifi-Spezialist' },
];
const brands = ['Focal', 'Helix', 'Ground Zero', 'Mosconi', 'Hifonics', 'Audison'];
const steps = [
{ title: 'Marke & Modell wählen', text: 'Finde dein Fahrzeug in wenigen Klicks.' },
{ title: 'Sound-Paket entdecken', text: 'Passende Pakete inkl. aktueller Preise auf einen Blick.' },
{ title: 'Unverbindlich anfragen', text: 'Wir melden uns zeitnah mit allen Details bei dir.' },
];
const gallery = [
{ src: gallerySubs, alt: 'Individueller Subwoofer-Bau mit Ground Zero Bässen' },
{ src: galleryAmp, alt: 'Endstufen-Einbau mit violetter Ambiente-Beleuchtung' },
{ src: gallerySpeaker, alt: 'Hochtöner- und Mitteltöner-Einbau von Focal Utopia' },
{ src: galleryDash, alt: 'Sony Navigationssystem mit Apple CarPlay im Cockpit' },
{ src: galleryRear, alt: 'Verbauter Hochtöner im Kofferraum' },
{ src: galleryTrunk, alt: 'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum' },
];
const testimonials = [
{ name: 'Thomas K.', text: 'Über YouTube auf dieses Klang-Juwel gestoßen. Beratung und Empfehlungen sind erstklassig der Tesla-Vorführwagen hat meine Leidenschaft für guten Sound neu entfacht.' },
{ name: 'Andreas I.', text: 'Sehr professionelle Beratung und Service. Obwohl ich von VAG zu Mercedes gewechselt bin, wurde alles reibungslos angepasst. Die 3-Wege-Lautsprecher und zwei Subwoofer liefern außergewöhnliche Leistung.' },
{ name: 'Thomas F.', text: 'Kompletter Audi A4 B9 Umbau hat meine Erwartungen übertroffen. Von Anfang bis Ende professionell, mit tadelloser Verarbeitung und Klangqualität.' },
{ name: 'Drago G.', text: 'Beeindruckt von der CNC-Arbeit, die auf YouTube gezeigt wird. Angefangen mit 3D-gedruckten Lautsprecherringen für meinen Ford Mustang daraus wurde ein viel größeres Projekt.' },
{ name: 'Leonardo B.', text: 'Toller Service und meisterhafte Handwerkskunst. Saubere Installation, Leidenschaft in jedem Detail erkennbar.' },
{ name: 'Marcel S.', text: 'Unschlagbares Preis-Leistungs-Verhältnis. Das Team hat sich viel Zeit genommen, um genau die Lösung für mein Budget zu finden.' },
{ name: 'Julia W.', text: 'Endlich ein Betrieb, der auch Wohnmobile ernst nimmt. Die Soundanlage in unserem Camper klingt jetzt wie im Wohnzimmer.' },
{ name: 'Kevin R.', text: 'Dashcam und Alarmanlage in einem Termin sauber verbaut, keine sichtbaren Kabel. Absolute Empfehlung für alle, die Wert auf Verarbeitung legen.' },
{ name: 'Sabrina H.', text: 'Mein Oldtimer hat jetzt modernen Sound, ohne dass es dem Original-Look geschadet hätte. Genau das habe ich gesucht.' },
{ name: 'Niklas P.', text: 'Von der ersten Anfrage bis zum fertigen Umbau lief alles reibungslos. Ehrliche Beratung ohne Verkaufsdruck.' },
];
const faqs = [
{ question: 'Was kostet eine Beratung?', answer: 'Beratung und Preisanfrage sind für dich komplett kostenlos und unverbindlich.' },
{ question: 'Muss ich mein Fahrzeug vorbeibringen?', answer: 'Für eine erste Einschätzung reicht oft deine Anfrage über die Website. Für den Einbau selbst vereinbaren wir gemeinsam einen Termin bei uns in Amorbach.' },
{ question: 'Wie lange dauert ein Umbau?', answer: 'Das hängt vom Umfang ab von einem einfachen Lautsprecher-Tausch in wenigen Stunden bis zum aufwendigen Komplettumbau über mehrere Tage.' },
{ question: 'Bietet ihr auch Lösungen für Leasingfahrzeuge?', answer: 'Ja, auf Wunsch bauen wir reversibel um, sodass dein Fahrzeug bei Rückgabe wieder in den Originalzustand versetzt werden kann.' },
{ question: 'Arbeitet ihr nur mit bestimmten Marken?', answer: 'Nein, wir sind herstellerunabhängig und wählen die Komponenten, die am besten zu deinem Anspruch und Budget passen.' },
{ question: 'Was ist, wenn mein Fahrzeug nicht gelistet ist?', answer: 'Kein Problem schreib uns einfach über das Kontaktformular, wir finden für jedes Fahrzeug eine passende Lösung.' },
];
const galleryImages = [gallerySubs, galleryAmp, gallerySpeaker, galleryDash, galleryRear, galleryTrunk];
export default function Home() {
const { hero_image_path: heroImagePath } = useSiteSettings();
const heroImage = heroImagePath || defaultHeroImage;
const { t, language } = useLanguage();
const stats = t('home.stats');
const steps = t('home.steps');
const galleryAlts = t('home.galleryAlts');
const gallery = galleryImages.map((src, i) => ({ src, alt: galleryAlts[i] }));
const testimonials = t('home.testimonials');
const faqs = t('home.faqs');
usePageMeta({
title: 'Car-Hifi Umbauten nach Maß',
description:
'HifiPlanet dein Car-Hifi Spezialist in Amorbach. Marke und Modell wählen, passende Sound-Pakete entdecken und unverbindlich anfragen.',
title: t('home.metaTitle'),
description: t('home.metaDescription'),
path: '/',
});
@ -91,7 +57,7 @@ export default function Home() {
});
document.head.appendChild(script);
return () => document.head.removeChild(script);
}, []);
}, [language]);
const heroRef = useRef(null);
const { scrollYProgress: heroProgress } = useScroll({ target: heroRef, offset: ['start start', 'end start'] });
@ -110,20 +76,20 @@ export default function Home() {
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3">
<p className="hidden text-sm font-medium text-neutral-600 dark:text-neutral-300 sm:block">
Bereit für deinen Sound-Umbau?
{t('home.stickyCta')}
</p>
<div className="flex w-full gap-3 sm:w-auto">
<Link
to="/fahrzeuge"
className="flex-1 rounded-md bg-brand-500 px-5 py-2.5 text-center text-sm font-semibold text-white shadow-sm transition hover:-translate-y-0.5 hover:bg-brand-600 sm:flex-none"
>
Fahrzeug auswählen
{t('home.selectVehicle')}
</Link>
<Link
to="/kontakt"
className="flex-1 rounded-md border border-neutral-300 px-5 py-2.5 text-center text-sm font-semibold text-neutral-700 transition hover:-translate-y-0.5 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800 sm:flex-none"
>
Kontakt aufnehmen
{t('home.getInTouch')}
</Link>
</div>
</div>
@ -133,7 +99,7 @@ export default function Home() {
<motion.img
style={{ y: heroImageY }}
src={heroImage}
alt="Ground Zero Subwoofer und Endstufen mit violetter Ambiente-Beleuchtung"
alt={t('home.heroImageAlt')}
className="absolute inset-0 h-full w-full scale-110 object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-neutral-950 via-neutral-950/80 to-neutral-950/40" />
@ -148,27 +114,26 @@ export default function Home() {
transition={{ duration: 0.7, ease: 'easeOut' }}
>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-brand-400">
Willkommen bei HifiPlanet
{t('home.welcome')}
</p>
<h1 className="text-3xl font-extrabold tracking-tight text-white sm:text-5xl">
Audiophil aus Prinzip
{t('home.heroTitle')}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-base text-neutral-200 sm:text-lg">
Dein Car-Hifi Spezialist für individuelle Sound-Umbauten. Wähle dein Fahrzeug,
entdecke passende Pakete und frag unverbindlich an wir kümmern uns um den Rest.
{t('home.heroSubtitle')}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
to="/fahrzeuge"
className="rounded-md bg-brand-500 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:-translate-y-0.5 hover:bg-brand-600 hover:shadow-lg"
>
Fahrzeug auswählen
{t('home.selectVehicle')}
</Link>
<Link
to="/kontakt"
className="rounded-md border border-white/30 bg-white/10 px-6 py-3 text-sm font-semibold text-white backdrop-blur transition hover:-translate-y-0.5 hover:bg-white/20"
>
Kontakt aufnehmen
{t('home.getInTouch')}
</Link>
</div>
</motion.div>
@ -189,7 +154,7 @@ export default function Home() {
<section className="border-b border-neutral-200 py-8 dark:border-neutral-800">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<p className="mb-4 text-center text-xs font-semibold uppercase tracking-wide text-neutral-400">
Marken, mit denen wir arbeiten
{t('home.brandsHeading')}
</p>
<div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3">
{brands.map((brand, i) => (
@ -203,7 +168,7 @@ export default function Home() {
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<Reveal>
<h2 className="mb-8 text-center text-2xl font-bold text-neutral-900 dark:text-white">So funktioniert's</h2>
<h2 className="mb-8 text-center text-2xl font-bold text-neutral-900 dark:text-white">{t('home.howItWorks')}</h2>
</Reveal>
<div className="grid gap-6 sm:grid-cols-3">
{steps.map((step, i) => (
@ -231,14 +196,14 @@ export default function Home() {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5 text-white">
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31.4 31.4 0 0 0 0 12a31.4 31.4 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31.4 31.4 0 0 0 24 12a31.4 31.4 0 0 0-.5-5.8ZM9.6 15.6V8.4l6.3 3.6-6.3 3.6Z" />
</svg>
HifiPlanet auf YouTube
{t('home.youtubeBadge')}
</div>
<h2 className="text-3xl font-extrabold text-white sm:text-4xl">Sieh dir unsere Umbauten in Aktion an</h2>
<h2 className="text-3xl font-extrabold text-white sm:text-4xl">{t('home.youtubeTitle')}</h2>
<p className="mx-auto mt-4 max-w-xl text-lg text-neutral-300 lg:mx-0">
Umbauten, Soundchecks und Verstärker-Tests aus unserer Werkstatt Den besten Sound gibt es nicht ab Werk!"
{t('home.youtubeText')}
</p>
<ul className="mx-auto mt-6 flex max-w-xl flex-col items-center gap-2 text-sm text-neutral-300 sm:flex-row sm:flex-wrap sm:justify-center lg:mx-0 lg:justify-start">
{['Umbauten Schritt für Schritt', 'Soundchecks & Vergleiche', 'Verstärker- & Komponenten-Tests'].map((item) => (
{t('home.youtubeChecklist').map((item) => (
<li key={item} className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-3.5 w-3.5 flex-none">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@ -253,7 +218,7 @@ export default function Home() {
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-2 rounded-md bg-brand-500 px-6 py-3 text-sm font-bold text-white shadow-lg shadow-black/40 transition hover:-translate-y-0.5 hover:bg-brand-400"
>
Zum YouTube-Kanal
{t('home.youtubeCta')}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0-4 4m4-4H3" />
</svg>
@ -262,7 +227,7 @@ export default function Home() {
<Reveal direction="right" index={1} className="overflow-hidden rounded-xl border border-white/10 shadow-2xl shadow-black/40">
<div className="aspect-video w-full">
<ExternalEmbed name="Das YouTube-Video" className="h-full w-full">
<ExternalEmbed name={t('home.youtubeEmbedName')} className="h-full w-full">
<iframe
className="h-full w-full"
src={`https://www.youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}`}
@ -281,29 +246,27 @@ export default function Home() {
<div className="mx-auto max-w-6xl space-y-16 overflow-hidden px-4 sm:px-6">
<div className="grid items-center gap-8 md:grid-cols-2">
<Reveal direction="left" shine className="rounded-xl shadow-lg">
<img src={gallerySubs} alt="Individueller Subwoofer-Bau" className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} />
<img src={gallerySubs} alt={t('home.craftImageAlt')} className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} />
</Reveal>
<Reveal direction="right" index={1}>
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">Handwerk</p>
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">Präzisionsarbeit in jedem Detail</h2>
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">{t('home.craftEyebrow')}</p>
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">{t('home.craftTitle')}</h2>
<p className="text-neutral-600 dark:text-neutral-300">
Ob eigens gefertigte Subwoofer-Gehäuse, integrierte Beleuchtung oder unsichtbar verlegte Kabelwege
dank eigener CNC-, Laser- und 3D-Druck-Fertigung entstehen bei uns Lösungen, die es von der Stange nicht gibt.
{t('home.craftText')}
</p>
</Reveal>
</div>
<div className="grid items-center gap-8 md:grid-cols-2">
<Reveal direction="left" className="order-2 md:order-1">
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">Beratung</p>
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">Von der ersten Idee bis zum letzten Schliff</h2>
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">{t('home.adviceEyebrow')}</p>
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">{t('home.adviceTitle')}</h2>
<p className="text-neutral-600 dark:text-neutral-300">
Jedes Fahrzeug und jeder Anspruch ist anders. Deshalb beraten wir dich persönlich und unverbindlich
vom dezenten Upgrade bis zum kompromisslosen High-End-System.
{t('home.adviceText')}
</p>
</Reveal>
<Reveal direction="right" index={1} shine className="order-1 md:order-2 rounded-xl shadow-lg">
<img src={galleryAmp} alt="Endstufen-Einbau mit Ambiente-Beleuchtung" className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} />
<img src={galleryAmp} alt={t('home.adviceImageAlt')} className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} />
</Reveal>
</div>
</div>
@ -311,9 +274,9 @@ export default function Home() {
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<Reveal className="mb-8 text-center">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">Einblicke in unsere Umbauten</h2>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{t('home.insightsTitle')}</h2>
<p className="mt-2 text-neutral-600 dark:text-neutral-300">
Vom unauffälligen Sound-Upgrade bis zum aufwendigen Komplettumbau Handarbeit aus unserer Werkstatt in Amorbach.
{t('home.insightsText')}
</p>
</Reveal>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
@ -331,15 +294,15 @@ export default function Home() {
<div className="mb-2 flex items-center justify-center gap-2">
<StarRating className="h-6 w-6" />
</div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">5,0 von 181 Kunden bewertet</h2>
<p className="mt-1 text-neutral-600 dark:text-neutral-300">Das sagen unsere Kunden über uns.</p>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{t('home.reviewsRating')}</h2>
<p className="mt-1 text-neutral-600 dark:text-neutral-300">{t('home.reviewsText')}</p>
<a
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-sm font-medium text-brand-600 hover:underline dark:text-brand-400"
>
Alle Bewertungen auf Google ansehen
{t('home.reviewsLink')}
</a>
</Reveal>
<Reveal>
@ -351,19 +314,18 @@ export default function Home() {
<section className="mx-auto max-w-6xl overflow-hidden px-4 py-16 sm:px-6">
<div className="grid items-center gap-10 md:grid-cols-2">
<Reveal direction="left" shine className="overflow-hidden rounded-xl shadow-lg">
<img src={heroHighlight} alt="Hochwertige Endstufe und Subwoofer, Studioaufnahme" className="h-full w-full object-cover" loading="lazy" />
<img src={heroHighlight} alt={t('home.moreImageAlt')} className="h-full w-full object-cover" loading="lazy" />
</Reveal>
<Reveal direction="right" index={1} className="text-center md:text-left">
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">Mehr als nur Car-Hifi</h2>
<h2 className="mb-3 text-2xl font-bold text-neutral-900 dark:text-white">{t('home.moreTitle')}</h2>
<p className="mx-auto mb-8 max-w-2xl text-neutral-600 dark:text-neutral-300 md:mx-0">
Neben individuellen Sound-Umbauten bieten wir CNC-Zerspanung, Lasertechnik, 3D-Druck und mehr
alles aus einer Hand in unserer eigenen Werkstatt.
{t('home.moreText')}
</p>
<Link
to="/leistungen"
className="inline-block rounded-md border border-neutral-300 px-6 py-3 text-sm font-semibold text-neutral-700 transition hover:-translate-y-0.5 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
Alle Leistungen entdecken
{t('home.moreCta')}
</Link>
</Reveal>
</div>
@ -372,7 +334,7 @@ export default function Home() {
<section className="border-t border-neutral-200 py-16 dark:border-neutral-800">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<Reveal className="mb-8 text-center">
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">Häufig gestellte Fragen</h2>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{t('home.faqHeading')}</h2>
</Reveal>
<Reveal index={1}>
<Accordion items={faqs} />

View file

@ -6,20 +6,21 @@ import DynamicIcon from '../../components/DynamicIcon.jsx';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
import { api } from '../../api/client.js';
export default function Leistungen() {
const [services, setServices] = useState([]);
const maintenance = useMaintenance();
const { t } = useLanguage();
useEffect(() => {
api.get('/services').then(setServices).catch(() => setServices([]));
}, []);
usePageMeta({
title: 'Leistungen',
description:
'Car-Hifi, Wohnmobil & Caravan, Oldtimer, CNC-Zerspanung, Lasertechnik, 3D-Druck, Alarmanlagen und Dash Cams alles aus einer Hand bei HifiPlanet in Amorbach.',
title: t('leistungen.metaTitle'),
description: t('leistungen.metaDescription'),
path: '/leistungen',
});
@ -30,10 +31,9 @@ export default function Leistungen() {
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.services.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">Unsere Leistungen</h1>
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">{t('leistungen.title')}</h1>
<p className="mb-10 max-w-2xl text-neutral-600 dark:text-neutral-300">
Von individuellen Sound-Umbauten bis zur eigenen CNC- und 3D-Druck-Fertigung alles aus einer Hand
in unserer Werkstatt in Amorbach.
{t('leistungen.intro')}
</p>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
@ -66,10 +66,10 @@ export default function Leistungen() {
</div>
<div className="mt-12 rounded-xl border border-neutral-200 bg-neutral-50 p-8 text-center dark:border-neutral-800 dark:bg-neutral-900/40">
<h2 className="mb-2 text-xl font-bold text-neutral-900 dark:text-white">Dein Projekt ist nicht dabei?</h2>
<p className="mb-5 text-neutral-600 dark:text-neutral-300">Sprich uns einfach an wir finden gemeinsam die passende Lösung.</p>
<h2 className="mb-2 text-xl font-bold text-neutral-900 dark:text-white">{t('leistungen.notListedTitle')}</h2>
<p className="mb-5 text-neutral-600 dark:text-neutral-300">{t('leistungen.notListedText')}</p>
<Link to="/kontakt" className="inline-block rounded-md bg-brand-500 px-6 py-3 text-sm font-semibold text-white hover:bg-brand-600">
Kontakt aufnehmen
{t('leistungen.contact')}
</Link>
</div>
</div>

View file

@ -5,15 +5,16 @@ import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
import { useLanguage } from '../../context/LanguageContext.jsx';
export default function ModelPage() {
const { brandSlug, modelSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const maintenance = useMaintenance();
const { t, language } = useLanguage();
const formatPrice = (value) =>
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
useEffect(() => {
if (maintenance.vehicles.enabled && !maintenance.bypass) return;
@ -24,10 +25,8 @@ export default function ModelPage() {
}, [brandSlug, modelSlug, maintenance.vehicles.enabled, maintenance.bypass]);
usePageMeta({
title: data ? `${data.model.brand_name} ${data.model.name} Sound-Pakete` : 'Sound-Pakete',
description: data
? `Car-Hifi Sound-Pakete für ${data.model.brand_name} ${data.model.name} inkl. aktueller Preise von HifiPlanet unverbindlich anfragen.`
: undefined,
title: data ? t('modelPage.metaTitle')(data.model.brand_name, data.model.name) : t('modelPage.metaTitleFallback'),
description: data ? t('modelPage.metaDescription')(data.model.brand_name, data.model.name) : undefined,
path: `/fahrzeuge/${brandSlug}/${modelSlug}`,
});
@ -40,7 +39,7 @@ export default function ModelPage() {
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">Lädt</p>;
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">{t('modelPage.loading')}</p>;
}
const { model, packages } = data;
@ -60,15 +59,15 @@ export default function ModelPage() {
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link> /{' '}
<Link to="/fahrzeuge" className="hover:text-brand-500">{t('modelPage.breadcrumbVehicles')}</Link> /{' '}
<Link to={`/fahrzeuge/${brandSlug}`} className="hover:text-brand-500">{model.brand_name}</Link> / {model.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">
{model.brand_name} {model.name} Sound-Pakete
{model.brand_name} {model.name} {t('modelPage.titleSuffix')}
</h1>
{packages.length === 0 && (
<p className="text-neutral-500 dark:text-neutral-400">Für dieses Modell sind noch keine Pakete hinterlegt.</p>
<p className="text-neutral-500 dark:text-neutral-400">{t('modelPage.empty')}</p>
)}
<div className="space-y-6">
@ -77,7 +76,7 @@ export default function ModelPage() {
<div className="mb-4 flex flex-wrap items-start justify-between gap-4">
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">{pkg.name}</h2>
<div className="text-right">
<p className="text-xs uppercase tracking-wide text-neutral-400">Gesamtpreis (ca.)</p>
<p className="text-xs uppercase tracking-wide text-neutral-400">{t('modelPage.totalPrice')}</p>
<p className="text-xl font-extrabold text-brand-600 dark:text-brand-400">{formatPrice(pkg.total_price)}</p>
</div>
</div>
@ -85,7 +84,7 @@ export default function ModelPage() {
<ul className="mb-5 list-disc space-y-1 pl-5 text-sm text-neutral-700 dark:text-neutral-300">
{pkg.products.map((product) => (
<li key={product.id}>
{product.name_override || product.scraped_name || 'Produkt wird geladen…'}
{product.name_override || product.scraped_name || t('modelPage.productLoading')}
</li>
))}
{pkg.description
@ -99,7 +98,7 @@ export default function ModelPage() {
to={contactUrl(pkg)}
className="inline-block rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Kontakt anfragen
{t('modelPage.requestContact')}
</Link>
</div>
))}

View file

@ -5,15 +5,17 @@ import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
export default function VehicleSelect() {
const [brands, setBrands] = useState([]);
const [error, setError] = useState('');
const maintenance = useMaintenance();
const { t } = useLanguage();
usePageMeta({
title: 'Fahrzeug auswählen',
description: 'Wähle deine Fahrzeugmarke und dein Modell und entdecke passende Car-Hifi Sound-Pakete von HifiPlanet.',
title: t('vehicleSelect.metaTitle'),
description: t('vehicleSelect.metaDescription'),
path: '/fahrzeuge',
});
@ -29,15 +31,15 @@ export default function VehicleSelect() {
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">Fahrzeug auswählen</h1>
<h1 className="mb-2 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">{t('vehicleSelect.title')}</h1>
<p className="mb-8 text-neutral-600 dark:text-neutral-300">
Wähle zuerst deine Marke, dann dein Modell wir zeigen dir direkt die passenden Sound-Pakete.
{t('vehicleSelect.intro')}
</p>
{error && <p className="text-red-600">{error}</p>}
{brands.length === 0 && !error && (
<p className="text-neutral-500 dark:text-neutral-400">Es sind noch keine Marken hinterlegt.</p>
<p className="text-neutral-500 dark:text-neutral-400">{t('vehicleSelect.empty')}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">