diff --git a/apps/admin/src/components/CategoryBulkJsonModal.tsx b/apps/admin/src/components/CategoryBulkJsonModal.tsx index 687a1ad..63b1169 100644 --- a/apps/admin/src/components/CategoryBulkJsonModal.tsx +++ b/apps/admin/src/components/CategoryBulkJsonModal.tsx @@ -75,6 +75,7 @@ export default function CategoryBulkJsonModal({ id: cat.id, name: catBaseName || transMap['th'] || 'Unnamed Category', imageUrl: cat.imageUrl || null, + layoutType: cat.layoutType || 'split_showcase', translations: transMap, }; }); @@ -89,6 +90,7 @@ export default function CategoryBulkJsonModal({ const sampleItems = exportData.slice(0, 5); return `Please translate and optimize our E-commerce Category Names into all 8 supported languages (th, en, ja, zh, vi, ko, lo, it). Ensure the tone is elegant, accurate for luxury aromatherapy & lifestyle products. +Allowed layoutType values: "split_showcase" (default), "editorial_hero", "dense_catalog". Return ONLY a valid JSON Array conforming to this structure: \`\`\`json @@ -99,6 +101,7 @@ ${JSON.stringify( { name: 'น้ำมันหอมระเหย', imageUrl: '/images/categories/essential-oils.jpg', + layoutType: 'split_showcase', translations: { th: 'น้ำมันหอมระเหย', en: 'Essential Oils', @@ -113,6 +116,7 @@ ${JSON.stringify( { name: 'ก้านไม้หอมปรับอากาศ', imageUrl: '/images/categories/reed-diffusers.jpg', + layoutType: 'editorial_hero', translations: { th: 'ก้านไม้หอมปรับอากาศ', en: 'Reed Diffusers', @@ -165,6 +169,7 @@ ${JSON.stringify( id: typeof item.id === 'number' ? item.id : undefined, name: baseName, imageUrl: item.imageUrl || null, + layoutType: typeof item.layoutType === 'string' ? item.layoutType : 'split_showcase', translations: trans, }; }); diff --git a/apps/admin/src/pages/CategoryEditPage.tsx b/apps/admin/src/pages/CategoryEditPage.tsx index ad2a92a..9608af7 100644 --- a/apps/admin/src/pages/CategoryEditPage.tsx +++ b/apps/admin/src/pages/CategoryEditPage.tsx @@ -22,7 +22,10 @@ import { FileJson, Check, Code2, - Sparkles + Sparkles, + LayoutTemplate, + Columns, + Grid3X3 } from 'lucide-react' import MediaLibraryModal from '../components/MediaLibraryModal' @@ -41,7 +44,7 @@ const SAMPLE_JSON_TEMPLATE = JSON.stringify({ ja: "アロマキャンドル", zh: "芳香香薰蜡烛", vi: "Nến thơm thư giãn", - ko: "아로마 캔들", + ko: "아로มา 캔들", lo: "ທຽນຫອມອາໂຣມາ", it: "Candele aromatiche profumate" }, null, 2) @@ -76,6 +79,7 @@ export default function CategoryEditPage() { it: '', }) const [imageUrl, setImageUrl] = useState('') + const [layoutType, setLayoutType] = useState('split_showcase') const [categoryMeta, setCategoryMeta] = useState(null) // Media Library Modal @@ -91,6 +95,7 @@ export default function CategoryEditPage() { const data = await res.json() setCategoryMeta(data) setImageUrl(data.imageUrl || '') + setLayoutType(data.layoutType || 'split_showcase') if (data.name && typeof data.name === 'object') { setName(prev => ({ ...prev, ...data.name })) @@ -278,7 +283,8 @@ export default function CategoryEditPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: masterName, - imageUrl: imageUrl || null + imageUrl: imageUrl || null, + layoutType: layoutType || 'split_showcase' }) }) @@ -657,6 +663,152 @@ export default function CategoryEditPage() { )} + {/* Card: Category Layout Archetype */} +
+
+
+
+ +
+
+

Category Layout & Presentation

+

Choose how this category displays on Storefront & Website

+
+
+ + {layoutType} + +
+ +
+ {/* Option 1: Split Showcase */} +
setLayoutType('split_showcase')} + className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${ + layoutType === 'split_showcase' + ? 'bg-cyan-glow/10 border-cyan-glow shadow-lg shadow-cyan-glow/10' + : 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]' + }`} + > +
+ {/* Visual Layout Mockup */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

+ + Split Showcase +

+ {layoutType === 'split_showcase' && ( + + )} +
+

ค่าเริ่มต้น (Balanced)

+

+ หัวข้อแบ่ง 2 ฝั่ง (ข้อความซ้าย + รูปเด่นขวา) พร้อมการจัดแสดงสินค้า 3-4 คอลัมน์มาตรฐาน +

+
+
+
+ + {/* Option 2: Editorial Hero */} +
setLayoutType('editorial_hero')} + className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${ + layoutType === 'editorial_hero' + ? 'bg-amber-500/10 border-amber-400 shadow-lg shadow-amber-500/10' + : 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]' + }`} + > +
+ {/* Visual Layout Mockup */} +
+
+
+
+
+
+
+
+
+
+ +
+
+

+ + Editorial Hero +

+ {layoutType === 'editorial_hero' && ( + + )} +
+

Cinematic & Luxury

+

+ Hero Banner เต็มจอสไตล์นิตยสารหรูหรา พร้อมการ์ดสินค้าขนาดใหญ่ 2-3 คอลัมน์ +

+
+
+
+ + {/* Option 3: Dense Catalog */} +
setLayoutType('dense_catalog')} + className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${ + layoutType === 'dense_catalog' + ? 'bg-purple-500/10 border-purple-400 shadow-lg shadow-purple-500/10' + : 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]' + }`} + > +
+ {/* Visual Layout Mockup */} +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

+ + Dense Catalog +

+ {layoutType === 'dense_catalog' && ( + + )} +
+

High Density Shopper

+

+ Header ย่อกะทัดรัด พร้อมตารางสินค้าความหนาแน่นสูง 4-5 คอลัมน์ เหมาะสำหรับสินค้าจำนวนมาก +

+
+
+
+
+
+ {/* Card 3: Associated Products & Quick Link */} {!isNew && categoryMeta && (
diff --git a/apps/backend/prisma/migrations/20260929175500_add_category_layout_type/migration.sql b/apps/backend/prisma/migrations/20260929175500_add_category_layout_type/migration.sql new file mode 100644 index 0000000..5eac354 --- /dev/null +++ b/apps/backend/prisma/migrations/20260929175500_add_category_layout_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Category" ADD COLUMN IF NOT EXISTS "layoutType" TEXT NOT NULL DEFAULT 'split_showcase'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 42d15e6..3fa3ecd 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -34,6 +34,7 @@ model Category { id Int @id @default(autoincrement()) name String @unique imageUrl String? + layoutType String @default("split_showcase") products Product[] attributes AttributeDefinition[] translations CategoryTranslation[] diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts index fb862c5..3cd4898 100644 --- a/apps/backend/src/routes/categories.ts +++ b/apps/backend/src/routes/categories.ts @@ -71,6 +71,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) const updateData: any = {}; if (imageUrl !== undefined) updateData.imageUrl = imageUrl; if (item.name) updateData.name = item.name; + if (item.layoutType) updateData.layoutType = item.layoutType; if (Object.keys(updateData).length > 0) { category = await prisma.category.update({ @@ -85,6 +86,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) data: { name: baseName, imageUrl, + layoutType: item.layoutType || 'split_showcase', }, }); createdCount++; @@ -199,6 +201,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) id: category.id, name: resolveCatName(category, lang), imageUrl: category.imageUrl, + layoutType: category.layoutType || 'split_showcase', _count: category._count, }, data: category.products.map((p: any) => { @@ -318,6 +321,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) data: { name: body.name, imageUrl: body.imageUrl, + layoutType: body.layoutType || 'split_showcase', // Auto-seed Thai translation translations: { create: { langCode: 'th', name: body.name } }, }, @@ -333,6 +337,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) body: t.Object({ name: t.String(), imageUrl: t.Optional(t.Union([t.String(), t.Null()])), + layoutType: t.Optional(t.String()), }) }) .put("/:id", async ({ params, body, status }) => { @@ -340,12 +345,14 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) if (isNaN(id)) return status(400, { message: "Invalid category ID" }); try { + const updateData: any = {}; + if (body.name !== undefined) updateData.name = body.name; + if (body.imageUrl !== undefined) updateData.imageUrl = body.imageUrl; + if (body.layoutType !== undefined) updateData.layoutType = body.layoutType; + const category = await prisma.category.update({ where: { id, deletedAt: null }, - data: { - name: body.name, - imageUrl: body.imageUrl, - }, + data: updateData, }); return category; } catch (err: any) { @@ -362,6 +369,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) body: t.Object({ name: t.Optional(t.String()), imageUrl: t.Optional(t.Union([t.String(), t.Null()])), + layoutType: t.Optional(t.String()), }) }) .delete("/:id", async ({ params, status }) => { diff --git a/apps/spa/src/pages/CategoryPage.tsx b/apps/spa/src/pages/CategoryPage.tsx index 22cd114..7cce180 100644 --- a/apps/spa/src/pages/CategoryPage.tsx +++ b/apps/spa/src/pages/CategoryPage.tsx @@ -7,6 +7,7 @@ import ProductCard from '../components/ProductCard' import DynamicPageRenderer from '../components/DynamicPageRenderer' import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types' import { getLocalizedField } from '../utils/localization' +import { Sparkles, Layers, Grid3X3, ArrowRight } from 'lucide-react' export default function CategoryPage() { const { id } = useParams<{ id: string }>() @@ -101,6 +102,20 @@ export default function CategoryPage() { const categoryName = getLocalizedField(category?.name, lang) const categoryImageUrl = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : '') + const layoutType = category?.layoutType || 'split_showcase' + + // Determine Product Grid Class based on layout archetype + const getProductGridClass = () => { + switch (layoutType) { + case 'editorial_hero': + return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-10' + case 'dense_catalog': + return 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4' + case 'split_showcase': + default: + return 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6' + } + } return (
@@ -114,7 +129,7 @@ export default function CategoryPage() { - {/* Hero Banner for Category */} + {/* Hero / Banner Area */} {heroSections.length > 0 ? (
+ ) : layoutType === 'editorial_hero' ? ( + /* ── Archetype 2: Editorial Hero Layout ── */ +
+ {/* Immersive Background Image with Dark Gradient Overlays */} + {categoryImageUrl && ( +
+ {categoryName} +
+
+
+ )} + +
+ {/* Breadcrumb */} +
+ {t('nav.home', 'Home')} + / + {t('nav.shop', 'Shop')} + / + {categoryName || '...'} +
+ + {/* Exclusive Badge */} +
+ + Curated Aroma Collection +
+ + {/* Title */} +

+ {loading ? ( +
+ ) : ( + categoryName + )} +

+ + {/* Subtitle & Item Count */} + {!loading && category?._count !== undefined && ( +

+ Discover {category._count.products} handcrafted scents and artisanal essences designed to elevate your living spaces. +

+ )} +
+
+ ) : layoutType === 'dense_catalog' ? ( + /* ── Archetype 3: Dense Catalog Layout ── */ +
+
+
+
+ {t('nav.home', 'Home')} + / + {t('nav.shop', 'Shop')} + / + {categoryName || '...'} +
+ +
+

+ {loading ? ( +
+ ) : ( + categoryName + )} +

+ {!loading && category?._count !== undefined && ( + + {category._count.products} items + + )} +
+
+ +
+ + High-Density Catalog View +
+
+
) : ( + /* ── Archetype 1: Split Showcase Layout (Default) ── */
-
+
{/* Breadcrumb */} -
+
{t('nav.home', 'Home')} / {t('nav.shop', 'Shop')} @@ -136,29 +236,36 @@ export default function CategoryPage() { {categoryName || '...'}
-

- {loading ? ( -
- ) : ( - categoryName - )} -

+
+
+ + Category Showcase +
+

+ {loading ? ( +
+ ) : ( + categoryName + )} +

+
{!loading && category?._count !== undefined && ( -

- {category._count.products} {t('categories.itemCount', 'products available')} +

+ {category._count.products} {t('categories.itemCount', 'products available in this collection')}

)}
{categoryImageUrl && ( -
+
{categoryName} { (e.target as HTMLImageElement).style.display = 'none' }} /> +
)}
@@ -166,9 +273,9 @@ export default function CategoryPage() { )} {/* Products & Category Navigation */} -
- {/* Other Category Pills */} -
+
+ {/* Category Pills Navigation */} +
{categories.map((cat) => { const catItemName = getLocalizedField(cat.name, lang) const isActive = String(cat.id) === String(id) @@ -179,7 +286,9 @@ export default function CategoryPage() { to={`/category/${cat.id}`} className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all duration-200 border cursor-pointer ${ isActive - ? 'bg-cyan-glow text-night-950 border-cyan-glow shadow-md shadow-cyan-glow/20 font-black' + ? layoutType === 'editorial_hero' + ? 'bg-amber-400 text-night-950 border-amber-400 shadow-lg shadow-amber-500/20 font-black' + : 'bg-cyan-glow text-night-950 border-cyan-glow shadow-md shadow-cyan-glow/20 font-black' : 'bg-night-900/80 text-slate-400 border-white/10 hover:border-cyan-glow/40 hover:text-white' }`} > @@ -198,24 +307,25 @@ export default function CategoryPage() {
) : loading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( +
+ {Array.from({ length: layoutType === 'dense_catalog' ? 10 : 8 }).map((_, i) => (
))}
) : products.length === 0 ? ( -
+

📦

{t('category.noProducts', 'No products in this category yet.')}

- - ← {t('shop.exploreAllProducts', 'Explore All Products')} + + {t('shop.exploreAllProducts', 'Explore All Products')} +
) : (
-
+
{products.slice(0, displayCount).map((p) => ( ))} diff --git a/apps/website/src/app/[lang]/category/[id]/page.tsx b/apps/website/src/app/[lang]/category/[id]/page.tsx index 499d432..3ec2220 100644 --- a/apps/website/src/app/[lang]/category/[id]/page.tsx +++ b/apps/website/src/app/[lang]/category/[id]/page.tsx @@ -93,6 +93,21 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s const category = categoryProductsResult.data.category const products = categoryProductsResult.data.data ?? [] const categoryName = resolveLocalized(category.name, lang) + const categoryImageUrl = category.imageUrl || getCategoryImage(categoryName) + const layoutType = category.layoutType || 'split_showcase' + + // Determine Product Grid Class based on layout archetype + const getProductGridClass = () => { + switch (layoutType) { + case 'editorial_hero': + return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10' + case 'dense_catalog': + return 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 md:gap-6' + case 'split_showcase': + default: + return 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8' + } + } return (
@@ -101,23 +116,92 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s )} {heroSections.length > 0 ? ( - ) : ( -
-
-
-
+ ) : layoutType === 'editorial_hero' ? ( + /* ── Archetype 2: Editorial Hero Layout ── */ +
+ {categoryImageUrl && ( +
+ {categoryName} +
+
+ )} + +
+
+ Home + / + Shop + / + {categoryName} +
+ +
+ Curated Aroma Collection +
+ +

+ {categoryName} +

+ + {category._count && ( +

+ Discover {category._count.products} handcrafted scents and artisanal essences designed to elevate your living spaces. +

+ )} +
+
+ ) : layoutType === 'dense_catalog' ? ( + /* ── Archetype 3: Dense Catalog Layout ── */ +
+
+
+
Home / Shop / - {categoryName} + {categoryName} +
+
+

{categoryName}

+ {category._count && ( + + {category._count.products} items + + )} +
+
+
+ High-Density Catalog View +
+
+
+ ) : ( + /* ── Archetype 1: Split Showcase Layout (Default) ── */ +
+
+
+
+ Home + / + Shop + / + {categoryName} +
+
+

{categoryName}

+ {category._count &&

{category._count.products} products available in this collection

}
-

{categoryName}

- {category._count &&

{category._count.products} products available

}
{categoryName} -
+
{!categoriesResult.ok && ( Categories unavailable @@ -160,7 +244,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s Back to Shop
) : ( -
+
{products.map((product) => )}
)} diff --git a/packages/types/index.ts b/packages/types/index.ts index 8bf2bfd..57b5463 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -10,6 +10,7 @@ export interface Category { id: number name: TranslatableString imageUrl?: string | null + layoutType?: 'split_showcase' | 'editorial_hero' | 'dense_catalog' | string | null _count?: { products: number } translations?: { langCode: string; name: string }[] createdAt?: string