From 9e417d0f04ee852cff34a3c6853bbad603781994 Mon Sep 17 00:00:00 2001 From: Maaxxs <61059039+MaaxxsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:13:55 +0200 Subject: [PATCH] Make package cards configurable and give them a proper tiered design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packages now support an optional icon, a short tagline, and an "empfohlen" flag, all editable in Admin > Pakete. The model page switches from a plain stacked list to a responsive grid where the featured package gets a highlighted border, badge, and bolder CTA — fixes the tiers looking inconsistent when only some packages had bullet content. Co-Authored-By: Claude Sonnet 5 --- hifi-src/src/i18n/de.js | 1 + hifi-src/src/i18n/en.js | 1 + hifi-src/src/pages/admin/Packages.jsx | 51 ++++++++++++++++++- hifi-src/src/pages/public/ModelPage.jsx | 40 +++++++++++---- .../api/database/migration_package_design.sql | 9 ++++ hifi/api/database/schema.sql | 3 ++ hifi/api/src/Controllers/ModelController.php | 3 +- .../api/src/Controllers/PackageController.php | 23 ++++++--- .../src/Controllers/SettingsController.php | 2 +- hifi/api/src/Support/Schema.php | 6 +++ 10 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 hifi/api/database/migration_package_design.sql diff --git a/hifi-src/src/i18n/de.js b/hifi-src/src/i18n/de.js index bb8600f..a8a3ef1 100644 --- a/hifi-src/src/i18n/de.js +++ b/hifi-src/src/i18n/de.js @@ -154,6 +154,7 @@ export default { totalPrice: 'Gesamtpreis (ca.)', productLoading: 'Produkt wird geladen…', requestContact: 'Kontakt anfragen', + featuredBadge: 'Empfohlen', }, leistungen: { metaTitle: 'Leistungen', diff --git a/hifi-src/src/i18n/en.js b/hifi-src/src/i18n/en.js index e84a8c7..965dc18 100644 --- a/hifi-src/src/i18n/en.js +++ b/hifi-src/src/i18n/en.js @@ -154,6 +154,7 @@ export default { totalPrice: 'Total price (approx.)', productLoading: 'Loading product…', requestContact: 'Request contact', + featuredBadge: 'Recommended', }, leistungen: { metaTitle: 'Services', diff --git a/hifi-src/src/pages/admin/Packages.jsx b/hifi-src/src/pages/admin/Packages.jsx index 79cac6d..5b684bf 100644 --- a/hifi-src/src/pages/admin/Packages.jsx +++ b/hifi-src/src/pages/admin/Packages.jsx @@ -1,8 +1,20 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../../api/client.js'; +import IconPicker from '../../components/IconPicker.jsx'; +import DynamicIcon from '../../components/DynamicIcon.jsx'; -const emptyForm = { name: '', car_model_id: '', description: '', markup_type: 'none', markup_value: 0, sort_order: 0 }; +const emptyForm = { + name: '', + car_model_id: '', + description: '', + markup_type: 'none', + markup_value: 0, + icon_name: '', + tagline: '', + is_featured: false, + sort_order: 0, +}; export default function Packages() { const [packages, setPackages] = useState([]); @@ -62,6 +74,9 @@ export default function Packages() { description: pkg.description || '', markup_type: pkg.markup_type || 'none', markup_value: pkg.markup_value || 0, + icon_name: pkg.icon_name || '', + tagline: pkg.tagline || '', + is_featured: !!pkg.is_featured, sort_order: pkg.sort_order, }); }; @@ -139,7 +154,17 @@ export default function Packages() { {filteredPackages.map((pkg) => ( {pkg.brand_name} {pkg.model_name} - {pkg.name} + + + {pkg.icon_name && } + {pkg.name} + {pkg.is_featured && ( + + Empfohlen + + )} + + {pkg.markup_type === 'fixed' && `+${pkg.markup_value} €`} {pkg.markup_type === 'percent' && `+${pkg.markup_value} %`} @@ -196,6 +221,28 @@ export default function Packages() { className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" /> +
+ + setForm({ ...form, icon_name: name })} /> +
+
+ + setForm({ ...form, tagline: e.target.value })} + placeholder="z. B. Perfekt für den Einstieg" + className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" + /> +
+
diff --git a/hifi-src/src/pages/public/ModelPage.jsx b/hifi-src/src/pages/public/ModelPage.jsx index 63eab28..eb8a72e 100644 --- a/hifi-src/src/pages/public/ModelPage.jsx +++ b/hifi-src/src/pages/public/ModelPage.jsx @@ -4,6 +4,7 @@ import { api } from '../../api/client.js'; import usePageMeta from '../../hooks/usePageMeta.js'; import MaintenanceNotice from '../../components/MaintenanceNotice.jsx'; import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx'; +import DynamicIcon from '../../components/DynamicIcon.jsx'; import { useMaintenance } from '../../context/MaintenanceContext.jsx'; import { useLanguage } from '../../context/LanguageContext.jsx'; @@ -70,18 +71,37 @@ export default function ModelPage() {

{t('modelPage.empty')}

)} -
+
{packages.map((pkg) => ( -
-
-

{pkg.name}

-
-

{t('modelPage.totalPrice')}

-

{formatPrice(pkg.total_price)}

+
+ {pkg.is_featured && ( + + {t('modelPage.featuredBadge')} + + )} + + {pkg.icon_name && ( +
+
+ )} + +

{pkg.name}

+ {pkg.tagline &&

{pkg.tagline}

} + +
+

{t('modelPage.totalPrice')}

+

{formatPrice(pkg.total_price)}

-
    +
      {pkg.products.map((product) => (
    • {product.name_override || product.scraped_name || t('modelPage.productLoading')} @@ -96,7 +116,9 @@ export default function ModelPage() { {t('modelPage.requestContact')} diff --git a/hifi/api/database/migration_package_design.sql b/hifi/api/database/migration_package_design.sql new file mode 100644 index 0000000..54b2e40 --- /dev/null +++ b/hifi/api/database/migration_package_design.sql @@ -0,0 +1,9 @@ +-- Design-Felder pro Paket (Icon, Slogan, "Empfohlen"-Hervorhebung) fuer die +-- Pakete-Karten auf der Modell-Seite. Additiv, nichts wird geloescht. +-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren" +-- unter Admin-Panel -> Einstellungen -> Datenbank. + +ALTER TABLE packages + ADD COLUMN IF NOT EXISTS icon_name VARCHAR(100) NULL, + ADD COLUMN IF NOT EXISTS tagline VARCHAR(150) NULL, + ADD COLUMN IF NOT EXISTS is_featured TINYINT(1) NOT NULL DEFAULT 0; diff --git a/hifi/api/database/schema.sql b/hifi/api/database/schema.sql index 2586a17..e3ae055 100644 --- a/hifi/api/database/schema.sql +++ b/hifi/api/database/schema.sql @@ -147,6 +147,9 @@ CREATE TABLE packages ( description TEXT NULL, markup_type ENUM('none','fixed','percent') NOT NULL DEFAULT 'none', markup_value DECIMAL(10,2) NOT NULL DEFAULT 0, + icon_name VARCHAR(100) NULL, + tagline VARCHAR(150) NULL, + is_featured TINYINT(1) NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/hifi/api/src/Controllers/ModelController.php b/hifi/api/src/Controllers/ModelController.php index 40af475..26569ab 100644 --- a/hifi/api/src/Controllers/ModelController.php +++ b/hifi/api/src/Controllers/ModelController.php @@ -90,7 +90,7 @@ class ModelController } $pkgStmt = $db->prepare( - 'SELECT id, name, slug, description, markup_type, markup_value, sort_order + 'SELECT id, name, slug, description, markup_type, markup_value, icon_name, tagline, is_featured, sort_order FROM packages WHERE car_model_id = ? ORDER BY sort_order, name' ); $pkgStmt->execute([$model['id']]); @@ -125,6 +125,7 @@ class ModelController unset($package['markup_type'], $package['markup_value']); $package['products'] = $products; $package['total_price'] = round($total, 2); + $package['is_featured'] = (bool) $package['is_featured']; } unset($package); } diff --git a/hifi/api/src/Controllers/PackageController.php b/hifi/api/src/Controllers/PackageController.php index 9742f06..19d08b6 100644 --- a/hifi/api/src/Controllers/PackageController.php +++ b/hifi/api/src/Controllers/PackageController.php @@ -22,14 +22,19 @@ class PackageController public static function index(): void { $stmt = Database::connection()->query( - 'SELECT p.id, p.name, p.slug, p.description, p.markup_type, p.markup_value, p.sort_order, p.car_model_id, - m.name AS model_name, b.name AS brand_name + 'SELECT p.id, p.name, p.slug, p.description, p.markup_type, p.markup_value, p.icon_name, p.tagline, + p.is_featured, p.sort_order, p.car_model_id, m.name AS model_name, b.name AS brand_name FROM packages p JOIN car_models m ON m.id = p.car_model_id JOIN brands b ON b.id = m.brand_id ORDER BY b.name, m.name, p.sort_order, p.name' ); - Http::send($stmt->fetchAll()); + $packages = $stmt->fetchAll(); + foreach ($packages as &$package) { + $package['is_featured'] = (bool) $package['is_featured']; + } + unset($package); + Http::send($packages); } public static function store(): void @@ -46,8 +51,8 @@ class PackageController $db = Database::connection(); $stmt = $db->prepare( - 'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, icon_name, tagline, is_featured, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $modelId, @@ -56,6 +61,9 @@ class PackageController $body['description'] ?? null, $markupType, $markupValue, + trim($body['icon_name'] ?? '') ?: null, + trim($body['tagline'] ?? '') ?: null, + !empty($body['is_featured']) ? 1 : 0, (int) ($body['sort_order'] ?? 0), ]); @@ -76,7 +84,7 @@ class PackageController [$markupType, $markupValue] = self::normalizeMarkup($body); $stmt = Database::connection()->prepare( - 'UPDATE packages SET car_model_id = ?, name = ?, slug = ?, description = ?, markup_type = ?, markup_value = ?, sort_order = ? WHERE id = ?' + 'UPDATE packages SET car_model_id = ?, name = ?, slug = ?, description = ?, markup_type = ?, markup_value = ?, icon_name = ?, tagline = ?, is_featured = ?, sort_order = ? WHERE id = ?' ); $stmt->execute([ $modelId, @@ -85,6 +93,9 @@ class PackageController $body['description'] ?? null, $markupType, $markupValue, + trim($body['icon_name'] ?? '') ?: null, + trim($body['tagline'] ?? '') ?: null, + !empty($body['is_featured']) ? 1 : 0, (int) ($body['sort_order'] ?? 0), $id, ]); diff --git a/hifi/api/src/Controllers/SettingsController.php b/hifi/api/src/Controllers/SettingsController.php index 66c5fa6..f86bbf7 100644 --- a/hifi/api/src/Controllers/SettingsController.php +++ b/hifi/api/src/Controllers/SettingsController.php @@ -16,7 +16,7 @@ class SettingsController private const TABLE_COLUMNS = [ 'brands' => ['id', 'name', 'slug', 'sort_order', 'created_at', 'updated_at'], 'car_models' => ['id', 'brand_id', 'name', 'slug', 'sort_order', 'created_at', 'updated_at'], - 'packages' => ['id', 'car_model_id', 'name', 'slug', 'description', 'markup_type', 'markup_value', 'sort_order', 'created_at', 'updated_at'], + 'packages' => ['id', 'car_model_id', 'name', 'slug', 'description', 'markup_type', 'markup_value', 'icon_name', 'tagline', 'is_featured', 'sort_order', 'created_at', 'updated_at'], 'package_products' => ['id', 'package_id', 'source_url', 'name_override', 'scraped_name', 'scraped_price', 'scraped_currency', 'scraped_image_url', 'price_updated_at', 'scrape_status', 'scrape_error', 'sort_order', 'created_at', 'updated_at'], 'package_upgrades' => ['id', 'package_id', 'name', 'description', 'price', 'sort_order', 'created_at', 'updated_at'], 'services' => ['id', 'icon_name', 'title', 'description', 'image_path', 'cta_label', 'cta_url', 'sort_order', 'created_at', 'updated_at'], diff --git a/hifi/api/src/Support/Schema.php b/hifi/api/src/Support/Schema.php index 0dc3d84..ef8afed 100644 --- a/hifi/api/src/Support/Schema.php +++ b/hifi/api/src/Support/Schema.php @@ -158,6 +158,9 @@ class Schema description TEXT NULL, markup_type ENUM('none','fixed','percent') NOT NULL DEFAULT 'none', markup_value DECIMAL(10,2) NOT NULL DEFAULT 0, + icon_name VARCHAR(100) NULL, + tagline VARCHAR(150) NULL, + is_featured TINYINT(1) NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -355,6 +358,9 @@ class Schema 'description' => 'TEXT NULL', 'markup_type' => "ENUM('none','fixed','percent') NOT NULL DEFAULT 'none'", 'markup_value' => 'DECIMAL(10,2) NOT NULL DEFAULT 0', + 'icon_name' => 'VARCHAR(100) NULL', + 'tagline' => 'VARCHAR(150) NULL', + 'is_featured' => 'TINYINT(1) NOT NULL DEFAULT 0', 'sort_order' => 'INT NOT NULL DEFAULT 0', 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',