HifiPlanet-Website/hifi-src/src/pages/public/ContactPage.jsx
Maaxxs 79fc5f2f76 Match the contact section to the prototype: Barlow, map crop, card title
Side-by-side with the prototype revealed the section never set its font
family - everything except the Michroma headings fell back to the system
font, which was the biggest visual mismatch (labels, subtitle, feature
bar). The section root now sets Barlow like the handoff specifies; the
subtitle even wraps identically to the prototype again.

The maps iframe crop is rebalanced (290px shifted -60px inside the 210px
window) so the map runs flush to the "Route planen" bar with Google's
bottom attribution cut at the card edge like the prototype, instead of
showing the full attribution line with a gap. The contact card is titled
"HiFi Planet Amorbach" per the design (was "HifiPlanet Amorbach").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 07:29:26 +02:00

550 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
import { useCookieConsent } from '../../context/CookieConsentContext.jsx';
import { User, Mail, Phone, Car, Package, Send, MapPin, ExternalLink } from 'lucide-react';
// Die Sektions-Hintergruende (bg-light.png / bg-dark.png) kommen als fertig
// gebackene Einzelbilder ueber die CSS-Klasse .kontakt-sektion (index.css).
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
const waHref = (whatsapp) => `https://wa.me/${digitsOnly(whatsapp).replace(/^0/, '49').replace('+', '')}`;
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 michroma = { fontFamily: "'Michroma', sans-serif" };
function WhatsAppIcon({ className }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2a10 10 0 0 0-8.6 15l-1.3 4.8 5-1.3A10 10 0 1 0 12 2zm0 2a8 8 0 1 1-4.2 14.8l-.3-.2-2.6.7.7-2.5-.2-.3A8 8 0 0 1 12 4zm4.3 9.9c-.2-.1-1.4-.7-1.6-.8s-.4-.1-.5.1l-.7.9c-.1.1-.3.2-.5.1a6.5 6.5 0 0 1-3.2-2.8c-.1-.2 0-.4.1-.5l.4-.5.2-.4v-.4l-.7-1.7c-.2-.4-.4-.4-.5-.4h-.5c-.2 0-.4.1-.6.3-.7.7-.9 1.6-.6 2.7.5 1.7 1.6 3.1 3.4 4.2 1.7 1 2.5 1 3.4.9.5-.1 1.4-.6 1.6-1.1.2-.5.2-1 .1-1.1z" />
</svg>
);
}
// Die 4 Feature-Icons aus dem Referenzdesign - kein 1:1-Lucide-Pendant fuer alle,
// daher als rohe Pfade uebernommen statt einem angenaeherten Lucide-Icon.
function FeatureIcon({ path, className }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={path} />
</svg>
);
}
const FEATURE_ICON_PATHS = {
premium: 'M4 12v3M8 8v11M12 4v16M16 8v11M20 12v3',
germany: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0 M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19',
individual: 'M6 3h12l4 6-10 12L2 9z M11 3 8 9l4 12 4-12-3-6M2 9h20',
experts: 'M20 6 9 17l-5-5',
};
// Michroma hat kein Euro-Zeichen-Glyph (rendert als Tofu/"O"-Ersatzform) - das
// Waehrungssymbol bekommt darum immer die System-Schrift, nur Ziffern/Komma bleiben
// in Michroma. Robust gegen beide Symbolpositionen (de-DE: "999,00 €", en-US: "€999.00").
function PriceMichroma({ formatted, color }) {
const [pre, post] = formatted.split('€');
return (
<span style={{ ...michroma, color }}>
{pre}
<span style={{ fontFamily: "'Barlow', sans-serif" }}></span>
{post}
</span>
);
}
function Field({ icon: Icon, label, name, type = 'text', required, value, onChange, placeholder }) {
return (
<div className="flex flex-col gap-2">
<label
htmlFor={`kf-${name}`}
className="text-xs font-semibold uppercase tracking-[2px]"
style={{ color: 'var(--kf-label)' }}
>
{label}
</label>
<div className="relative flex items-center">
<Icon className="pointer-events-none absolute left-[14px] h-[17px] w-[17px]" style={{ color: 'var(--kf-fieldicon)' }} />
<input
id={`kf-${name}`}
name={name}
type={type}
required={required}
value={value}
onChange={onChange}
placeholder={placeholder}
className="w-full rounded-lg border py-3.5 pl-[42px] pr-4 text-[15px] outline-none transition-colors"
style={{ background: 'var(--kf-field)', borderColor: 'var(--kf-fieldbrd)', color: 'var(--kf-text2)' }}
/>
</div>
</div>
);
}
export default function ContactPage() {
const [params] = useSearchParams();
const { phone, whatsapp, contact_email: contactEmail } = useSiteSettings();
const { t, language } = useLanguage();
const { consent } = useCookieConsent();
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('');
const [upgrades, setUpgrades] = useState([]);
const [selectedUpgradeIds, setSelectedUpgradeIds] = useState([]);
const packageId = params.get('packageId');
const packageTotal = params.get('total') ? Number(params.get('total')) : null;
useEffect(() => {
if (!packageId) {
setUpgrades([]);
return;
}
api.get(`/packages/${packageId}/upgrades`).then(setUpgrades).catch(() => setUpgrades([]));
}, [packageId]);
const toggleUpgrade = (id) => {
setSelectedUpgradeIds((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
};
const upgradesTotal = upgrades
.filter((u) => selectedUpgradeIds.includes(u.id))
.reduce((sum, u) => sum + Number(u.price), 0);
usePageMeta({
title: t('contactPage.metaTitle'),
description: t('contactPage.metaDescription'),
path: '/kontakt',
});
const context = {
brand: params.get('brand') || '',
model: params.get('model') || '',
package: params.get('package') || '',
};
const hasPackage = Boolean(context.brand && context.model && context.package);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('sending');
setError('');
try {
await api.post('/contact', {
...form,
brand_name: context.brand || null,
model_name: context.model || null,
package_name: context.package || null,
product_name: params.get('product') || null,
package_id: packageId || null,
package_product_id: params.get('productId') || null,
upgrade_ids: selectedUpgradeIds,
});
setStatus('sent');
setForm({ name: '', email: '', phone: '', vin: '', message: '' });
} catch (e) {
setError(e.message);
setStatus('idle');
}
};
const features = [
{ key: 'premium', title: t('contactPage.featurePremiumTitle'), sub: t('contactPage.featurePremiumSub') },
{ key: 'germany', title: t('contactPage.featureGermanyTitle'), sub: t('contactPage.featureGermanySub') },
{ key: 'individual', title: t('contactPage.featureIndividualTitle'), sub: t('contactPage.featureIndividualSub') },
{ key: 'experts', title: t('contactPage.featureExpertsTitle'), sub: t('contactPage.featureExpertsSub') },
];
return (
<div
className="kf-page kontakt-sektion relative w-full overflow-hidden px-4 pb-10 sm:px-10"
style={{ color: 'var(--kf-text2)', fontFamily: "'Barlow', sans-serif" }}
>
<div className="relative z-10 mx-auto max-w-[1280px] pt-10 sm:pt-14">
{/* Hero */}
<div
className="mb-3 text-[13px] font-semibold uppercase tracking-[6px]"
style={{ color: 'var(--kf-accent2)' }}
>
{t('contactPage.heroEyebrow')}
</div>
<h1
className="text-[34px] leading-[1.08] sm:text-[46px] lg:text-[60px]"
style={{ ...michroma, color: 'var(--kf-text)' }}
>
{t('contactPage.heroTitlePre')} <span style={{ color: 'var(--kf-accent2)' }}>{t('contactPage.heroTitleAccent')}</span> {t('contactPage.heroTitlePost')}
</h1>
<p className="mb-10 mt-5 max-w-[520px] text-lg leading-[1.55]" style={{ color: 'var(--kf-muted)' }}>
{t('contactPage.heroSubtitle')}
</p>
{/* Hauptraster */}
<div className="grid grid-cols-1 items-start gap-10 min-[981px]:grid-cols-[minmax(0,1.35fr)_minmax(320px,1fr)]">
{/* Formular-Karte */}
<div>
<div
className="rounded-2xl border p-[22px] sm:p-10"
style={{ background: 'var(--kf-card)', borderColor: 'var(--kf-cardbrd)', boxShadow: 'var(--kf-shadow)' }}
>
{hasPackage && (
<div
className="mb-8 rounded-xl border px-6 py-[22px]"
style={{ background: 'var(--kf-pkgbg)', borderColor: 'var(--kf-pkgbrd)' }}
>
<div
className="mb-3.5 flex items-center gap-2.5 text-xs font-bold uppercase tracking-[2px]"
style={{ color: 'var(--kf-accent2)' }}
>
<Package className="h-4 w-4" />
{t('contactPage.packageBoxTitle')}
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-5 gap-y-2 text-[15px]">
<span style={{ color: 'var(--kf-label)' }}>{t('contactPage.contextBrand')}</span>
<span className="font-semibold" style={{ color: 'var(--kf-text2)' }}>{context.brand}</span>
<span style={{ color: 'var(--kf-label)' }}>{t('contactPage.contextModel')}</span>
<span className="font-semibold" style={{ color: 'var(--kf-text2)' }}>{context.model}</span>
<span style={{ color: 'var(--kf-label)' }}>{t('contactPage.contextPackage')}</span>
<span className="font-semibold" style={{ color: 'var(--kf-text2)' }}>{context.package}</span>
</div>
<div
className="mt-3.5 flex items-baseline justify-between border-t pt-3.5"
style={{ borderColor: 'var(--kf-pkgbrd)' }}
>
<span className="text-[13px] font-semibold uppercase tracking-wide" style={{ color: 'var(--kf-muted)' }}>
{t('contactPage.packagePrice')}
</span>
<span className="text-xl">
{packageTotal != null ? <PriceMichroma formatted={formatPrice(packageTotal)} color="var(--kf-text)" /> : '—'}
</span>
</div>
</div>
)}
{upgrades.length > 0 && (
<div
className="mb-8 rounded-xl border p-5"
style={{ background: 'var(--kf-field)', borderColor: 'var(--kf-fieldbrd)' }}
>
<div className="mb-3 text-xs font-bold uppercase tracking-[2px]" style={{ color: 'var(--kf-accent2)' }}>
{t('contactPage.optionalUpgrades')}
</div>
<div className="space-y-2">
{upgrades.map((u) => (
<label
key={u.id}
className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-sm"
style={{ borderColor: 'var(--kf-fieldbrd)' }}
>
<input
type="checkbox"
checked={selectedUpgradeIds.includes(u.id)}
onChange={() => toggleUpgrade(u.id)}
className="mt-0.5 h-4 w-4 rounded"
style={{ accentColor: 'var(--kf-accent)' }}
/>
<span className="flex-1">
<span className="block font-semibold" style={{ color: 'var(--kf-text2)' }}>{u.name}</span>
{u.description && (
<span className="block text-xs" style={{ color: 'var(--kf-muted)' }}>{u.description}</span>
)}
</span>
<span className="font-semibold" style={{ color: 'var(--kf-accent2)' }}>+{formatPrice(u.price)}</span>
</label>
))}
</div>
{selectedUpgradeIds.length > 0 && packageTotal != null && (
<div
className="mt-3 flex items-baseline justify-between border-t pt-3 text-sm"
style={{ borderColor: 'var(--kf-pkgbrd)' }}
>
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--kf-muted)' }}>
{t('contactPage.totalWithUpgrades')}
</span>
<PriceMichroma formatted={formatPrice(packageTotal + upgradesTotal)} color="var(--kf-text)" />
</div>
)}
</div>
)}
<h2 className="mb-8 text-xl" style={{ ...michroma, color: 'var(--kf-text)' }}>
{t('contactPage.formTitle')}
</h2>
{status === 'sent' && (
<div
className="mb-6 rounded-[10px] border p-5"
style={{ borderColor: 'var(--kf-accent)', background: 'var(--kf-accentsoft)' }}
>
<div className="text-base font-bold" style={{ color: 'var(--kf-accent2)' }}>
{t('contactPage.sentTitle')}
</div>
<div className="mt-1 text-sm" style={{ color: 'var(--kf-muted)' }}>
{t('contactPage.sentText')}
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 min-[641px]:grid-cols-2">
<Field
icon={User}
label={t('contactPage.nameLabel')}
name="name"
required
value={form.name}
onChange={handleChange}
placeholder={t('contactPage.namePlaceholder')}
/>
<Field
icon={Mail}
label={t('contactPage.emailLabel')}
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
placeholder={t('contactPage.emailPlaceholder')}
/>
<Field
icon={Phone}
label={t('contactPage.phoneLabel')}
name="phone"
value={form.phone}
onChange={handleChange}
placeholder={t('contactPage.phonePlaceholder')}
/>
<Field
icon={Car}
label={t('contactPage.vinLabel')}
name="vin"
value={form.vin}
onChange={handleChange}
placeholder={t('contactPage.vinPlaceholder')}
/>
</div>
<div className="mt-6 flex flex-col gap-2">
<label
htmlFor="kf-message"
className="text-xs font-semibold uppercase tracking-[2px]"
style={{ color: 'var(--kf-label)' }}
>
{t('contactPage.messageLabel')}
</label>
<textarea
id="kf-message"
name="message"
rows={5}
value={form.message}
onChange={handleChange}
placeholder={t('contactPage.messagePlaceholder')}
className="w-full resize-y rounded-lg border px-4 py-3.5 text-[15px] outline-none"
style={{ background: 'var(--kf-field)', borderColor: 'var(--kf-fieldbrd)', color: 'var(--kf-text2)' }}
/>
</div>
{error && <p className="mt-4 text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="mt-8 flex flex-wrap items-center gap-5">
<button
type="submit"
disabled={status === 'sending'}
className="inline-flex items-center gap-3 rounded-lg px-8 py-4 text-sm font-bold uppercase tracking-[3px] transition-[filter,box-shadow] hover:brightness-110 disabled:opacity-60"
style={{
background: 'var(--kf-accent)',
color: 'var(--kf-btntext)',
boxShadow: '0 10px 24px rgba(150,200,20,0.32)',
}}
>
<Send className="h-[18px] w-[18px]" />
{status === 'sending' ? t('contactPage.sending') : t('contactPage.submit')}
</button>
<div className="text-[13px]" style={{ color: 'var(--kf-label)' }}>
{t('contactPage.requiredHint')}
</div>
</div>
</form>
</div>
</div>
{/* Rechte Spalte */}
<div className="flex flex-col gap-6">
<div
className="relative overflow-hidden rounded-2xl border px-[22px] py-[26px] sm:px-8 sm:py-[34px]"
style={{ background: 'var(--kf-card)', borderColor: 'var(--kf-cardbrd)', boxShadow: 'var(--kf-shadow)' }}
>
<div
className="pointer-events-none absolute -right-[60px] -top-[60px] h-[190px] w-[190px] rounded-full border border-dashed"
style={{ borderColor: 'var(--kf-pkgbrd)' }}
/>
<h2 className="mb-7 text-[17px]" style={{ ...michroma, color: 'var(--kf-text)' }}>
{t('contactPage.cardTitle')}
</h2>
<div className="flex flex-col gap-5">
<div className="flex items-start gap-4">
<div
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
style={{ background: 'var(--kf-accentsoft)' }}
>
<Phone className="h-[18px] w-[18px]" style={{ color: 'var(--kf-accent2)' }} />
</div>
<div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[3px]" style={{ color: 'var(--kf-accent2)' }}>
{t('contactPage.phone')}
</div>
<a href={`tel:${digitsOnly(phone)}`} className="text-base font-semibold" style={{ color: 'var(--kf-text2)' }}>
{phone}
</a>
</div>
</div>
<div className="flex items-start gap-4">
<div
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
style={{ background: 'var(--kf-accentsoft)' }}
>
<Mail className="h-[18px] w-[18px]" style={{ color: 'var(--kf-accent2)' }} />
</div>
<div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[3px]" style={{ color: 'var(--kf-accent2)' }}>
{t('contactPage.email')}
</div>
<a href={`mailto:${contactEmail}`} className="text-base font-semibold" style={{ color: 'var(--kf-text2)' }}>
{contactEmail}
</a>
</div>
</div>
<div className="flex items-start gap-4">
<div
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
style={{ background: 'var(--kf-accentsoft)' }}
>
<MapPin className="h-[18px] w-[18px]" style={{ color: 'var(--kf-accent2)' }} />
</div>
<div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[3px]" style={{ color: 'var(--kf-accent2)' }}>
{t('contactPage.address')}
</div>
<div className="text-base font-semibold leading-[1.4]" style={{ color: 'var(--kf-text2)' }}>
Boxbrunner Straße 20a<br />63916 Amorbach
</div>
</div>
</div>
{whatsapp && (
<div className="flex items-start gap-4">
<div
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
style={{ background: 'var(--kf-accentsoft)' }}
>
<WhatsAppIcon className="h-[18px] w-[18px]" style={{ color: 'var(--kf-accent2)' }} />
</div>
<div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[3px]" style={{ color: 'var(--kf-accent2)' }}>
{t('nav.whatsapp')}
</div>
<a href={waHref(whatsapp)} className="text-base font-semibold" style={{ color: 'var(--kf-text2)' }}>
{whatsapp}
</a>
</div>
</div>
)}
</div>
<div className="mt-7 border-t pt-6" style={{ borderColor: 'var(--kf-divider)' }}>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[3px]" style={{ color: 'var(--kf-accent2)' }}>
{t('contactPage.hours')}
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-[15px]">
<span style={{ color: 'var(--kf-label)' }}>{t('contactPage.hoursWeekDays')}</span>
<span>{t('contactPage.hoursWeekTime')}</span>
<span style={{ color: 'var(--kf-label)' }}>{t('contactPage.hoursSatDay')}</span>
<span>{t('contactPage.hoursSatTime')}</span>
</div>
</div>
</div>
<div
className="relative overflow-hidden rounded-2xl border"
style={{ background: 'var(--kf-card)', borderColor: 'var(--kf-cardbrd)', boxShadow: 'var(--kf-shadow)' }}
>
{consent.external && (
<a
href={DIRECTIONS_URL}
target="_blank"
rel="noopener noreferrer"
className="absolute left-3.5 top-3.5 z-10 inline-flex items-center gap-2 rounded-lg border px-3.5 py-2 text-[13px] font-semibold hover:border-[var(--kf-accent)]"
style={{ background: 'var(--kf-pill)', borderColor: 'var(--kf-pillbrd)', color: 'var(--kf-text2)', boxShadow: 'var(--kf-shadow2)' }}
>
{t('contactPage.mapOpenChip')}
<ExternalLink className="h-3.5 w-3.5" style={{ color: 'var(--kf-accent2)' }} />
</a>
)}
<ExternalEmbed name={t('contactPage.mapEmbedName')} className="h-[210px] w-full">
{/* iframe hoeher als der sichtbare 210px-Ausschnitt + negativer margin-top:
schneidet Googles Info-Overlay (oben links) komplett und die untere
Attributionszeile bis auf den Rand an - wie im Prototyp fuellt die
Karte so buendig bis zur "Route planen"-Leiste. */}
<div className="h-[210px] overflow-hidden">
<iframe
title="HifiPlanet Amorbach Standort"
src={`https://www.google.com/maps?q=${SHOP_ADDRESS_ENCODED}&output=embed`}
width="100%"
height="290"
style={{ border: 0, display: 'block', marginTop: '-60px' }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</ExternalEmbed>
<a
href={DIRECTIONS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2.5 border-t px-4 py-4 text-[13px] font-bold uppercase tracking-[3px] hover:bg-[var(--kf-accentsoft)]"
style={{ borderColor: 'var(--kf-divider)', color: 'var(--kf-accent2)' }}
>
{t('contactPage.routePlan')}
</a>
</div>
</div>
</div>
{/* Feature-Leiste */}
<div
className="mt-10 grid grid-cols-1 overflow-hidden rounded-2xl border min-[641px]:grid-cols-2 min-[901px]:grid-cols-4"
style={{ background: 'var(--kf-card)', borderColor: 'var(--kf-cardbrd)', boxShadow: 'var(--kf-shadow2)' }}
>
{features.map((f) => (
<div
key={f.key}
className="flex items-center gap-4 border-b p-6 last:border-b-0 min-[641px]:border-b-0 min-[641px]:border-r min-[641px]:last:border-r-0"
style={{ borderColor: 'var(--kf-divider)' }}
>
<div
className="flex h-[46px] w-[46px] flex-shrink-0 items-center justify-center rounded-full border-[1.5px]"
style={{ borderColor: 'var(--kf-pkgbrd)', color: 'var(--kf-accent2)' }}
>
<FeatureIcon path={FEATURE_ICON_PATHS[f.key]} className="h-[22px] w-[22px]" />
</div>
<div>
<div className="text-[15px] font-bold" style={{ color: 'var(--kf-text)' }}>{f.title}</div>
<div className="mt-0.5 text-[13px]" style={{ color: 'var(--kf-muted)' }}>{f.sub}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}