Add selectable package card color themes and refine the feature list styling

Bullet lists on package cards now use accent-colored checkmarks instead of
plain browser dots. The card color system is now driven by a persistent
site setting (Admin > Einstellungen > Website > "Paket-Kachel-Design")
with three curated themes - Graphite Green, Deep Blue Luxury, and Warm
Bronze - all converging on the same dark "Onyx + Gold" treatment for the
priciest tier, matching the reference mockups.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-08 10:28:05 +02:00
parent d7fdd36662
commit 411ee8a241
7 changed files with 122 additions and 25 deletions

View file

@ -25,6 +25,7 @@ const DEFAULT_SITE_SETTINGS = {
contact_email: 'info@hifi-planet-amorbach.de', contact_email: 'info@hifi-planet-amorbach.de',
hero_image_path: null, hero_image_path: null,
ga_measurement_id: null, ga_measurement_id: null,
package_card_theme: 'graphite',
}; };
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG // Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG

View file

@ -2,7 +2,20 @@ import { useEffect, useState } from 'react';
import { api } from '../../../api/client.js'; import { api } from '../../../api/client.js';
import ImageUploadField from '../../../components/ImageUploadField.jsx'; import ImageUploadField from '../../../components/ImageUploadField.jsx';
const emptyForm = { phone: '', whatsapp: '', contact_email: '', hero_image_path: '', ga_measurement_id: '' }; const emptyForm = {
phone: '',
whatsapp: '',
contact_email: '',
hero_image_path: '',
ga_measurement_id: '',
package_card_theme: 'graphite',
};
const PACKAGE_CARD_THEMES = [
{ value: 'graphite', label: 'Graphite Green', description: 'Graphit über Bronze/Silber/Gold/Platin bis zur Onyx-Krönung.' },
{ value: 'deep-blue', label: 'Deep Blue Luxury', description: 'Kühles Blau, das sich über Grün zu Gold erwärmt.' },
{ value: 'warm-bronze', label: 'Warm Bronze', description: 'Durchgehend warmes Kupfer/Bronze/Amber bis zum Gold.' },
];
export default function WebsiteSettings() { export default function WebsiteSettings() {
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
@ -28,6 +41,7 @@ export default function WebsiteSettings() {
contact_email: res.contact_email || '', contact_email: res.contact_email || '',
hero_image_path: res.hero_image_path || '', hero_image_path: res.hero_image_path || '',
ga_measurement_id: res.ga_measurement_id || '', ga_measurement_id: res.ga_measurement_id || '',
package_card_theme: res.package_card_theme || 'graphite',
}) })
) )
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -134,6 +148,37 @@ export default function WebsiteSettings() {
</div> </div>
</section> </section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Paket-Kachel-Design</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Bestimmt die Farbgebung der Paket-Stufen auf jeder Fahrzeug-Modell-Seite (von der günstigsten bis zur
teuersten Option).
</p>
<div className="grid gap-3 sm:grid-cols-3">
{PACKAGE_CARD_THEMES.map((theme) => (
<label
key={theme.value}
className={`cursor-pointer rounded-lg border p-3 text-sm transition ${
form.package_card_theme === theme.value
? 'border-brand-500 ring-2 ring-brand-500/40'
: 'border-neutral-300 hover:border-neutral-400 dark:border-neutral-700 dark:hover:border-neutral-600'
}`}
>
<input
type="radio"
name="package_card_theme"
value={theme.value}
checked={form.package_card_theme === theme.value}
onChange={(e) => update('package_card_theme', e.target.value)}
className="sr-only"
/>
<p className="font-semibold text-neutral-900 dark:text-white">{theme.label}</p>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{theme.description}</p>
</label>
))}
</div>
</section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"> <section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2> <h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google Analytics</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400"> <p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">

View file

@ -7,26 +7,46 @@ import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.js
import DynamicIcon from '../../components/DynamicIcon.jsx'; import DynamicIcon from '../../components/DynamicIcon.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx'; import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx'; import { useLanguage } from '../../context/LanguageContext.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene // Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen - Graphit (Einstieg) -> Bronze -> // "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen. Die Paket-Sektion sitzt bewusst IMMER
// Silber -> Gold -> Platin -> Onyx (fast schwarz, mit Gold-Glanz als Kroenung ganz oben). // auf einer dunklen Buehne (unabhaengig vom Hell/Dunkel-Modus der Seite, wie ein
// Die Paket-Sektion sitzt bewusst IMMER auf einer dunklen Buehne (unabhaengig vom // Fahrzeugkonfigurator) - das ist der groesste Hebel fuer den edlen/luxurioesen Eindruck,
// Hell/Dunkel-Modus der Seite, wie ein Fahrzeugkonfigurator) - das ist der groesste Hebel // den flache helle Kacheln nicht liefern konnten. Jede Stufe hat eine Flaechenfarbe (bg)
// fuer den edlen/luxurioesen Eindruck, den flache helle Kacheln nicht liefern konnten. // und eine dazu passende Akzentfarbe (Rahmen/Leucht-Schatten/Preis/Icon) - nur der
// Jede Stufe hat eine Flaechenfarbe (bg) und eine dazu passende Akzentfarbe (Rahmen/ // "Kontakt anfragen"-Button bleibt ueberall einheitlich markengruen, damit die Kernaktion
// Leucht-Schatten/Preis/Icon) - nur der "Kontakt anfragen"-Button bleibt ueberall // auf jeder Karte wiedererkennbar bleibt. Zwischen zwei Nachbar-Materialien wird in RGB
// einheitlich markengruen, damit die Kernaktion auf jeder Karte wiedererkennbar bleibt. // linear interpoliert (siehe materialRgbAt), sodass JEDES Paket (nicht nur die Materialien
// Zwischen zwei Nachbar-Materialien wird in RGB linear interpoliert (siehe materialRgbAt), // selbst) eine eigene Abstufung bekommt. Welches Farbschema verwendet wird, waehlt der
// sodass JEDES Paket (nicht nur die Materialien selbst) eine eigene Abstufung bekommt. // Kunde selbst unter Admin -> Einstellungen -> Website ("Paket-Kachel-Design") - alle drei
const MATERIALS = [ // laufen bewusst auf dieselbe Onyx+Gold-Kroenung bei der teuersten Stufe zu.
{ bg: [0, 0, 14], accent: [88, 45, 55] }, // Graphit (Einstieg) const PACKAGE_THEMES = {
{ bg: [24, 32, 22], accent: [24, 55, 58] }, // Bronze graphite: [
{ bg: [212, 10, 24], accent: [212, 18, 68] }, // Silber { bg: [0, 0, 14], accent: [88, 45, 55] }, // Graphit (Einstieg)
{ bg: [45, 38, 24], accent: [42, 68, 58] }, // Gold { bg: [24, 32, 22], accent: [24, 55, 58] }, // Bronze
{ bg: [196, 12, 27], accent: [200, 20, 72] }, // Platin { bg: [212, 10, 24], accent: [212, 18, 68] }, // Silber
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx { bg: [45, 38, 24], accent: [42, 68, 58] }, // Gold
]; { bg: [196, 12, 27], accent: [200, 20, 72] }, // Platin
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
],
'deep-blue': [
{ bg: [212, 30, 16], accent: [205, 45, 62] }, // Eisblau (Einstieg)
{ bg: [212, 35, 20], accent: [205, 50, 64] }, // Tiefblau
{ bg: [190, 30, 22], accent: [190, 45, 62] }, // Petrol
{ bg: [160, 30, 20], accent: [150, 45, 58] }, // Seegruen
{ bg: [95, 30, 20], accent: [95, 50, 58] }, // Markengruen
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
],
'warm-bronze': [
{ bg: [28, 22, 16], accent: [32, 40, 55] }, // Warmes Graphit (Einstieg)
{ bg: [26, 38, 22], accent: [28, 58, 55] }, // Bronze
{ bg: [32, 42, 24], accent: [34, 62, 56] }, // Kupfer
{ bg: [38, 45, 24], accent: [38, 66, 58] }, // Amber
{ bg: [42, 48, 24], accent: [42, 70, 60] }, // Reiches Gold
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
],
};
const hslToRgb = (h, s, l) => { const hslToRgb = (h, s, l) => {
s /= 100; s /= 100;
@ -67,6 +87,8 @@ export default function ModelPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const maintenance = useMaintenance(); const maintenance = useMaintenance();
const { t, language } = useLanguage(); const { t, language } = useLanguage();
const { package_card_theme: packageCardTheme } = useSiteSettings();
const MATERIALS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
const formatPrice = (value) => const formatPrice = (value) =>
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value); new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
@ -257,17 +279,27 @@ export default function ModelPage() {
</p> </p>
</div> </div>
<ul style={{ color: mutedColor }} className="mb-6 flex-1 list-disc space-y-1 pl-5 text-sm"> <ul className="mb-6 flex-1 space-y-2.5 text-sm">
{pkg.products.map((product) => ( {pkg.products.map((product) => (
<li key={product.id}> <li key={product.id} className="flex items-start gap-2.5">
{product.name_override || product.scraped_name || t('modelPage.productLoading')} <DynamicIcon name="check" className="mt-0.5 h-4 w-4 shrink-0" style={{ color: accentColor }} />
<span style={{ color: mutedColor }} className="leading-snug">
{product.name_override || product.scraped_name || t('modelPage.productLoading')}
</span>
</li> </li>
))} ))}
{pkg.description {pkg.description
?.split('\n') ?.split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.map((line, i) => <li key={`desc-${i}`}>{line}</li>)} .map((line, i) => (
<li key={`desc-${i}`} className="flex items-start gap-2.5">
<DynamicIcon name="check" className="mt-0.5 h-4 w-4 shrink-0" style={{ color: accentColor }} />
<span style={{ color: mutedColor }} className="leading-snug">
{line}
</span>
</li>
))}
</ul> </ul>
<Link <Link

View file

@ -0,0 +1,7 @@
-- Auswaehlbares Farbschema fuer die Paket-Kacheln (Graphite Green / Deep Blue / Warm
-- Bronze). Additiv, nichts wird geloescht.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
ALTER TABLE app_settings
ADD COLUMN IF NOT EXISTS package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite';

View file

@ -26,6 +26,7 @@ CREATE TABLE app_settings (
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL, ga_measurement_id VARCHAR(20) NULL,
ga_property_id VARCHAR(30) NULL, ga_property_id VARCHAR(30) NULL,
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
mail_host VARCHAR(255) NULL, mail_host VARCHAR(255) NULL,
mail_port INT NULL, mail_port INT NULL,
mail_username VARCHAR(255) NULL, mail_username VARCHAR(255) NULL,

View file

@ -15,13 +15,14 @@ class SiteSettingsController
'contact_email' => 'info@hifi-planet-amorbach.de', 'contact_email' => 'info@hifi-planet-amorbach.de',
'hero_image_path' => null, 'hero_image_path' => null,
'ga_measurement_id' => null, 'ga_measurement_id' => null,
'package_card_theme' => 'graphite',
]; ];
public static function show(): void public static function show(): void
{ {
try { try {
$stmt = Database::connection()->query( $stmt = Database::connection()->query(
'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id FROM app_settings WHERE id = 1' 'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id, package_card_theme FROM app_settings WHERE id = 1'
); );
$row = $stmt->fetch(); $row = $stmt->fetch();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -37,14 +38,21 @@ class SiteSettingsController
Http::send($result); Http::send($result);
} }
private const PACKAGE_CARD_THEMES = ['graphite', 'deep-blue', 'warm-bronze'];
public static function update(): void public static function update(): void
{ {
$body = Http::jsonBody(); $body = Http::jsonBody();
$db = Database::connection(); $db = Database::connection();
$packageCardTheme = trim($body['package_card_theme'] ?? '') ?: 'graphite';
if (!in_array($packageCardTheme, self::PACKAGE_CARD_THEMES, true)) {
$packageCardTheme = 'graphite';
}
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)'); $db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
$stmt = $db->prepare( $stmt = $db->prepare(
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ? WHERE id = 1' 'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ?, package_card_theme = ? WHERE id = 1'
); );
$stmt->execute([ $stmt->execute([
trim($body['phone'] ?? '') ?: null, trim($body['phone'] ?? '') ?: null,
@ -52,6 +60,7 @@ class SiteSettingsController
trim($body['contact_email'] ?? '') ?: null, trim($body['contact_email'] ?? '') ?: null,
trim($body['hero_image_path'] ?? '') ?: null, trim($body['hero_image_path'] ?? '') ?: null,
trim($body['ga_measurement_id'] ?? '') ?: null, trim($body['ga_measurement_id'] ?? '') ?: null,
$packageCardTheme,
]); ]);
self::show(); self::show();

View file

@ -37,6 +37,7 @@ class Schema
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL, ga_measurement_id VARCHAR(20) NULL,
ga_property_id VARCHAR(30) NULL, ga_property_id VARCHAR(30) NULL,
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
mail_host VARCHAR(255) NULL, mail_host VARCHAR(255) NULL,
mail_port INT NULL, mail_port INT NULL,
mail_username VARCHAR(255) NULL, mail_username VARCHAR(255) NULL,
@ -260,6 +261,7 @@ class Schema
'hero_image_path' => 'VARCHAR(255) NULL', 'hero_image_path' => 'VARCHAR(255) NULL',
'ga_measurement_id' => 'VARCHAR(20) NULL', 'ga_measurement_id' => 'VARCHAR(20) NULL',
'ga_property_id' => 'VARCHAR(30) NULL', 'ga_property_id' => 'VARCHAR(30) NULL',
'package_card_theme' => "VARCHAR(30) NOT NULL DEFAULT 'graphite'",
'mail_host' => 'VARCHAR(255) NULL', 'mail_host' => 'VARCHAR(255) NULL',
'mail_port' => 'INT NULL', 'mail_port' => 'INT NULL',
'mail_username' => 'VARCHAR(255) NULL', 'mail_username' => 'VARCHAR(255) NULL',