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:
parent
d7fdd36662
commit
411ee8a241
7 changed files with 122 additions and 25 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
const PACKAGE_THEMES = {
|
||||||
|
graphite: [
|
||||||
{ bg: [0, 0, 14], accent: [88, 45, 55] }, // Graphit (Einstieg)
|
{ bg: [0, 0, 14], accent: [88, 45, 55] }, // Graphit (Einstieg)
|
||||||
{ bg: [24, 32, 22], accent: [24, 55, 58] }, // Bronze
|
{ bg: [24, 32, 22], accent: [24, 55, 58] }, // Bronze
|
||||||
{ bg: [212, 10, 24], accent: [212, 18, 68] }, // Silber
|
{ bg: [212, 10, 24], accent: [212, 18, 68] }, // Silber
|
||||||
{ bg: [45, 38, 24], accent: [42, 68, 58] }, // Gold
|
{ bg: [45, 38, 24], accent: [42, 68, 58] }, // Gold
|
||||||
{ bg: [196, 12, 27], accent: [200, 20, 72] }, // Platin
|
{ bg: [196, 12, 27], accent: [200, 20, 72] }, // Platin
|
||||||
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
|
{ 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">
|
||||||
|
<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')}
|
{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
|
||||||
|
|
|
||||||
7
hifi/api/database/migration_package_card_theme.sql
Normal file
7
hifi/api/database/migration_package_card_theme.sql
Normal 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';
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue