Implement client feedback: calmer cursor, price override text, 3D coverflow layout

Cursor: the default state is now just the green dot - the orbital ring and
satellite only appear over interactive elements, where the core switches
to white with a dark rim so it contrasts with the brand-green buttons
instead of vanishing green-on-green. The click pulse is white for the
same reason.

Packages: new optional price_text field (schema column, migration,
controllers, export/import columns, admin form). While set, it replaces
the "ab ca." label and computed price on the tile - e.g. "Coming soon".

Layouts: third package_card_layout option "coverflow" - a scroll-snap row
with CSS 3D perspective where the centered card faces front and its
neighbors rotate away, transforms driven per scroll frame (rAF-throttled,
ResizeObserver covers card expansion). Selectable in the admin website
settings next to grid and strip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-12 15:13:08 +02:00
parent fe751dce5f
commit 873b9c2b50
11 changed files with 117 additions and 34 deletions

View file

@ -84,25 +84,28 @@ html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
opacity 0.15s ease; opacity 0.15s ease;
} }
/* Der Orbit (Bahn + Satellit) ist im Normalzustand UNSICHTBAR - Kundenwunsch:
Standard ist nur der gruene Punkt, das Orbital-Motiv erscheint erst ueber
bedienbaren Flaechen. */
.hifi-cursor__orbit { .hifi-cursor__orbit {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
transition: opacity 0.15s ease; opacity: 0;
transition: opacity 0.18s ease;
} }
/* Schraege Ellipsenbahn wie der Orbit-Schwung im Logo. */ /* Schraege Ellipsenbahn wie der Orbit-Schwung im Logo - auf Hover in Weiss,
damit sie sich von den markengruenen Buttons abhebt. */
.hifi-cursor__ring { .hifi-cursor__ring {
position: absolute; position: absolute;
width: 38px; width: 38px;
height: 15px; height: 15px;
margin: -7.5px 0 0 -19px; margin: -7.5px 0 0 -19px;
border: 1px solid rgba(127, 212, 74, 0.5); border: 1px solid rgba(255, 255, 255, 0.85);
border-radius: 50%; border-radius: 50%;
transform: rotate(-24deg); transform: rotate(-24deg) scale(0.6);
transition: transition: transform 0.22s ease;
transform 0.25s ease,
border-color 0.25s ease;
} }
/* Satellit: Position auf der Bahn wird per JS gesetzt (rotierender Punkt). */ /* Satellit: Position auf der Bahn wird per JS gesetzt (rotierender Punkt). */
@ -112,21 +115,28 @@ html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
height: 5px; height: 5px;
margin: -2.5px 0 0 -2.5px; margin: -2.5px 0 0 -2.5px;
border-radius: 9999px; border-radius: 9999px;
background: #eaffdb; background: #ffffff;
box-shadow: 0 0 6px rgba(139, 234, 60, 0.9); box-shadow: 0 0 6px rgba(255, 255, 255, 0.9);
} }
/* Hover ueber Interaktivem: Kern waechst, Bahn weitet sich und leuchtet auf /* Hover ueber Interaktivem: Orbit blendet ein und weitet sich, Kern wechselt auf
(Satellit beschleunigt via JS). */ WEISS (Kontrast zu den gruenen Schaltflaechen - Kundenwunsch), Satellit
beschleunigt via JS. Dunkler Aussenrand haelt den weissen Kern auch auf hellen
Flaechen sichtbar. */
.hifi-cursor[data-state='hover'] .hifi-cursor__orbit {
opacity: 1;
}
.hifi-cursor[data-state='hover'] .hifi-cursor__core { .hifi-cursor[data-state='hover'] .hifi-cursor__core {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin: -8px 0 0 -8px; margin: -8px 0 0 -8px;
background: #8bea3c; background: #ffffff;
box-shadow:
0 0 0 1.5px rgba(0, 0, 0, 0.35),
0 0 12px rgba(255, 255, 255, 0.85);
} }
.hifi-cursor[data-state='hover'] .hifi-cursor__ring { .hifi-cursor[data-state='hover'] .hifi-cursor__ring {
transform: rotate(-24deg) scale(1.45); transform: rotate(-24deg) scale(1.45);
border-color: rgba(139, 234, 60, 0.9);
} }
/* Ueber Textfeldern: komplett ausblenden, nativer Text-Cursor uebernimmt. */ /* Ueber Textfeldern: komplett ausblenden, nativer Text-Cursor uebernimmt. */
@ -151,7 +161,7 @@ html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
height: 12px; height: 12px;
margin: -6px 0 0 -6px; margin: -6px 0 0 -6px;
border-radius: 9999px; border-radius: 9999px;
border: 1.5px solid rgba(139, 234, 60, 0.9); border: 1.5px solid rgba(255, 255, 255, 0.9);
opacity: 0; opacity: 0;
} }
.hifi-cursor__pulse.is-live { .hifi-cursor__pulse.is-live {

View file

@ -12,6 +12,7 @@ const emptyForm = {
markup_value: 0, markup_value: 0,
icon_name: '', icon_name: '',
tagline: '', tagline: '',
price_text: '',
is_featured: false, is_featured: false,
sort_order: 0, sort_order: 0,
}; };
@ -76,6 +77,7 @@ export default function Packages() {
markup_value: pkg.markup_value || 0, markup_value: pkg.markup_value || 0,
icon_name: pkg.icon_name || '', icon_name: pkg.icon_name || '',
tagline: pkg.tagline || '', tagline: pkg.tagline || '',
price_text: pkg.price_text || '',
is_featured: !!pkg.is_featured, is_featured: !!pkg.is_featured,
sort_order: pkg.sort_order, sort_order: pkg.sort_order,
}); });
@ -234,6 +236,16 @@ 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" 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"
/> />
</div> </div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Preis-Text (optional)</label>
<input
value={form.price_text}
onChange={(e) => setForm({ ...form, price_text: e.target.value })}
placeholder='z. B. "Coming soon" oder "Preis auf Anfrage"'
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"
/>
<p className="mt-1 text-xs text-neutral-400">Ersetzt auf der Kachel den berechneten Preis, solange ausgefüllt.</p>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300"> <label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input <input
type="checkbox" type="checkbox"

View file

@ -21,6 +21,7 @@ const PACKAGE_CARD_THEMES = [
const PACKAGE_CARD_LAYOUTS = [ const PACKAGE_CARD_LAYOUTS = [
{ value: 'grid', label: 'Raster', description: 'Breite Kacheln, die responsiv in mehrere Zeilen umbrechen.' }, { value: 'grid', label: 'Raster', description: 'Breite Kacheln, die responsiv in mehrere Zeilen umbrechen.' },
{ value: 'strip', label: 'Scroll-Band', description: 'Schmale, hochkant Kacheln in einer horizontal scrollbaren Reihe.' }, { value: 'strip', label: 'Scroll-Band', description: 'Schmale, hochkant Kacheln in einer horizontal scrollbaren Reihe.' },
{ value: 'coverflow', label: '3D-Coverflow', description: 'Kacheln drehen sich beim Wischen räumlich die mittlere steht im Fokus.' },
]; ];
export default function WebsiteSettings() { export default function WebsiteSettings() {
@ -191,7 +192,7 @@ export default function WebsiteSettings() {
<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">
Bestimmt, wie die Paket-Stufen auf jeder Fahrzeug-Modell-Seite angeordnet werden. Bestimmt, wie die Paket-Stufen auf jeder Fahrzeug-Modell-Seite angeordnet werden.
</p> </p>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-3">
{PACKAGE_CARD_LAYOUTS.map((layout) => ( {PACKAGE_CARD_LAYOUTS.map((layout) => (
<label <label
key={layout.value} key={layout.value}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js'; import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js'; import usePageMeta from '../../hooks/usePageMeta.js';
@ -91,7 +91,7 @@ const BULLET_COLOR = '#c4c4c4';
// Wertigkeitssignal wirkt. // Wertigkeitssignal wirkt.
const VISIBLE_BULLETS = 5; const VISIBLE_BULLETS = 5;
function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, contactUrl, t }) { function PackageCard({ pkg, tier, tierT, layout, bullets, formatPrice, contactUrl, t }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hiddenCount = bullets.length - VISIBLE_BULLETS; const hiddenCount = bullets.length - VISIBLE_BULLETS;
const shownBullets = expanded ? bullets : bullets.slice(0, VISIBLE_BULLETS); const shownBullets = expanded ? bullets : bullets.slice(0, VISIBLE_BULLETS);
@ -120,9 +120,13 @@ function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, co
background: `linear-gradient(180deg, ${tier.bgTop} 0%, #0b0b0b 55%, #080808 100%)`, background: `linear-gradient(180deg, ${tier.bgTop} 0%, #0b0b0b 55%, #080808 100%)`,
}} }}
className={ className={
isStripLayout layout === 'strip'
? 'relative flex min-h-[640px] grow shrink basis-[190px] min-w-[190px] max-w-[240px] snap-start flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:basis-[210px]' ? 'relative flex min-h-[640px] grow shrink basis-[190px] min-w-[190px] max-w-[240px] snap-start flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:basis-[210px]'
: 'relative flex min-h-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40' : layout === 'coverflow'
? // Feste Breite + snap-center: die 3D-Transformation (rotateY/scale/translateZ)
// setzt der Scroll-Handler in ModelPage direkt per style.transform.
'relative flex min-h-[640px] w-[230px] shrink-0 snap-center flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 will-change-transform dark:shadow-black/40'
: 'relative flex min-h-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40'
} }
> >
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */} {/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
@ -226,7 +230,8 @@ function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, co
}} }}
> >
<div className="text-center"> <div className="text-center">
<div style={{ fontSize: '13px', color: LABEL_COLOR }}>{t('modelPage.totalPrice')}</div> {/* Optionaler Admin-Freitext (z. B. "Coming soon") ersetzt "ab ca." + Preis. */}
{!pkg.price_text && <div style={{ fontSize: '13px', color: LABEL_COLOR }}>{t('modelPage.totalPrice')}</div>}
<div <div
style={{ style={{
marginTop: '4px', marginTop: '4px',
@ -237,7 +242,7 @@ function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, co
color: PRICE_COLOR, color: PRICE_COLOR,
}} }}
> >
{formatPrice(pkg.total_price)} {pkg.price_text || formatPrice(pkg.total_price)}
</div> </div>
<Link <Link
to={contactUrl(pkg)} to={contactUrl(pkg)}
@ -259,7 +264,8 @@ export default function ModelPage() {
const { t, language } = useLanguage(); const { t, language } = useLanguage();
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings(); const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite; const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
const isStripLayout = packageCardLayout !== 'grid'; const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
const coverflowRef = useRef(null);
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);
@ -271,6 +277,39 @@ export default function ModelPage() {
.catch((e) => setError(e.message)); .catch((e) => setError(e.message));
}, [brandSlug, modelSlug, maintenance.vehicles.enabled, maintenance.bypass]); }, [brandSlug, modelSlug, maintenance.vehicles.enabled, maintenance.bypass]);
// 3D-Coverflow: pro Scroll-Frame bekommt jede Karte abhaengig vom Abstand ihrer Mitte
// zur Containermitte eine Drehung/Skalierung/Tiefe - die mittlere Karte steht frontal,
// die Nachbarn drehen raeumlich weg. rAF-gedrosselt; der ResizeObserver deckt auch
// Hoehenaenderungen ab (z. B. wenn eine Karte ihre Stichpunkte aufklappt).
useEffect(() => {
const el = coverflowRef.current;
if (layout !== 'coverflow' || !el) return undefined;
let rafId = 0;
const apply = () => {
rafId = 0;
const mid = el.scrollLeft + el.clientWidth / 2;
for (const card of el.children) {
const center = card.offsetLeft + card.offsetWidth / 2;
const d = Math.max(-1, Math.min(1, (center - mid) / (card.offsetWidth * 1.4)));
const abs = Math.abs(d);
card.style.transform = `rotateY(${(-d * 35).toFixed(2)}deg) scale(${(1 - abs * 0.13).toFixed(3)}) translateZ(${(-abs * 60).toFixed(1)}px)`;
card.style.zIndex = String(100 - Math.round(abs * 50));
}
};
const schedule = () => {
if (!rafId) rafId = requestAnimationFrame(apply);
};
apply();
el.addEventListener('scroll', schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(el);
return () => {
el.removeEventListener('scroll', schedule);
ro.disconnect();
cancelAnimationFrame(rafId);
};
}, [layout, data]);
usePageMeta({ usePageMeta({
title: data ? t('modelPage.metaTitle')(data.model.brand_name, data.model.name) : t('modelPage.metaTitleFallback'), 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, description: data ? t('modelPage.metaDescription')(data.model.brand_name, data.model.name) : undefined,
@ -351,14 +390,23 @@ export default function ModelPage() {
// bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige // bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige
// "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an. // "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an.
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}> <section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
<div className={isStripLayout ? 'mx-auto max-w-[1880px] px-4 sm:px-6' : 'mx-auto max-w-6xl px-4 sm:px-6'}> <div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'mx-auto max-w-[1880px] px-4 sm:px-6'}>
<div <div
ref={layout === 'coverflow' ? coverflowRef : undefined}
className={ className={
isStripLayout layout === 'grid'
? // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst ? 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
: // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
// nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe. // nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
'flex items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:thin] [scrollbar-color:rgba(0,0,0,0.3)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.25)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/30 dark:[&::-webkit-scrollbar-thumb]:bg-white/25' 'flex items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:thin] [scrollbar-color:rgba(0,0,0,0.3)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.25)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/30 dark:[&::-webkit-scrollbar-thumb]:bg-white/25'
: 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3' }
style={
layout === 'coverflow'
? // Perspektive fuer die 3D-Drehung; das seitliche Padding erlaubt es,
// auch die erste/letzte Karte in die Mitte zu scrollen (Kartenbreite
// 230px -> halbe Breite 115px). pt schafft Luft fuer die Skalierung.
{ perspective: '1200px', paddingLeft: 'calc(50% - 115px)', paddingRight: 'calc(50% - 115px)', paddingTop: '10px' }
: undefined
} }
> >
{packages.map((pkg) => { {packages.map((pkg) => {
@ -369,7 +417,7 @@ export default function ModelPage() {
pkg={pkg} pkg={pkg}
tier={tier} tier={tier}
tierT={tierT} tierT={tierT}
isStripLayout={isStripLayout} layout={layout}
bullets={bulletsOf(pkg)} bullets={bulletsOf(pkg)}
formatPrice={formatPrice} formatPrice={formatPrice}
contactUrl={contactUrl} contactUrl={contactUrl}

View file

@ -0,0 +1,7 @@
-- Optionaler Freitext, der auf der Paket-Kachel den Preis ersetzt (z. B. "Coming soon").
-- 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 price_text VARCHAR(100) NULL;

View file

@ -151,6 +151,7 @@ CREATE TABLE packages (
markup_value DECIMAL(10,2) NOT NULL DEFAULT 0, markup_value DECIMAL(10,2) NOT NULL DEFAULT 0,
icon_name VARCHAR(100) NULL, icon_name VARCHAR(100) NULL,
tagline VARCHAR(150) NULL, tagline VARCHAR(150) NULL,
price_text VARCHAR(100) NULL,
is_featured TINYINT(1) NOT NULL DEFAULT 0, is_featured TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View file

@ -90,7 +90,7 @@ class ModelController
} }
$pkgStmt = $db->prepare( $pkgStmt = $db->prepare(
'SELECT id, name, slug, description, markup_type, markup_value, icon_name, tagline, is_featured, sort_order 'SELECT id, name, slug, description, markup_type, markup_value, icon_name, tagline, price_text, is_featured, sort_order
FROM packages WHERE car_model_id = ? ORDER BY sort_order, name' FROM packages WHERE car_model_id = ? ORDER BY sort_order, name'
); );
$pkgStmt->execute([$model['id']]); $pkgStmt->execute([$model['id']]);

View file

@ -23,7 +23,7 @@ class PackageController
{ {
$stmt = Database::connection()->query( $stmt = Database::connection()->query(
'SELECT p.id, p.name, p.slug, p.description, p.markup_type, p.markup_value, p.icon_name, p.tagline, '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 p.price_text, p.is_featured, p.sort_order, p.car_model_id, m.name AS model_name, b.name AS brand_name
FROM packages p FROM packages p
JOIN car_models m ON m.id = p.car_model_id JOIN car_models m ON m.id = p.car_model_id
JOIN brands b ON b.id = m.brand_id JOIN brands b ON b.id = m.brand_id
@ -51,8 +51,8 @@ class PackageController
$db = Database::connection(); $db = Database::connection();
$stmt = $db->prepare( $stmt = $db->prepare(
'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, icon_name, tagline, is_featured, sort_order) 'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, icon_name, tagline, price_text, is_featured, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
); );
$stmt->execute([ $stmt->execute([
$modelId, $modelId,
@ -63,6 +63,7 @@ class PackageController
$markupValue, $markupValue,
trim($body['icon_name'] ?? '') ?: null, trim($body['icon_name'] ?? '') ?: null,
trim($body['tagline'] ?? '') ?: null, trim($body['tagline'] ?? '') ?: null,
trim($body['price_text'] ?? '') ?: null,
!empty($body['is_featured']) ? 1 : 0, !empty($body['is_featured']) ? 1 : 0,
(int) ($body['sort_order'] ?? 0), (int) ($body['sort_order'] ?? 0),
]); ]);
@ -84,7 +85,7 @@ class PackageController
[$markupType, $markupValue] = self::normalizeMarkup($body); [$markupType, $markupValue] = self::normalizeMarkup($body);
$stmt = Database::connection()->prepare( $stmt = Database::connection()->prepare(
'UPDATE packages SET car_model_id = ?, name = ?, slug = ?, description = ?, markup_type = ?, markup_value = ?, icon_name = ?, tagline = ?, is_featured = ?, sort_order = ? WHERE id = ?' 'UPDATE packages SET car_model_id = ?, name = ?, slug = ?, description = ?, markup_type = ?, markup_value = ?, icon_name = ?, tagline = ?, price_text = ?, is_featured = ?, sort_order = ? WHERE id = ?'
); );
$stmt->execute([ $stmt->execute([
$modelId, $modelId,
@ -95,6 +96,7 @@ class PackageController
$markupValue, $markupValue,
trim($body['icon_name'] ?? '') ?: null, trim($body['icon_name'] ?? '') ?: null,
trim($body['tagline'] ?? '') ?: null, trim($body['tagline'] ?? '') ?: null,
trim($body['price_text'] ?? '') ?: null,
!empty($body['is_featured']) ? 1 : 0, !empty($body['is_featured']) ? 1 : 0,
(int) ($body['sort_order'] ?? 0), (int) ($body['sort_order'] ?? 0),
$id, $id,

View file

@ -16,7 +16,7 @@ class SettingsController
private const TABLE_COLUMNS = [ private const TABLE_COLUMNS = [
'brands' => ['id', 'name', 'slug', 'sort_order', 'created_at', 'updated_at'], 'brands' => ['id', 'name', 'slug', 'sort_order', 'created_at', 'updated_at'],
'car_models' => ['id', 'brand_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', 'icon_name', 'tagline', 'is_featured', 'sort_order', 'created_at', 'updated_at'], 'packages' => ['id', 'car_model_id', 'name', 'slug', 'description', 'markup_type', 'markup_value', 'icon_name', 'tagline', 'price_text', '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_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'], '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'], 'services' => ['id', 'icon_name', 'title', 'description', 'image_path', 'cta_label', 'cta_url', 'sort_order', 'created_at', 'updated_at'],

View file

@ -40,7 +40,7 @@ class SiteSettingsController
} }
private const PACKAGE_CARD_THEMES = ['graphite', 'deep-blue', 'warm-bronze']; private const PACKAGE_CARD_THEMES = ['graphite', 'deep-blue', 'warm-bronze'];
private const PACKAGE_CARD_LAYOUTS = ['grid', 'strip']; private const PACKAGE_CARD_LAYOUTS = ['grid', 'strip', 'coverflow'];
public static function update(): void public static function update(): void
{ {

View file

@ -162,6 +162,7 @@ class Schema
markup_value DECIMAL(10,2) NOT NULL DEFAULT 0, markup_value DECIMAL(10,2) NOT NULL DEFAULT 0,
icon_name VARCHAR(100) NULL, icon_name VARCHAR(100) NULL,
tagline VARCHAR(150) NULL, tagline VARCHAR(150) NULL,
price_text VARCHAR(100) NULL,
is_featured TINYINT(1) NOT NULL DEFAULT 0, is_featured TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -364,6 +365,7 @@ class Schema
'markup_value' => 'DECIMAL(10,2) NOT NULL DEFAULT 0', 'markup_value' => 'DECIMAL(10,2) NOT NULL DEFAULT 0',
'icon_name' => 'VARCHAR(100) NULL', 'icon_name' => 'VARCHAR(100) NULL',
'tagline' => 'VARCHAR(150) NULL', 'tagline' => 'VARCHAR(150) NULL',
'price_text' => 'VARCHAR(100) NULL',
'is_featured' => 'TINYINT(1) NOT NULL DEFAULT 0', 'is_featured' => 'TINYINT(1) NOT NULL DEFAULT 0',
'sort_order' => 'INT NOT NULL DEFAULT 0', 'sort_order' => 'INT NOT NULL DEFAULT 0',
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',