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:
parent
ca4d3a2079
commit
e282a981a6
20 changed files with 912 additions and 272 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:00–18:00 Uhr' },
|
||||
{ days: 'Samstag', from: 10 * 60, to: 13 * 60, label: '10:00–13: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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}`}
|
||||
|
|
|
|||
56
hifi-src/src/context/LanguageContext.jsx
Normal file
56
hifi-src/src/context/LanguageContext.jsx
Normal 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
233
hifi-src/src/i18n/de.js
Normal 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: 'Mo–Fr: 9:00–18:00 Uhr',
|
||||
hoursSat: 'Sa: 10:00–13: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
233
hifi-src/src/i18n/en.js
Normal 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: 'Mon–Fri: 9:00 AM–6:00 PM',
|
||||
hoursSat: 'Sat: 10:00 AM–1: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',
|
||||
},
|
||||
};
|
||||
4
hifi-src/src/i18n/index.js
Normal file
4
hifi-src/src/i18n/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import de from './de.js';
|
||||
import en from './en.js';
|
||||
|
||||
export const dictionaries = { de, en };
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>Mo–Fr: 9:00–18:00 Uhr</dd>
|
||||
<dd>Sa: 10:00–13: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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue