x
This commit is contained in:
1 parent
ce4502d123
commit
acee55a633
14 files changed
+626
-96
No files matched your search
@@ -126,8 +126,62 @@ export default function ProductCarouselForm({
|
|||||||
}
|
}
|
||||||
className="admin-input w-full bg-slate-900 border-white/10"
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
>
|
>
|
||||||
<option value="standard">Standard (Retail with Add to Cart)</option>
|
<option value="standard">Standard Retail (1:1 Boxed with Add to Cart)</option>
|
||||||
<option value="minimal_swatch">Minimal Swatch (Editorial with Color Dots)</option>
|
<option value="minimal">Fashion Minimal (3:4 Portrait, Pomelo/Zara Style)</option>
|
||||||
|
<option value="swatch">Editorial Swatch (Lookbook with Swatch Dots)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ความกว้างบล็อก (Width)</label>
|
||||||
|
<select
|
||||||
|
value={editingSection.payload?.containerWidth || 'standard'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
payload: { ...(prev.payload || {}), containerWidth: e.target.value }
|
||||||
|
} : null)
|
||||||
|
}
|
||||||
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
|
>
|
||||||
|
<option value="standard">Standard (กรอบ 1280px)</option>
|
||||||
|
<option value="wide">Wide (ขยายกว้าง 1600px)</option>
|
||||||
|
<option value="full">Full Width (เต็มพื้นที่ 100%)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ขนาดการ์ด (Card Size)</label>
|
||||||
|
<select
|
||||||
|
value={editingSection.payload?.cardSize || 'medium'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
payload: { ...(prev.payload || {}), cardSize: e.target.value }
|
||||||
|
} : null)
|
||||||
|
}
|
||||||
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
|
>
|
||||||
|
<option value="medium">Medium (~4 ใบต่อหน้าจอ สไตล์ Pomelo - แนะนำ)</option>
|
||||||
|
<option value="large">Large (~3-4 ใบต่อหน้าจอ ขนาดใหญ่พิเศษ)</option>
|
||||||
|
<option value="compact">Compact (~5-6 ใบต่อหน้าจอ ขนาดกะทัดรัด)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">รูปแบบการแสดง (Display Layout)</label>
|
||||||
|
<select
|
||||||
|
value={editingSection.payload?.layout || 'carousel'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
payload: { ...(prev.payload || {}), layout: e.target.value }
|
||||||
|
} : null)
|
||||||
|
}
|
||||||
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
|
>
|
||||||
|
<option value="carousel">Carousel Slider (สไลด์เลื่อนแนวนอน)</option>
|
||||||
|
<option value="grid">Grid Layout (ตารางสินค้า แถวละ 4 ใบ แบบ Pomelo)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,42 @@ export default function ProductsForm({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ความกว้างบล็อก (Width)</label>
|
||||||
|
<select
|
||||||
|
value={editingSection.payload?.containerWidth || 'standard'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
payload: { ...(prev.payload || {}), containerWidth: e.target.value }
|
||||||
|
} : null)
|
||||||
|
}
|
||||||
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
|
>
|
||||||
|
<option value="standard">Standard (กรอบ 1280px)</option>
|
||||||
|
<option value="wide">Wide (ขยายกว้าง 1600px)</option>
|
||||||
|
<option value="full">Full Width (เต็มพื้นที่ 100%)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ขนาดการ์ด (Card Size)</label>
|
||||||
|
<select
|
||||||
|
value={editingSection.payload?.cardSize || 'medium'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
payload: { ...(prev.payload || {}), cardSize: e.target.value }
|
||||||
|
} : null)
|
||||||
|
}
|
||||||
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
|
>
|
||||||
|
<option value="medium">Medium (~4 ใบต่อหน้าจอ สไตล์ Pomelo - แนะนำ)</option>
|
||||||
|
<option value="large">Large (~3-4 ใบต่อหน้าจอ ขนาดใหญ่พิเศษ)</option>
|
||||||
|
<option value="compact">Compact (~5-6 ใบต่อหน้าจอ ขนาดกะทัดรัด)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 flex flex-col justify-end">
|
<div className="space-y-2 flex flex-col justify-end">
|
||||||
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/5 rounded-xl cursor-pointer select-none">
|
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/5 rounded-xl cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -935,6 +935,9 @@ export default function PageBuilderPage() {
|
|||||||
showPrice: true,
|
showPrice: true,
|
||||||
showAddToCart: true,
|
showAddToCart: true,
|
||||||
cardStyle: 'standard',
|
cardStyle: 'standard',
|
||||||
|
containerWidth: 'standard',
|
||||||
|
cardSize: 'medium',
|
||||||
|
layout: 'carousel',
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case 'PRODUCT_MAIN':
|
case 'PRODUCT_MAIN':
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Monitor,
|
Monitor,
|
||||||
Smartphone
|
Smartphone,
|
||||||
|
LayoutGrid
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const defaultThemeTokens = {
|
const defaultThemeTokens = {
|
||||||
@@ -167,6 +168,7 @@ export default function ThemeSettingsPage() {
|
|||||||
const [headerBehavior, setHeaderBehavior] = useState<'always_sticky' | 'hide_on_scroll'>('hide_on_scroll')
|
const [headerBehavior, setHeaderBehavior] = useState<'always_sticky' | 'hide_on_scroll'>('hide_on_scroll')
|
||||||
const [mobileHeaderStyle, setMobileHeaderStyle] = useState<'logo_center' | 'logo_left' | 'search_bar_below'>('logo_center')
|
const [mobileHeaderStyle, setMobileHeaderStyle] = useState<'logo_center' | 'logo_left' | 'search_bar_below'>('logo_center')
|
||||||
const [mobileHeaderBehavior, setMobileHeaderBehavior] = useState<'always_sticky' | 'hide_on_scroll'>('hide_on_scroll')
|
const [mobileHeaderBehavior, setMobileHeaderBehavior] = useState<'always_sticky' | 'hide_on_scroll'>('hide_on_scroll')
|
||||||
|
const [cardPreset, setCardPreset] = useState<'standard' | 'minimal' | 'swatch'>('standard')
|
||||||
|
|
||||||
// JSON Quick Copy / Paste State
|
// JSON Quick Copy / Paste State
|
||||||
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
|
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
|
||||||
@@ -197,6 +199,8 @@ export default function ThemeSettingsPage() {
|
|||||||
setHeaderBehavior(pcBehavior)
|
setHeaderBehavior(pcBehavior)
|
||||||
setMobileHeaderStyle((data as any).mobileHeaderStyle || (data.themeTokens as any)?.mobileHeaderStyle || 'logo_center')
|
setMobileHeaderStyle((data as any).mobileHeaderStyle || (data.themeTokens as any)?.mobileHeaderStyle || 'logo_center')
|
||||||
setMobileHeaderBehavior((data as any).mobileHeaderBehavior || (data.themeTokens as any)?.mobileHeaderBehavior || pcBehavior)
|
setMobileHeaderBehavior((data as any).mobileHeaderBehavior || (data.themeTokens as any)?.mobileHeaderBehavior || pcBehavior)
|
||||||
|
const cp = (data as any).cardPreset || (data.themeTokens as any)?.cardPreset || 'standard'
|
||||||
|
setCardPreset(cp === 'minimal_swatch' ? 'swatch' : cp)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -217,7 +221,9 @@ export default function ThemeSettingsPage() {
|
|||||||
headerBehavior,
|
headerBehavior,
|
||||||
mobileHeaderBehavior,
|
mobileHeaderBehavior,
|
||||||
mobileHeaderStyle,
|
mobileHeaderStyle,
|
||||||
|
cardPreset,
|
||||||
},
|
},
|
||||||
|
cardPreset,
|
||||||
scrollRevealEnabled,
|
scrollRevealEnabled,
|
||||||
scrollRevealStyle,
|
scrollRevealStyle,
|
||||||
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||||
@@ -444,7 +450,9 @@ export default function ThemeSettingsPage() {
|
|||||||
headerBehavior,
|
headerBehavior,
|
||||||
mobileHeaderBehavior,
|
mobileHeaderBehavior,
|
||||||
mobileHeaderStyle,
|
mobileHeaderStyle,
|
||||||
|
cardPreset,
|
||||||
},
|
},
|
||||||
|
cardPreset,
|
||||||
scrollRevealEnabled,
|
scrollRevealEnabled,
|
||||||
scrollRevealStyle,
|
scrollRevealStyle,
|
||||||
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||||
@@ -1064,7 +1072,84 @@ export default function ThemeSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 4. Desktop (PC) Header Settings ── */}
|
{/* ── 4. Product Card UI Presets ── */}
|
||||||
|
<div className="admin-card p-6 md:p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-emerald-500/10 rounded-xl text-emerald-400">
|
||||||
|
<LayoutGrid className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Product Card UI Preset</h2>
|
||||||
|
<p className="text-xs text-slate-400">เลือกรูปแบบการ์ดสินค้าหลักสำหรับทั้งร้านค้า (Catalog, Search & Default)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
id: 'standard',
|
||||||
|
title: 'Standard Retail',
|
||||||
|
badge: 'Default Retail',
|
||||||
|
desc: 'กล่องการ์ดมีขอบ, ภาพ 1:1 จัตุรัส, ปุ่ม Select Options / Add to Cart ใต้ราคา เหมาะกับร้านค้าทั่วไปหรือสินค้าอิเล็กทรอนิกส์/ของใช้',
|
||||||
|
features: ['1:1 Square Image', 'Bordered Card Container', 'Action Button (Add to Cart)', 'Category Badge'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minimal',
|
||||||
|
title: 'Fashion Minimal',
|
||||||
|
badge: 'Pomelo / Zara Style',
|
||||||
|
desc: 'สไตล์แฟชั่นมินิมอล ภาพแนวตั้ง 3:4 ไร้กรอบแบนราบ แบรนด์ตัวหนา หัวใจ Wishlist แสดงยอดขาย คลีนหรูหรา',
|
||||||
|
features: ['3:4 Portrait Image', 'Borderless Flat Layout', 'Brand Name & Wishlist Heart', 'Price & Stock Status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swatch',
|
||||||
|
title: 'Editorial Swatch',
|
||||||
|
badge: 'Lookbook / New Arrivals',
|
||||||
|
desc: 'สไตล์นิตยสารมินิมอล เน้นจุดสีกลม (Color Swatch Dots) และชื่อสินค้าแบบเรียบหรู เหมาะกับสินค้าหลายเฉดสี',
|
||||||
|
features: ['Color Swatch Dots', 'Clean Typography', 'Frameless Lookbook', 'Price with Subtitle'],
|
||||||
|
},
|
||||||
|
].map((preset) => {
|
||||||
|
const isSelected = cardPreset === preset.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => setCardPreset(preset.id as any)}
|
||||||
|
className={`relative p-5 rounded-2xl border transition-all cursor-pointer flex flex-col justify-between ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-500/60 bg-emerald-500/10 ring-1 ring-emerald-500/40 shadow-lg'
|
||||||
|
: 'border-white/10 bg-night-900/60 hover:border-white/20 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-white/10 text-slate-300">
|
||||||
|
{preset.badge}
|
||||||
|
</span>
|
||||||
|
<div className={`w-4 h-4 rounded-full border flex items-center justify-center ${isSelected ? 'border-emerald-400 bg-emerald-400' : 'border-slate-500'}`}>
|
||||||
|
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-black" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white">{preset.title}</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{preset.desc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-white/5 space-y-1">
|
||||||
|
{preset.features.map((feat, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1.5 text-[11px] text-slate-300">
|
||||||
|
<span className="text-emerald-400 font-bold">✓</span>
|
||||||
|
<span>{feat}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 5. Desktop (PC) Header Settings ── */}
|
||||||
<div className="admin-card p-6 md:p-8 space-y-6">
|
<div className="admin-card p-6 md:p-8 space-y-6">
|
||||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
<div className="p-2 bg-amber-500/10 rounded-xl text-amber-400">
|
<div className="p-2 bg-amber-500/10 rounded-xl text-amber-400">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
// Trigger reload: support paddingTop, paddingBottom, and sectionPadding for FEATURED_TILES
|
// Trigger reload: support containerWidth, cardSize, and layout for PRODUCT_CAROUSEL and PRODUCTS
|
||||||
import {
|
import {
|
||||||
LocalizedStringSchema,
|
LocalizedStringSchema,
|
||||||
HeroSectionPayloadSchema,
|
HeroSectionPayloadSchema,
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
||||||
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
|
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
|
||||||
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||||
|
const cardPreset = (settings.themeTokens as any)?.cardPreset ?? 'standard';
|
||||||
return {
|
return {
|
||||||
...settingsData,
|
...settingsData,
|
||||||
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
|
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
|
||||||
@@ -195,6 +196,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
headerBehavior,
|
headerBehavior,
|
||||||
mobileHeaderBehavior,
|
mobileHeaderBehavior,
|
||||||
mobileHeaderStyle,
|
mobileHeaderStyle,
|
||||||
|
cardPreset,
|
||||||
footerDescription: localizedDescription ?? settingsData.footerDescription,
|
footerDescription: localizedDescription ?? settingsData.footerDescription,
|
||||||
defaultSeoTitle: localizedSeoTitle ?? settingsData.defaultSeoTitle,
|
defaultSeoTitle: localizedSeoTitle ?? settingsData.defaultSeoTitle,
|
||||||
defaultSeoDescription: localizedSeoDescription ?? settingsData.defaultSeoDescription,
|
defaultSeoDescription: localizedSeoDescription ?? settingsData.defaultSeoDescription,
|
||||||
@@ -369,7 +371,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
}, { beforeHandle: requireAdmin, body: t.Any() })
|
}, { beforeHandle: requireAdmin, body: t.Any() })
|
||||||
.put("/", async ({ body, set }: { body: any; set: any }) => {
|
.put("/", async ({ body, set }: { body: any; set: any }) => {
|
||||||
try {
|
try {
|
||||||
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, cardPreset, translations, ...data } = body as any;
|
||||||
if (footerLayout !== undefined) {
|
if (footerLayout !== undefined) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
||||||
@@ -387,6 +389,9 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
if (mobileHeaderStyle !== undefined) {
|
if (mobileHeaderStyle !== undefined) {
|
||||||
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
|
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
|
||||||
}
|
}
|
||||||
|
if (cardPreset !== undefined) {
|
||||||
|
updatedTokens.cardPreset = cardPreset;
|
||||||
|
}
|
||||||
data.themeTokens = updatedTokens;
|
data.themeTokens = updatedTokens;
|
||||||
|
|
||||||
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
||||||
@@ -410,11 +415,13 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
const returnedBehavior = (settings?.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
const returnedBehavior = (settings?.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
||||||
const returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
|
const returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
|
||||||
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||||
|
const returnedCardPreset = (settings?.themeTokens as any)?.cardPreset ?? 'standard';
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
headerBehavior: returnedBehavior,
|
headerBehavior: returnedBehavior,
|
||||||
mobileHeaderBehavior: returnedMobileBehavior,
|
mobileHeaderBehavior: returnedMobileBehavior,
|
||||||
mobileHeaderStyle: returnedMobileStyle,
|
mobileHeaderStyle: returnedMobileStyle,
|
||||||
|
cardPreset: returnedCardPreset,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Settings] Error:", err);
|
console.error("[Settings] Error:", err);
|
||||||
@@ -449,6 +456,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
headerBehavior: t.Optional(t.String()),
|
headerBehavior: t.Optional(t.String()),
|
||||||
mobileHeaderBehavior: t.Optional(t.String()),
|
mobileHeaderBehavior: t.Optional(t.String()),
|
||||||
mobileHeaderStyle: t.Optional(t.String()),
|
mobileHeaderStyle: t.Optional(t.String()),
|
||||||
|
cardPreset: t.Optional(t.String()),
|
||||||
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
|
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
|
||||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||||
@@ -459,7 +467,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
})
|
})
|
||||||
.post("/", async ({ body, set }: { body: any; set: any }) => {
|
.post("/", async ({ body, set }: { body: any; set: any }) => {
|
||||||
try {
|
try {
|
||||||
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, cardPreset, translations, ...data } = body as any;
|
||||||
if (footerLayout !== undefined) {
|
if (footerLayout !== undefined) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
||||||
@@ -477,6 +485,9 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
if (mobileHeaderStyle !== undefined) {
|
if (mobileHeaderStyle !== undefined) {
|
||||||
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
|
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
|
||||||
}
|
}
|
||||||
|
if (cardPreset !== undefined) {
|
||||||
|
updatedTokens.cardPreset = cardPreset;
|
||||||
|
}
|
||||||
data.themeTokens = updatedTokens;
|
data.themeTokens = updatedTokens;
|
||||||
|
|
||||||
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
||||||
@@ -500,11 +511,13 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
const returnedBehavior = (settings?.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
const returnedBehavior = (settings?.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
||||||
const returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
|
const returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
|
||||||
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||||
|
const returnedCardPreset = (settings?.themeTokens as any)?.cardPreset ?? 'standard';
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
headerBehavior: returnedBehavior,
|
headerBehavior: returnedBehavior,
|
||||||
mobileHeaderBehavior: returnedMobileBehavior,
|
mobileHeaderBehavior: returnedMobileBehavior,
|
||||||
mobileHeaderStyle: returnedMobileStyle,
|
mobileHeaderStyle: returnedMobileStyle,
|
||||||
|
cardPreset: returnedCardPreset,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Settings] Error:", err);
|
console.error("[Settings] Error:", err);
|
||||||
@@ -539,6 +552,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
headerBehavior: t.Optional(t.String()),
|
headerBehavior: t.Optional(t.String()),
|
||||||
mobileHeaderBehavior: t.Optional(t.String()),
|
mobileHeaderBehavior: t.Optional(t.String()),
|
||||||
mobileHeaderStyle: t.Optional(t.String()),
|
mobileHeaderStyle: t.Optional(t.String()),
|
||||||
|
cardPreset: t.Optional(t.String()),
|
||||||
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
|
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
|
||||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||||
|
|||||||
@@ -19,18 +19,57 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
|||||||
const title = getLocalizedField(p.title, lang);
|
const title = getLocalizedField(p.title, lang);
|
||||||
const description = getLocalizedField(p.description, lang);
|
const description = getLocalizedField(p.description, lang);
|
||||||
const cardStyle = p.cardStyle || 'standard';
|
const cardStyle = p.cardStyle || 'standard';
|
||||||
const isMinimal = cardStyle === 'minimal_swatch';
|
const isMinimal = cardStyle === 'minimal' || cardStyle === 'swatch' || cardStyle === 'minimal_swatch';
|
||||||
const viewAllLink = p.viewAllLink || '/products';
|
const viewAllLink = p.viewAllLink || '/products';
|
||||||
const viewAllText = getLocalizedField(p.viewAllText, lang) || 'View All';
|
const viewAllText = getLocalizedField(p.viewAllText, lang) || 'View All';
|
||||||
const badgeText = getLocalizedField(p.badgeText, lang);
|
const badgeText = getLocalizedField(p.badgeText, lang);
|
||||||
|
const layout = p.layout || 'carousel';
|
||||||
|
const containerWidth = p.containerWidth || 'standard';
|
||||||
|
const cardSize = p.cardSize || 'medium';
|
||||||
|
|
||||||
|
const getContainerWidthClass = (width: string) => {
|
||||||
|
switch (width) {
|
||||||
|
case 'full':
|
||||||
|
return 'w-full max-w-none';
|
||||||
|
case 'wide':
|
||||||
|
return 'w-full max-w-[1600px] mx-auto';
|
||||||
|
case 'standard':
|
||||||
|
default:
|
||||||
|
return 'ui-section-inner';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCarouselCardWidth = () => {
|
||||||
|
switch (cardSize) {
|
||||||
|
case 'compact':
|
||||||
|
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start';
|
||||||
|
case 'large':
|
||||||
|
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start';
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGridColsClass = () => {
|
||||||
|
switch (cardSize) {
|
||||||
|
case 'compact':
|
||||||
|
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6';
|
||||||
|
case 'large':
|
||||||
|
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3';
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const carouselRef = useRef<HTMLDivElement>(null);
|
const carouselRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
|
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
|
||||||
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
|
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`bg-pop-bg ${getSectionPaddingClasses(p)}`}>
|
<section className={`bg-pop-bg px-4 sm:px-6 md:px-8 overflow-hidden ${getSectionPaddingClasses(p)}`}>
|
||||||
<div className="ui-section-inner space-y-6">
|
<div className={`${getContainerWidthClass(containerWidth)} space-y-6`}>
|
||||||
{isMinimal ? (
|
{isMinimal ? (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-black/10 pb-5">
|
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-black/10 pb-5">
|
||||||
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
||||||
@@ -55,6 +94,18 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
|||||||
<div className="text-center py-12 text-pop-black/50 italic">
|
<div className="text-center py-12 text-pop-black/50 italic">
|
||||||
No products to display.
|
No products to display.
|
||||||
</div>
|
</div>
|
||||||
|
) : layout === 'grid' ? (
|
||||||
|
<div className={getGridColsClass()}>
|
||||||
|
{products.map((prod: any) => (
|
||||||
|
<ProductCard
|
||||||
|
key={prod.id}
|
||||||
|
product={prod}
|
||||||
|
cardStyle={cardStyle}
|
||||||
|
badgeText={badgeText}
|
||||||
|
enableQuickShop={cardStyle === 'standard'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
|
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||||
<button
|
<button
|
||||||
@@ -68,17 +119,14 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
|||||||
{products.map((prod: any) => (
|
{products.map((prod: any) => (
|
||||||
<div
|
<div
|
||||||
key={prod.id}
|
key={prod.id}
|
||||||
className={
|
className={getCarouselCardWidth()}
|
||||||
isMinimal
|
|
||||||
? "w-[42vw] sm:w-[200px] md:w-[220px] lg:w-[240px] flex-none snap-start"
|
|
||||||
: "w-[85vw] sm:w-[300px] flex-none snap-start"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isMinimal ? (
|
<ProductCard
|
||||||
<ProductSwatchCard product={prod} badgeText={badgeText} />
|
product={prod}
|
||||||
) : (
|
cardStyle={cardStyle}
|
||||||
<ProductCard product={prod} enableQuickShop={true} />
|
badgeText={badgeText}
|
||||||
)}
|
enableQuickShop={cardStyle === 'standard'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,13 +20,53 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
|||||||
const subtitle = getLocalizedField(payload.subtitle, lang);
|
const subtitle = getLocalizedField(payload.subtitle, lang);
|
||||||
const categoryLookup = new Map(categories.map((category) => [category.id, category]));
|
const categoryLookup = new Map(categories.map((category) => [category.id, category]));
|
||||||
|
|
||||||
|
const containerWidth = payload.containerWidth || 'standard';
|
||||||
|
|
||||||
|
const getContainerWidthClass = (width: string) => {
|
||||||
|
switch (width) {
|
||||||
|
case 'full':
|
||||||
|
return 'w-full max-w-none';
|
||||||
|
case 'wide':
|
||||||
|
return 'w-full max-w-[1600px] mx-auto';
|
||||||
|
case 'standard':
|
||||||
|
default:
|
||||||
|
return 'ui-section-inner';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardSize = payload.cardSize || 'medium';
|
||||||
|
|
||||||
|
const getCarouselCardWidth = () => {
|
||||||
|
switch (cardSize) {
|
||||||
|
case 'compact':
|
||||||
|
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start';
|
||||||
|
case 'large':
|
||||||
|
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start';
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGridColsClass = () => {
|
||||||
|
switch (cardSize) {
|
||||||
|
case 'compact':
|
||||||
|
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6';
|
||||||
|
case 'large':
|
||||||
|
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3';
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const carouselRef = useRef<HTMLDivElement>(null);
|
const carouselRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
|
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
|
||||||
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
|
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`relative bg-pop-bg ${getSectionPaddingClasses(payload)}`}>
|
<section className={`relative bg-pop-bg px-4 sm:px-6 md:px-8 overflow-hidden ${getSectionPaddingClasses(payload)}`}>
|
||||||
<div className="ui-section-inner relative z-10 space-y-12">
|
<div className={`${getContainerWidthClass(containerWidth)} relative z-10 space-y-12`}>
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6 border-b border-pop-black/10 pb-6">
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6 border-b border-pop-black/10 pb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-bold text-pop-black md:text-5xl">
|
<h2 className="text-4xl font-bold text-pop-black md:text-5xl">
|
||||||
@@ -60,7 +100,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
|||||||
</button>
|
</button>
|
||||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 pb-12">
|
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 pb-12">
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<div key={p.id} className="w-[85vw] sm:w-[300px] flex-none snap-start">
|
<div key={p.id} className={getCarouselCardWidth()}>
|
||||||
<ProductCard
|
<ProductCard
|
||||||
product={p}
|
product={p}
|
||||||
enableQuickShop={enableQuickShop}
|
enableQuickShop={enableQuickShop}
|
||||||
@@ -78,7 +118,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className={getGridColsClass()}>
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<div key={p.id}>
|
<div key={p.id}>
|
||||||
<ProductCard
|
<ProductCard
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useWishlistStore } from '../store/wishlistStore';
|
|||||||
import { useCartStore } from '../store/cartStore';
|
import { useCartStore } from '../store/cartStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import ProductSwatchCard from './ProductSwatchCard';
|
||||||
|
|
||||||
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
|
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
|
||||||
if (!v) return ''
|
if (!v) return ''
|
||||||
if (typeof v === 'string') return v
|
if (typeof v === 'string') return v
|
||||||
@@ -35,9 +37,17 @@ interface ProductCardProps {
|
|||||||
product: Product;
|
product: Product;
|
||||||
enableQuickShop?: boolean;
|
enableQuickShop?: boolean;
|
||||||
categoryNameOverride?: string;
|
categoryNameOverride?: string;
|
||||||
|
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch';
|
||||||
|
badgeText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product, enableQuickShop = false, categoryNameOverride }: ProductCardProps) {
|
export default function ProductCard({
|
||||||
|
product,
|
||||||
|
enableQuickShop = false,
|
||||||
|
categoryNameOverride,
|
||||||
|
cardStyle = 'standard',
|
||||||
|
badgeText
|
||||||
|
}: ProductCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
||||||
const { addToCart } = useCartStore();
|
const { addToCart } = useCartStore();
|
||||||
@@ -62,12 +72,14 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
return swatches;
|
return swatches;
|
||||||
}, [product.variants]);
|
}, [product.variants]);
|
||||||
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : '');
|
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : '');
|
||||||
const displayCategoryName = categoryNameOverride || resolveStr(product.category.name, i18n.language);
|
const displayCategoryName = categoryNameOverride || resolveStr(product.category?.name, i18n.language);
|
||||||
const fallbackImage = getCategoryImage(displayCategoryName);
|
const fallbackImage = getCategoryImage(displayCategoryName);
|
||||||
const images = (product.images?.length ?? 0) > 0
|
const images = (product.images?.length ?? 0) > 0
|
||||||
? product.images!.slice(0, 3).map(img => img.url)
|
? product.images!.slice(0, 3).map(img => img.url)
|
||||||
: [fallbackImage];
|
: [fallbackImage];
|
||||||
const hasMultipleImages = images.length > 1;
|
const hasMultipleImages = images.length > 1;
|
||||||
|
const displayPrice = selectedVariant?.price ?? product.variants?.[0]?.price ?? 0;
|
||||||
|
const totalStock = product.variants?.reduce((sum, v) => sum + (v.stock ?? 0), 0) ?? 0;
|
||||||
|
|
||||||
const handleWishlistClick = (e: React.MouseEvent) => {
|
const handleWishlistClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -83,6 +95,123 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
navigate(`/products/${product.id}`);
|
navigate(`/products/${product.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (cardStyle === 'swatch' || cardStyle === 'minimal_swatch') {
|
||||||
|
return <ProductSwatchCard product={product} badgeText={badgeText} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardStyle === 'minimal') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleCardClick}
|
||||||
|
className="group flex flex-col h-full select-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* 3:4 Image Frame */}
|
||||||
|
<div className="relative aspect-[3/4] overflow-hidden rounded-lg bg-pop-black/5 group/image">
|
||||||
|
{hasMultipleImages ? (
|
||||||
|
<>
|
||||||
|
{/* Desktop Hover Effect */}
|
||||||
|
<div className="hidden md:block absolute inset-0 w-full h-full">
|
||||||
|
<img src={images[0]} alt={displayName} className="absolute inset-0 w-full h-full object-cover group-hover/image:opacity-0 transition-opacity duration-700" />
|
||||||
|
<img src={images[1]} alt={displayName} className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover/image:opacity-100 group-hover/image:scale-105 transition-all duration-700" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Native Slider */}
|
||||||
|
<div className="md:hidden relative w-full h-full" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block" onClick={handleCardClick}>
|
||||||
|
<img src={img} alt={`${displayName} ${idx + 1}`} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1.5 z-10 pointer-events-none">
|
||||||
|
{images.map((_, idx) => (
|
||||||
|
<div key={idx} className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-[0_1px_2px_rgba(0,0,0,0.3)]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={images[0]}
|
||||||
|
className="w-full h-full object-cover group-hover/image:scale-105 transition-transform duration-700"
|
||||||
|
alt={displayName}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{badgeText && (
|
||||||
|
<span className="absolute top-2.5 left-2.5 z-20 text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-full bg-black/70 text-white backdrop-blur-sm">
|
||||||
|
{badgeText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Info (Fashion Minimal / Pomelo Style) */}
|
||||||
|
<div className="pt-2 pb-1 flex flex-col gap-1">
|
||||||
|
{/* Row 1: Brand Name + Wishlist */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-pop-black uppercase tracking-wider truncate">
|
||||||
|
{product.brand?.name || 'Brand'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleWishlistClick}
|
||||||
|
className="p-1 text-pop-black/40 hover:text-pop-pink transition-colors cursor-pointer -mr-1"
|
||||||
|
aria-label="Wishlist"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`w-3.5 h-3.5 transition-colors ${isWished ? 'fill-pop-pink text-pop-pink' : 'text-pop-black/40 hover:text-pop-pink'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Product Name */}
|
||||||
|
<h3 className="line-clamp-1 text-xs md:text-sm font-normal text-pop-black transition-colors group-hover:text-pop-pink">
|
||||||
|
{displayName}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Row 3: Price + Status */}
|
||||||
|
<div className="flex items-center justify-between text-xs pt-0.5">
|
||||||
|
<span className="font-bold text-pop-black md:text-sm">
|
||||||
|
{formatPrice(displayPrice)}
|
||||||
|
</span>
|
||||||
|
{totalStock > 0 ? (
|
||||||
|
<span className="text-[10px] text-pop-black/50">
|
||||||
|
{t('product.inStock', 'In Stock')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-rose-500 font-semibold">
|
||||||
|
{t('product.outOfStock', 'Out of Stock')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Swatch Dots */}
|
||||||
|
{colorSwatches.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 pt-0.5 flex-wrap">
|
||||||
|
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => (
|
||||||
|
<span
|
||||||
|
key={variant.id || colorCode}
|
||||||
|
className="relative h-3 w-3 rounded-full border border-black/15 shadow-sm shrink-0"
|
||||||
|
style={{ backgroundColor: colorCode }}
|
||||||
|
title={label || colorCode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{colorSwatches.length > 5 && (
|
||||||
|
<span className="text-[10px] font-semibold text-pop-black/60 pl-0.5">
|
||||||
|
+{colorSwatches.length - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
|
|||||||
@@ -91,34 +91,22 @@ export default function ProductSwatchCard({ product, badgeText }: ProductSwatchC
|
|||||||
{formatPrice(displayPrice)}
|
{formatPrice(displayPrice)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color Swatch Dots */}
|
{/* Color Swatch Dots (Display Only) */}
|
||||||
{colorSwatches.length > 0 && (
|
{colorSwatches.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 pt-1">
|
<div className="flex items-center gap-1.5 pt-1 flex-wrap">
|
||||||
{colorSwatches.map(({ colorCode, variant }) => {
|
{colorSwatches.slice(0, 5).map(({ colorCode, variant }) => (
|
||||||
const isActive = activeVariant?.colorCode === colorCode;
|
<span
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={colorCode}
|
key={colorCode}
|
||||||
type="button"
|
className="relative w-3.5 h-3.5 rounded-full border border-black/15 shadow-sm shrink-0"
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setActiveVariant(variant);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (variant.imageUrl) {
|
|
||||||
setActiveVariant(variant);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`relative w-3.5 h-3.5 rounded-full border border-black/15 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
|
||||||
isActive ? 'ring-2 ring-neutral-700 ring-offset-1 scale-110' : 'opacity-85 hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: colorCode }}
|
style={{ backgroundColor: colorCode }}
|
||||||
title={variant.option1 || variant.option2 || colorCode}
|
title={variant.option1 || variant.option2 || colorCode}
|
||||||
aria-label={`Color ${colorCode}`}
|
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
{colorSwatches.length > 5 && (
|
||||||
|
<span className="text-[10px] font-semibold text-pop-black/60 pl-0.5">
|
||||||
|
+{colorSwatches.length - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import { generateProductSlug } from '../lib/slugify'
|
|||||||
import { useLang } from '../hooks/useLang'
|
import { useLang } from '../hooks/useLang'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
import WishlistButton from './WishlistButton'
|
import WishlistButton from './WishlistButton'
|
||||||
|
import ProductSwatchCard from './ProductSwatchCard'
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product
|
product: Product
|
||||||
|
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch'
|
||||||
|
badgeText?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product }: ProductCardProps) {
|
export default function ProductCard({ product, cardStyle = 'standard', badgeText }: ProductCardProps) {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const { addToCart } = useCartStore()
|
const { addToCart } = useCartStore()
|
||||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
|
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
|
||||||
@@ -79,6 +82,116 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
const optionsText = optionsLabels[lang] || optionsLabels.en
|
const optionsText = optionsLabels[lang] || optionsLabels.en
|
||||||
const selectOptionsText = selectOptionsLabels[lang] || selectOptionsLabels.en
|
const selectOptionsText = selectOptionsLabels[lang] || selectOptionsLabels.en
|
||||||
|
|
||||||
|
if (cardStyle === 'swatch' || cardStyle === 'minimal_swatch') {
|
||||||
|
return <ProductSwatchCard product={product} badgeText={badgeText} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardStyle === 'minimal') {
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col h-full select-none cursor-pointer">
|
||||||
|
{/* 3:4 Image Frame */}
|
||||||
|
<div className="relative aspect-[3/4] overflow-hidden rounded-lg bg-surface-page">
|
||||||
|
{hasMultipleImages ? (
|
||||||
|
<>
|
||||||
|
{/* Desktop Hover Effect */}
|
||||||
|
<Link href={`/products/${productSlug}`} className="hidden md:block absolute inset-0 w-full h-full">
|
||||||
|
<Image src={images[0]} alt={productName} fill unoptimized className="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:opacity-0" />
|
||||||
|
<Image src={images[1]} alt={productName} fill unoptimized className="absolute inset-0 h-full w-full object-cover transition duration-700 opacity-0 group-hover:opacity-100 group-hover:scale-105" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Mobile Native Slider */}
|
||||||
|
<div className="md:hidden relative w-full h-full">
|
||||||
|
<div className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<Link key={idx} href={`/products/${productSlug}`} className="relative w-full h-full flex-none snap-center snap-always block">
|
||||||
|
<Image src={img} alt={`${productName} ${idx + 1}`} fill unoptimized className="absolute inset-0 h-full w-full object-cover" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1.5 z-10 pointer-events-none">
|
||||||
|
{images.map((_, idx) => (
|
||||||
|
<div key={idx} className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-[0_1px_2px_rgba(0,0,0,0.3)]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link href={`/products/${productSlug}`} className="block h-full w-full">
|
||||||
|
<Image
|
||||||
|
src={images[0]}
|
||||||
|
alt={productName}
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{badgeText && (
|
||||||
|
<span className="absolute top-2.5 left-2.5 z-20 text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-full bg-black/70 text-white backdrop-blur-sm">
|
||||||
|
{badgeText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Info (Fashion Minimal / Pomelo Style) */}
|
||||||
|
<div className="pt-2 pb-1 flex flex-col gap-1">
|
||||||
|
{/* Row 1: Brand Name + Wishlist */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-text-main uppercase tracking-wider truncate">
|
||||||
|
{product.brand?.name || 'Brand'}
|
||||||
|
</span>
|
||||||
|
<div className="shrink-0 -mr-1">
|
||||||
|
<WishlistButton product={product} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Product Name */}
|
||||||
|
<Link href={`/products/${productSlug}`}>
|
||||||
|
<h3 className="line-clamp-1 text-xs md:text-sm font-normal text-text-main transition-colors group-hover:text-brand-primary">
|
||||||
|
{productName}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Row 3: Price + Status */}
|
||||||
|
<div className="flex items-center justify-between text-xs pt-0.5">
|
||||||
|
<span className="font-bold text-text-main md:text-sm">
|
||||||
|
{formatPrice(displayPrice)}
|
||||||
|
</span>
|
||||||
|
{totalStock > 0 ? (
|
||||||
|
<span className="text-[10px] text-muted-text">
|
||||||
|
มีสินค้า
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-rose-500 font-semibold">
|
||||||
|
สินค้าหมด
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Swatch Dots */}
|
||||||
|
{colorSwatches.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 pt-0.5 flex-wrap">
|
||||||
|
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => (
|
||||||
|
<span
|
||||||
|
key={variant.id || colorCode}
|
||||||
|
className="relative h-3 w-3 rounded-full border border-black/15 dark:border-white/20 shadow-sm shrink-0"
|
||||||
|
style={{ backgroundColor: colorCode }}
|
||||||
|
title={label || colorCode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{colorSwatches.length > 5 && (
|
||||||
|
<span className="text-[10px] font-semibold text-muted-text pl-0.5">
|
||||||
|
+{colorSwatches.length - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
||||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-surface-page">
|
<div className="relative aspect-square overflow-hidden rounded-xl bg-surface-page">
|
||||||
|
|||||||
@@ -91,34 +91,22 @@ export default function ProductSwatchCard({ product, badgeText }: ProductSwatchC
|
|||||||
{formatPrice(displayPrice)}
|
{formatPrice(displayPrice)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color Swatch Dots */}
|
{/* Color Swatch Dots (Display Only) */}
|
||||||
{colorSwatches.length > 0 && (
|
{colorSwatches.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 pt-1">
|
<div className="flex items-center gap-1.5 pt-1 flex-wrap">
|
||||||
{colorSwatches.map(({ colorCode, variant }) => {
|
{colorSwatches.slice(0, 5).map(({ colorCode, variant }) => (
|
||||||
const isActive = activeVariant?.colorCode === colorCode
|
<span
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={colorCode}
|
key={colorCode}
|
||||||
type="button"
|
className="relative w-3.5 h-3.5 rounded-full border border-black/15 dark:border-white/20 shadow-sm shrink-0"
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setActiveVariant(variant)
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (variant.imageUrl) {
|
|
||||||
setActiveVariant(variant)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`relative w-3.5 h-3.5 rounded-full border border-black/15 dark:border-white/20 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
|
||||||
isActive ? 'ring-2 ring-neutral-700 dark:ring-neutral-300 ring-offset-1 scale-110' : 'opacity-85 hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: colorCode }}
|
style={{ backgroundColor: colorCode }}
|
||||||
title={variant.option1 || variant.option2 || colorCode}
|
title={variant.option1 || variant.option2 || colorCode}
|
||||||
aria-label={`Color ${colorCode}`}
|
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
{colorSwatches.length > 5 && (
|
||||||
|
<span className="text-[10px] font-semibold text-neutral-500 dark:text-neutral-400 pl-0.5">
|
||||||
|
+{colorSwatches.length - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1132,11 +1132,49 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
|
|||||||
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' })
|
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' })
|
||||||
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' })
|
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' })
|
||||||
|
|
||||||
const isMinimal = cardStyle === 'minimal_swatch'
|
const isMinimal = cardStyle === 'minimal' || cardStyle === 'swatch' || cardStyle === 'minimal_swatch'
|
||||||
|
const containerWidth = typeof payload.containerWidth === 'string' ? payload.containerWidth : 'standard'
|
||||||
|
const cardSize = typeof payload.cardSize === 'string' ? payload.cardSize : 'medium'
|
||||||
|
|
||||||
|
const getContainerWidthClass = (width: string) => {
|
||||||
|
switch (width) {
|
||||||
|
case 'full':
|
||||||
|
return 'w-full max-w-none'
|
||||||
|
case 'wide':
|
||||||
|
return 'w-full max-w-[1600px] mx-auto'
|
||||||
|
case 'standard':
|
||||||
|
default:
|
||||||
|
return 'w-full max-w-7xl mx-auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCarouselCardWidth = () => {
|
||||||
|
switch (cardSize) {
|
||||||
|
case 'compact':
|
||||||
|
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start'
|
||||||
|
case 'large':
|
||||||
|
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start'
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGridColsClass = () => {
|
||||||
|
switch (cardSize) {
|
||||||
|
case 'compact':
|
||||||
|
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6'
|
||||||
|
case 'large':
|
||||||
|
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
|
<section className={`overflow-hidden bg-surface-page px-4 sm:px-6 md:px-8 ${getSectionPaddingClasses(payload)}`}>
|
||||||
<div className="ui-section-shell space-y-8">
|
<div className={`${getContainerWidthClass(containerWidth)} space-y-8`}>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-line-primary/10 pb-5">
|
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-line-primary/10 pb-5">
|
||||||
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
||||||
<h2 className={isMinimal ? "text-2xl md:text-3xl font-serif text-text-main tracking-tight" : "ui-section-title"}>
|
<h2 className={isMinimal ? "text-2xl md:text-3xl font-serif text-text-main tracking-tight" : "ui-section-title"}>
|
||||||
@@ -1168,17 +1206,9 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
|
|||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<div
|
<div
|
||||||
key={product.id}
|
key={product.id}
|
||||||
className={
|
className={getCarouselCardWidth()}
|
||||||
isMinimal
|
|
||||||
? "w-[42vw] sm:w-[200px] md:w-[220px] lg:w-[240px] flex-none snap-start"
|
|
||||||
: "w-[85vw] sm:w-[300px] flex-none snap-start"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isMinimal ? (
|
<ProductCard product={product} cardStyle={cardStyle as any} badgeText={badgeText} />
|
||||||
<ProductSwatchCard product={product} badgeText={badgeText} />
|
|
||||||
) : (
|
|
||||||
<ProductCard product={product} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1191,14 +1221,10 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={isMinimal ? "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6" : "grid grid-cols-1 gap-8 md:grid-cols-3"}>
|
<div className={getGridColsClass()}>
|
||||||
{products.map((product) =>
|
{products.map((product) => (
|
||||||
isMinimal ? (
|
<ProductCard key={product.id} product={product} cardStyle={cardStyle as any} badgeText={badgeText} />
|
||||||
<ProductSwatchCard key={product.id} product={product} badgeText={badgeText} />
|
))}
|
||||||
) : (
|
|
||||||
<ProductCard key={product.id} product={product} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -2595,7 +2621,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
|||||||
case 'PRODUCT_CAROUSEL':
|
case 'PRODUCT_CAROUSEL':
|
||||||
return (
|
return (
|
||||||
<ScrollReveal key={section.id}>
|
<ScrollReveal key={section.id}>
|
||||||
<ProductsSection payload={{ ...payload, layout: 'carousel' }} lang={lang} />
|
<ProductsSection payload={{ layout: 'carousel', ...payload }} lang={lang} />
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
)
|
)
|
||||||
case 'BANNER':
|
case 'BANNER':
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export interface StoreSettings {
|
|||||||
headerBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
headerBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
||||||
mobileHeaderStyle?: 'logo_center' | 'logo_left' | 'search_bar_below' | string | null
|
mobileHeaderStyle?: 'logo_center' | 'logo_left' | 'search_bar_below' | string | null
|
||||||
mobileHeaderBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
mobileHeaderBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
||||||
|
cardPreset?: 'standard' | 'minimal' | 'swatch' | string | null
|
||||||
defaultSeoTitle?: string | null
|
defaultSeoTitle?: string | null
|
||||||
defaultSeoDescription?: string | null
|
defaultSeoDescription?: string | null
|
||||||
defaultOgImage?: string | null
|
defaultOgImage?: string | null
|
||||||
@@ -680,6 +681,8 @@ export const ProductsSectionPayloadSchema = z.object({
|
|||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
layout: z.enum(['grid', 'carousel']).optional(),
|
layout: z.enum(['grid', 'carousel']).optional(),
|
||||||
enableQuickShop: z.boolean().optional(),
|
enableQuickShop: z.boolean().optional(),
|
||||||
|
containerWidth: z.enum(['standard', 'wide', 'full']).optional().default('standard'),
|
||||||
|
cardSize: z.enum(['compact', 'medium', 'large']).optional().default('medium'),
|
||||||
});
|
});
|
||||||
export type ProductsSectionPayload = z.infer<typeof ProductsSectionPayloadSchema>;
|
export type ProductsSectionPayload = z.infer<typeof ProductsSectionPayloadSchema>;
|
||||||
|
|
||||||
@@ -1035,7 +1038,10 @@ export const ProductCarouselSectionPayloadSchema = z.object({
|
|||||||
showPrice: z.boolean().optional(),
|
showPrice: z.boolean().optional(),
|
||||||
showAddToCart: z.boolean().optional(),
|
showAddToCart: z.boolean().optional(),
|
||||||
products: z.array(z.any()).optional(),
|
products: z.array(z.any()).optional(),
|
||||||
cardStyle: z.enum(['standard', 'minimal_swatch']).optional().default('standard'),
|
cardStyle: z.enum(['standard', 'minimal', 'minimal_swatch', 'swatch']).optional().default('standard'),
|
||||||
|
containerWidth: z.enum(['standard', 'wide', 'full']).optional().default('standard'),
|
||||||
|
cardSize: z.enum(['compact', 'medium', 'large']).optional().default('medium'),
|
||||||
|
layout: z.enum(['carousel', 'grid']).optional().default('carousel'),
|
||||||
viewAllText: LocalizedStringSchema.optional(),
|
viewAllText: LocalizedStringSchema.optional(),
|
||||||
viewAllLink: z.string().optional(),
|
viewAllLink: z.string().optional(),
|
||||||
badgeText: LocalizedStringSchema.optional(),
|
badgeText: LocalizedStringSchema.optional(),
|
||||||
|
|||||||
Reference in new issue
Block a user