diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts
index 569cff1..ac7b1b4 100644
--- a/apps/backend/src/routes/pages.ts
+++ b/apps/backend/src/routes/pages.ts
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia";
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 {
LocalizedStringSchema,
HeroSectionPayloadSchema,
diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts
index 947836c..c9fdc98 100644
--- a/apps/backend/src/routes/settings.ts
+++ b/apps/backend/src/routes/settings.ts
@@ -188,6 +188,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
+ const cardPreset = (settings.themeTokens as any)?.cardPreset ?? 'standard';
return {
...settingsData,
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
@@ -195,6 +196,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
headerBehavior,
mobileHeaderBehavior,
mobileHeaderStyle,
+ cardPreset,
footerDescription: localizedDescription ?? settingsData.footerDescription,
defaultSeoTitle: localizedSeoTitle ?? settingsData.defaultSeoTitle,
defaultSeoDescription: localizedSeoDescription ?? settingsData.defaultSeoDescription,
@@ -369,7 +371,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
}, { beforeHandle: requireAdmin, body: t.Any() })
.put("/", async ({ body, set }: { body: any; set: any }) => {
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) {
set.status = 400;
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) {
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
}
+ if (cardPreset !== undefined) {
+ updatedTokens.cardPreset = cardPreset;
+ }
data.themeTokens = updatedTokens;
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 returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
+ const returnedCardPreset = (settings?.themeTokens as any)?.cardPreset ?? 'standard';
return {
...settings,
headerBehavior: returnedBehavior,
mobileHeaderBehavior: returnedMobileBehavior,
mobileHeaderStyle: returnedMobileStyle,
+ cardPreset: returnedCardPreset,
};
} catch (err) {
console.error("[Settings] Error:", err);
@@ -449,6 +456,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
headerBehavior: t.Optional(t.String()),
mobileHeaderBehavior: t.Optional(t.String()),
mobileHeaderStyle: t.Optional(t.String()),
+ cardPreset: t.Optional(t.String()),
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
defaultSeoDescription: 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 }) => {
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) {
set.status = 400;
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) {
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
}
+ if (cardPreset !== undefined) {
+ updatedTokens.cardPreset = cardPreset;
+ }
data.themeTokens = updatedTokens;
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 returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
+ const returnedCardPreset = (settings?.themeTokens as any)?.cardPreset ?? 'standard';
return {
...settings,
headerBehavior: returnedBehavior,
mobileHeaderBehavior: returnedMobileBehavior,
mobileHeaderStyle: returnedMobileStyle,
+ cardPreset: returnedCardPreset,
};
} catch (err) {
console.error("[Settings] Error:", err);
@@ -539,6 +552,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
headerBehavior: t.Optional(t.String()),
mobileHeaderBehavior: t.Optional(t.String()),
mobileHeaderStyle: t.Optional(t.String()),
+ cardPreset: t.Optional(t.String()),
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())),
diff --git a/apps/spa/src/components/DynamicProductCarousel.tsx b/apps/spa/src/components/DynamicProductCarousel.tsx
index 9d2d7ab..cf38837 100644
--- a/apps/spa/src/components/DynamicProductCarousel.tsx
+++ b/apps/spa/src/components/DynamicProductCarousel.tsx
@@ -19,18 +19,57 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
const title = getLocalizedField(p.title, lang);
const description = getLocalizedField(p.description, lang);
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 viewAllText = getLocalizedField(p.viewAllText, lang) || 'View All';
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
(null);
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
return (
-
-
+
+
{isMinimal ? (
@@ -55,6 +94,18 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
No products to display.
+ ) : layout === 'grid' ? (
+
+ {products.map((prod: any) => (
+
+ ))}
+
) : (
diff --git a/apps/spa/src/components/DynamicProducts.tsx b/apps/spa/src/components/DynamicProducts.tsx
index 47c216a..8f71395 100644
--- a/apps/spa/src/components/DynamicProducts.tsx
+++ b/apps/spa/src/components/DynamicProducts.tsx
@@ -20,13 +20,53 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
const subtitle = getLocalizedField(payload.subtitle, lang);
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
(null);
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
return (
-
-
+
+
@@ -60,7 +100,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
{products.map((p) => (
-
+
) : (
-
+
{products.map((p) => (
0
? product.images!.slice(0, 3).map(img => img.url)
: [fallbackImage];
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) => {
e.preventDefault();
@@ -83,6 +95,123 @@ export default function ProductCard({ product, enableQuickShop = false, category
navigate(`/products/${product.id}`);
};
+ if (cardStyle === 'swatch' || cardStyle === 'minimal_swatch') {
+ return ;
+ }
+
+ if (cardStyle === 'minimal') {
+ return (
+
+ {/* 3:4 Image Frame */}
+
+ {hasMultipleImages ? (
+ <>
+ {/* Desktop Hover Effect */}
+
+

+

+
+
+ {/* Mobile Native Slider */}
+
e.stopPropagation()}>
+
+ {images.map((img, idx) => (
+
+

+
+ ))}
+
+
+ {images.map((_, idx) => (
+
+ ))}
+
+
+ >
+ ) : (
+

{
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+ )}
+
+ {badgeText && (
+
+ {badgeText}
+
+ )}
+
+
+ {/* Content Info (Fashion Minimal / Pomelo Style) */}
+
+ {/* Row 1: Brand Name + Wishlist */}
+
+
+ {product.brand?.name || 'Brand'}
+
+
+
+
+ {/* Row 2: Product Name */}
+
+ {displayName}
+
+
+ {/* Row 3: Price + Status */}
+
+
+ {formatPrice(displayPrice)}
+
+ {totalStock > 0 ? (
+
+ {t('product.inStock', 'In Stock')}
+
+ ) : (
+
+ {t('product.outOfStock', 'Out of Stock')}
+
+ )}
+
+
+ {/* Row 4: Swatch Dots */}
+ {colorSwatches.length > 0 && (
+
+ {colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => (
+
+ ))}
+ {colorSwatches.length > 5 && (
+
+ +{colorSwatches.length - 5}
+
+ )}
+
+ )}
+
+
+ );
+ }
+
return (
- {/* Color Swatch Dots */}
+ {/* Color Swatch Dots (Display Only) */}
{colorSwatches.length > 0 && (
-
- {colorSwatches.map(({ colorCode, variant }) => {
- const isActive = activeVariant?.colorCode === colorCode;
- return (
-
diff --git a/apps/website/src/components/ProductCard.tsx b/apps/website/src/components/ProductCard.tsx
index 33f2be5..3cfb692 100644
--- a/apps/website/src/components/ProductCard.tsx
+++ b/apps/website/src/components/ProductCard.tsx
@@ -11,12 +11,15 @@ import { generateProductSlug } from '../lib/slugify'
import { useLang } from '../hooks/useLang'
import { useCartStore } from '../store/cartStore'
import WishlistButton from './WishlistButton'
+import ProductSwatchCard from './ProductSwatchCard'
interface ProductCardProps {
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 { addToCart } = useCartStore()
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 selectOptionsText = selectOptionsLabels[lang] || selectOptionsLabels.en
+ if (cardStyle === 'swatch' || cardStyle === 'minimal_swatch') {
+ return
+ }
+
+ if (cardStyle === 'minimal') {
+ return (
+
+ {/* 3:4 Image Frame */}
+
+ {hasMultipleImages ? (
+ <>
+ {/* Desktop Hover Effect */}
+
+
+
+
+
+ {/* Mobile Native Slider */}
+
+
+ {images.map((img, idx) => (
+
+
+
+ ))}
+
+
+ {images.map((_, idx) => (
+
+ ))}
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ {badgeText && (
+
+ {badgeText}
+
+ )}
+
+
+ {/* Content Info (Fashion Minimal / Pomelo Style) */}
+
+ {/* Row 1: Brand Name + Wishlist */}
+
+
+ {product.brand?.name || 'Brand'}
+
+
+
+
+
+
+ {/* Row 2: Product Name */}
+
+
+ {productName}
+
+
+
+ {/* Row 3: Price + Status */}
+
+
+ {formatPrice(displayPrice)}
+
+ {totalStock > 0 ? (
+
+ มีสินค้า
+
+ ) : (
+
+ สินค้าหมด
+
+ )}
+
+
+ {/* Row 4: Swatch Dots */}
+ {colorSwatches.length > 0 && (
+
+ {colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => (
+
+ ))}
+ {colorSwatches.length > 5 && (
+
+ +{colorSwatches.length - 5}
+
+ )}
+
+ )}
+
+
+ )
+ }
+
return (
diff --git a/apps/website/src/components/ProductSwatchCard.tsx b/apps/website/src/components/ProductSwatchCard.tsx
index bb4e331..5e12b83 100644
--- a/apps/website/src/components/ProductSwatchCard.tsx
+++ b/apps/website/src/components/ProductSwatchCard.tsx
@@ -91,34 +91,22 @@ export default function ProductSwatchCard({ product, badgeText }: ProductSwatchC
{formatPrice(displayPrice)}
- {/* Color Swatch Dots */}
+ {/* Color Swatch Dots (Display Only) */}
{colorSwatches.length > 0 && (
-
- {colorSwatches.map(({ colorCode, variant }) => {
- const isActive = activeVariant?.colorCode === colorCode
- return (
-
diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
index c5d20b5..fd4ac77 100644
--- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
+++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
@@ -1132,11 +1132,49 @@ function ProductsSection({ payload, lang }: { payload: Record
,
const scrollLeft = () => 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 (
-
-
+
+
@@ -1168,17 +1206,9 @@ function ProductsSection({ payload, lang }: { payload: Record,
{products.map((product) => (
- {isMinimal ? (
-
- ) : (
-
- )}
+
))}
@@ -1191,14 +1221,10 @@ function ProductsSection({ payload, lang }: { payload: Record
,
) : (
-
- {products.map((product) =>
- isMinimal ? (
-
- ) : (
-
- )
- )}
+
+ {products.map((product) => (
+
+ ))}
)
) : (
@@ -2595,7 +2621,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
case 'PRODUCT_CAROUSEL':
return (
-
+
)
case 'BANNER':
diff --git a/packages/types/index.ts b/packages/types/index.ts
index d259037..a270abd 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -407,6 +407,7 @@ export interface StoreSettings {
headerBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
mobileHeaderStyle?: 'logo_center' | 'logo_left' | 'search_bar_below' | string | null
mobileHeaderBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
+ cardPreset?: 'standard' | 'minimal' | 'swatch' | string | null
defaultSeoTitle?: string | null
defaultSeoDescription?: string | null
defaultOgImage?: string | null
@@ -680,6 +681,8 @@ export const ProductsSectionPayloadSchema = z.object({
limit: z.number().optional(),
layout: z.enum(['grid', 'carousel']).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
;
@@ -1035,7 +1038,10 @@ export const ProductCarouselSectionPayloadSchema = z.object({
showPrice: z.boolean().optional(),
showAddToCart: z.boolean().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(),
viewAllLink: z.string().optional(),
badgeText: LocalizedStringSchema.optional(),