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:
parent
fe751dce5f
commit
873b9c2b50
11 changed files with 117 additions and 34 deletions
|
|
@ -84,25 +84,28 @@ html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
|
|||
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 {
|
||||
position: fixed;
|
||||
top: 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 {
|
||||
position: absolute;
|
||||
width: 38px;
|
||||
height: 15px;
|
||||
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%;
|
||||
transform: rotate(-24deg);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
transform: rotate(-24deg) scale(0.6);
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
margin: -2.5px 0 0 -2.5px;
|
||||
border-radius: 9999px;
|
||||
background: #eaffdb;
|
||||
box-shadow: 0 0 6px rgba(139, 234, 60, 0.9);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Hover ueber Interaktivem: Kern waechst, Bahn weitet sich und leuchtet auf
|
||||
(Satellit beschleunigt via JS). */
|
||||
/* Hover ueber Interaktivem: Orbit blendet ein und weitet sich, Kern wechselt auf
|
||||
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 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
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 {
|
||||
transform: rotate(-24deg) scale(1.45);
|
||||
border-color: rgba(139, 234, 60, 0.9);
|
||||
}
|
||||
|
||||
/* Ueber Textfeldern: komplett ausblenden, nativer Text-Cursor uebernimmt. */
|
||||
|
|
@ -151,7 +161,7 @@ html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
|
|||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
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;
|
||||
}
|
||||
.hifi-cursor__pulse.is-live {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const emptyForm = {
|
|||
markup_value: 0,
|
||||
icon_name: '',
|
||||
tagline: '',
|
||||
price_text: '',
|
||||
is_featured: false,
|
||||
sort_order: 0,
|
||||
};
|
||||
|
|
@ -76,6 +77,7 @@ export default function Packages() {
|
|||
markup_value: pkg.markup_value || 0,
|
||||
icon_name: pkg.icon_name || '',
|
||||
tagline: pkg.tagline || '',
|
||||
price_text: pkg.price_text || '',
|
||||
is_featured: !!pkg.is_featured,
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const PACKAGE_CARD_THEMES = [
|
|||
const PACKAGE_CARD_LAYOUTS = [
|
||||
{ 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: 'coverflow', label: '3D-Coverflow', description: 'Kacheln drehen sich beim Wischen räumlich – die mittlere steht im Fokus.' },
|
||||
];
|
||||
|
||||
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">
|
||||
Bestimmt, wie die Paket-Stufen auf jeder Fahrzeug-Modell-Seite angeordnet werden.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{PACKAGE_CARD_LAYOUTS.map((layout) => (
|
||||
<label
|
||||
key={layout.value}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client.js';
|
||||
import usePageMeta from '../../hooks/usePageMeta.js';
|
||||
|
|
@ -91,7 +91,7 @@ const BULLET_COLOR = '#c4c4c4';
|
|||
// Wertigkeitssignal wirkt.
|
||||
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 hiddenCount = bullets.length - VISIBLE_BULLETS;
|
||||
const shownBullets = expanded ? bullets : bullets.slice(0, VISIBLE_BULLETS);
|
||||
|
|
@ -120,8 +120,12 @@ function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, co
|
|||
background: `linear-gradient(180deg, ${tier.bgTop} 0%, #0b0b0b 55%, #080808 100%)`,
|
||||
}}
|
||||
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]'
|
||||
: 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'
|
||||
}
|
||||
>
|
||||
|
|
@ -226,7 +230,8 @@ function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, co
|
|||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
|
|
@ -237,7 +242,7 @@ function PackageCard({ pkg, tier, tierT, isStripLayout, bullets, formatPrice, co
|
|||
color: PRICE_COLOR,
|
||||
}}
|
||||
>
|
||||
{formatPrice(pkg.total_price)}
|
||||
{pkg.price_text || formatPrice(pkg.total_price)}
|
||||
</div>
|
||||
<Link
|
||||
to={contactUrl(pkg)}
|
||||
|
|
@ -259,7 +264,8 @@ export default function ModelPage() {
|
|||
const { t, language } = useLanguage();
|
||||
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
|
||||
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) =>
|
||||
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));
|
||||
}, [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({
|
||||
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,
|
||||
|
|
@ -351,14 +390,23 @@ export default function ModelPage() {
|
|||
// bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige
|
||||
// "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an.
|
||||
<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
|
||||
ref={layout === 'coverflow' ? coverflowRef : undefined}
|
||||
className={
|
||||
isStripLayout
|
||||
? // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
|
||||
layout === 'grid'
|
||||
? '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.
|
||||
'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) => {
|
||||
|
|
@ -369,7 +417,7 @@ export default function ModelPage() {
|
|||
pkg={pkg}
|
||||
tier={tier}
|
||||
tierT={tierT}
|
||||
isStripLayout={isStripLayout}
|
||||
layout={layout}
|
||||
bullets={bulletsOf(pkg)}
|
||||
formatPrice={formatPrice}
|
||||
contactUrl={contactUrl}
|
||||
|
|
|
|||
7
hifi/api/database/migration_package_price_text.sql
Normal file
7
hifi/api/database/migration_package_price_text.sql
Normal 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;
|
||||
|
|
@ -151,6 +151,7 @@ CREATE TABLE packages (
|
|||
markup_value DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
icon_name VARCHAR(100) NULL,
|
||||
tagline VARCHAR(150) NULL,
|
||||
price_text VARCHAR(100) NULL,
|
||||
is_featured TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class ModelController
|
|||
}
|
||||
|
||||
$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'
|
||||
);
|
||||
$pkgStmt->execute([$model['id']]);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class PackageController
|
|||
{
|
||||
$stmt = Database::connection()->query(
|
||||
'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
|
||||
JOIN car_models m ON m.id = p.car_model_id
|
||||
JOIN brands b ON b.id = m.brand_id
|
||||
|
|
@ -51,8 +51,8 @@ class PackageController
|
|||
|
||||
$db = Database::connection();
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, icon_name, tagline, is_featured, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, icon_name, tagline, price_text, is_featured, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$modelId,
|
||||
|
|
@ -63,6 +63,7 @@ class PackageController
|
|||
$markupValue,
|
||||
trim($body['icon_name'] ?? '') ?: null,
|
||||
trim($body['tagline'] ?? '') ?: null,
|
||||
trim($body['price_text'] ?? '') ?: null,
|
||||
!empty($body['is_featured']) ? 1 : 0,
|
||||
(int) ($body['sort_order'] ?? 0),
|
||||
]);
|
||||
|
|
@ -84,7 +85,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 = ?, 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([
|
||||
$modelId,
|
||||
|
|
@ -95,6 +96,7 @@ class PackageController
|
|||
$markupValue,
|
||||
trim($body['icon_name'] ?? '') ?: null,
|
||||
trim($body['tagline'] ?? '') ?: null,
|
||||
trim($body['price_text'] ?? '') ?: null,
|
||||
!empty($body['is_featured']) ? 1 : 0,
|
||||
(int) ($body['sort_order'] ?? 0),
|
||||
$id,
|
||||
|
|
|
|||
|
|
@ -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', '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_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'],
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class SiteSettingsController
|
|||
}
|
||||
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ class Schema
|
|||
markup_value DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
icon_name VARCHAR(100) NULL,
|
||||
tagline VARCHAR(150) NULL,
|
||||
price_text VARCHAR(100) NULL,
|
||||
is_featured TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
@ -364,6 +365,7 @@ class Schema
|
|||
'markup_value' => 'DECIMAL(10,2) NOT NULL DEFAULT 0',
|
||||
'icon_name' => 'VARCHAR(100) NULL',
|
||||
'tagline' => 'VARCHAR(150) NULL',
|
||||
'price_text' => 'VARCHAR(100) NULL',
|
||||
'is_featured' => 'TINYINT(1) NOT NULL DEFAULT 0',
|
||||
'sort_order' => 'INT NOT NULL DEFAULT 0',
|
||||
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
|
|
|
|||
Loading…
Reference in a new issue