diff --git a/apps/admin/src/components/page-builder/HeroForm.tsx b/apps/admin/src/components/page-builder/HeroForm.tsx
index 85cc5e9..bec3e6a 100644
--- a/apps/admin/src/components/page-builder/HeroForm.tsx
+++ b/apps/admin/src/components/page-builder/HeroForm.tsx
@@ -1,4 +1,4 @@
-import { UploadCloud, Image as ImageIcon } from 'lucide-react';
+import { UploadCloud, Image as ImageIcon, Monitor, Smartphone, RotateCcw } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker';
@@ -12,6 +12,18 @@ interface HeroFormProps {
setIsMediaModalOpen: (open: boolean) => void;
}
+const GRID_POSITIONS = [
+ { id: 'top-left', label: 'Top Left (บนซ้าย)', short: 'TL', icon: '↖' },
+ { id: 'top-center', label: 'Top Center (บนกลาง)', short: 'TC', icon: '↑' },
+ { id: 'top-right', label: 'Top Right (บนขวา)', short: 'TR', icon: '↗' },
+ { id: 'center-left', label: 'Center Left (กลางซ้าย)', short: 'CL', icon: '←' },
+ { id: 'center', label: 'Center (กึ่งกลาง)', short: 'C', icon: '•' },
+ { id: 'center-right', label: 'Center Right (กลางขวา)', short: 'CR', icon: '→' },
+ { id: 'bottom-left', label: 'Bottom Left (ล่างซ้าย)', short: 'BL', icon: '↙' },
+ { id: 'bottom-center', label: 'Bottom Center (ล่างกลาง)', short: 'BC', icon: '↓' },
+ { id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' },
+];
+
export default function HeroForm({
editingSection,
getLocalizedValue,
@@ -59,35 +71,35 @@ export default function HeroForm({
)}
-
+
updateLocalizedValue('tagline', e.target.value)}
+ placeholder="เว้นว่างได้หากไม่ต้องการแสดง"
className="admin-input w-full"
/>
-
+
updateLocalizedValue('title', e.target.value)}
+ placeholder="เว้นว่างได้หากไม่ต้องการแสดง"
className="admin-input w-full"
/>
Use [brackets] for golden accent serif text (e.g. Inhale [Serenity]).
-
+
@@ -96,9 +108,9 @@ export default function HeroForm({
updateLocalizedValue('buttonText', e.target.value)}
+ placeholder="เช่น Explore Collection"
className="admin-input w-full"
/>
@@ -107,12 +119,125 @@ export default function HeroForm({
updatePayloadValue('buttonLink', e.target.value)}
+ placeholder="เช่น /shop"
className="admin-input w-full"
/>
+
+ {/* 9-Box Grid Button Position Selector (Desktop & Mobile) */}
+
+
+
+
+
+ สามารถแยกตำแหน่งระหว่าง Desktop (PC) และ Mobile (มือถือ) ได้อิสระ
+
+
+
+
+
+ {/* Desktop (PC) */}
+
+
+
+
+ Desktop (PC)
+
+
+ {editingSection.payload?.buttonPosition || 'center'}
+
+
+
+
+
+ {GRID_POSITIONS.map((pos) => {
+ const isSelected = (editingSection.payload?.buttonPosition || 'center') === pos.id;
+ return (
+
+ );
+ })}
+
+
+ ตำแหน่งที่จะแสดงเมื่อเปิดบนหน้าจอคอมพิวเตอร์ / จอกว้าง (ขนาด ≥ 768px)
+
+
+
+
+ {/* Mobile (Smartphone) */}
+
+
+
+
+ Mobile (มือถือ)
+
+
+ {editingSection.payload?.mobileButtonPosition && (
+
+ )}
+
+ {editingSection.payload?.mobileButtonPosition || `${editingSection.payload?.buttonPosition || 'center'} (ตาม PC)`}
+
+
+
+
+
+
+ {GRID_POSITIONS.map((pos) => {
+ const currentMobilePos = editingSection.payload?.mobileButtonPosition || editingSection.payload?.buttonPosition || 'center';
+ const isSelected = currentMobilePos === pos.id;
+ const isExplicit = editingSection.payload?.mobileButtonPosition === pos.id;
+ return (
+
+ );
+ })}
+
+
+ ตำแหน่งที่จะแสดงเมื่อเปิดบนโทรศัพท์มือถือ (หากไม่เลือก จะใช้ตาม Desktop อัตโนมัติ)
+
+
+
+
+
);
}
diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts
index e793764..2816273 100644
--- a/apps/backend/src/routes/pages.ts
+++ b/apps/backend/src/routes/pages.ts
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
+// Trigger reload for HeroSectionPayloadSchema with mobileButtonPosition support
import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
diff --git a/apps/spa/src/components/DynamicHero.tsx b/apps/spa/src/components/DynamicHero.tsx
index 641604b..ead9679 100644
--- a/apps/spa/src/components/DynamicHero.tsx
+++ b/apps/spa/src/components/DynamicHero.tsx
@@ -11,12 +11,30 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { imageUrl, buttonLink } = payload;
- const tagline = getLocalizedField(payload.tagline, lang);
- const title = getLocalizedField(payload.title, lang);
- const description = getLocalizedField(payload.description, lang);
- const buttonText = getLocalizedField(payload.buttonText, lang);
+ const desktopPosition = (payload as any).buttonPosition || 'center';
+ const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition;
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined;
+ // Localized values
+ const rawTagline = getLocalizedField(payload.tagline, lang);
+ const rawTitle = getLocalizedField(payload.title, lang);
+ const rawDescription = getLocalizedField(payload.description, lang);
+ const rawButtonText = getLocalizedField(payload.buttonText, lang);
+
+ // If fields are explicitly defined as empty strings, respect them as empty
+ const hasTagline = payload.tagline !== undefined && payload.tagline !== null ? !!rawTagline?.trim() : true;
+ const tagline = hasTagline ? (rawTagline || t('hero.defaultTagline')) : '';
+
+ const hasTitle = payload.title !== undefined && payload.title !== null ? !!rawTitle?.trim() : true;
+ const title = hasTitle ? (rawTitle || t('hero.defaultTitle')) : '';
+
+ const hasDescription = payload.description !== undefined && payload.description !== null ? !!rawDescription?.trim() : false;
+ const description = hasDescription ? rawDescription : '';
+
+ const buttonText = rawButtonText !== undefined && rawButtonText !== null
+ ? rawButtonText.trim()
+ : t('hero.exploreCollection');
+
const renderTitle = (text: string) => {
if (!text) return null;
return text.split('[').map((part, i) => {
@@ -35,8 +53,137 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
});
};
+ // Map 9-box grid position to mobile flex classes
+ const getMobilePositionClasses = (pos: string) => {
+ switch (pos) {
+ case 'top-left':
+ return {
+ wrapper: 'justify-start items-start text-left pt-20 pb-12 pl-6 pr-6',
+ content: 'items-start text-left',
+ actions: 'items-start justify-start'
+ };
+ case 'top-center':
+ return {
+ wrapper: 'justify-start items-center text-center pt-20 pb-12 px-6',
+ content: 'items-center text-center',
+ actions: 'items-center justify-center'
+ };
+ case 'top-right':
+ return {
+ wrapper: 'justify-start items-end text-right pt-20 pb-12 pr-6 pl-6',
+ content: 'items-end text-right',
+ actions: 'items-end justify-end'
+ };
+ case 'center-left':
+ return {
+ wrapper: 'justify-center items-start text-left py-16 pl-6 pr-6',
+ content: 'items-start text-left',
+ actions: 'items-start justify-start'
+ };
+ case 'center-right':
+ return {
+ wrapper: 'justify-center items-end text-right py-16 pr-6 pl-6',
+ content: 'items-end text-right',
+ actions: 'items-end justify-end'
+ };
+ case 'bottom-left':
+ return {
+ wrapper: 'justify-end items-start text-left pt-12 pb-16 pl-6 pr-6',
+ content: 'items-start text-left',
+ actions: 'items-start justify-start'
+ };
+ case 'bottom-center':
+ return {
+ wrapper: 'justify-end items-center text-center pt-12 pb-16 px-6',
+ content: 'items-center text-center',
+ actions: 'items-center justify-center'
+ };
+ case 'bottom-right':
+ return {
+ wrapper: 'justify-end items-end text-right pt-12 pb-16 pr-6 pl-6',
+ content: 'items-end text-right',
+ actions: 'items-end justify-end'
+ };
+ case 'center':
+ default:
+ return {
+ wrapper: 'justify-center items-center text-center py-16 px-6',
+ content: 'items-center text-center',
+ actions: 'items-center justify-center'
+ };
+ }
+ };
+
+ // Map 9-box grid position to desktop (md:) flex classes
+ const getDesktopPositionClasses = (pos: string) => {
+ switch (pos) {
+ case 'top-left':
+ return {
+ wrapper: 'md:justify-start md:items-start md:text-left md:pt-28 md:pb-12 md:pl-16 md:pr-6',
+ content: 'md:items-start md:text-left',
+ actions: 'md:items-start md:justify-start'
+ };
+ case 'top-center':
+ return {
+ wrapper: 'md:justify-start md:items-center md:text-center md:pt-28 md:pb-12 md:px-6',
+ content: 'md:items-center md:text-center',
+ actions: 'md:items-center md:justify-center'
+ };
+ case 'top-right':
+ return {
+ wrapper: 'md:justify-start md:items-end md:text-right md:pt-28 md:pb-12 md:pr-16 md:pl-6',
+ content: 'md:items-end md:text-right',
+ actions: 'md:items-end md:justify-end'
+ };
+ case 'center-left':
+ return {
+ wrapper: 'md:justify-center md:items-start md:text-left md:py-16 md:pl-16 md:pr-6',
+ content: 'md:items-start md:text-left',
+ actions: 'md:items-start md:justify-start'
+ };
+ case 'center-right':
+ return {
+ wrapper: 'md:justify-center md:items-end md:text-right md:py-16 md:pr-16 md:pl-6',
+ content: 'md:items-end md:text-right',
+ actions: 'md:items-end md:justify-end'
+ };
+ case 'bottom-left':
+ return {
+ wrapper: 'md:justify-end md:items-start md:text-left md:pt-12 md:pb-24 md:pl-16 md:pr-6',
+ content: 'md:items-start md:text-left',
+ actions: 'md:items-start md:justify-start'
+ };
+ case 'bottom-center':
+ return {
+ wrapper: 'md:justify-end md:items-center md:text-center md:pt-12 md:pb-24 md:px-6',
+ content: 'md:items-center md:text-center',
+ actions: 'md:items-center md:justify-center'
+ };
+ case 'bottom-right':
+ return {
+ wrapper: 'md:justify-end md:items-end md:text-right md:pt-12 md:pb-24 md:pr-16 md:pl-6',
+ content: 'md:items-end md:text-right',
+ actions: 'md:items-end md:justify-end'
+ };
+ case 'center':
+ default:
+ return {
+ wrapper: 'md:justify-center md:items-center md:text-center md:py-16 md:px-6',
+ content: 'md:items-center md:text-center',
+ actions: 'md:items-center md:justify-center'
+ };
+ }
+ };
+
+ const mClasses = getMobilePositionClasses(mobilePosition);
+ const dClasses = getDesktopPositionClasses(desktopPosition);
+
+ const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`;
+ const contentClasses = `${mClasses.content} ${dClasses.content}`;
+ const actionsClasses = `${mClasses.actions} ${dClasses.actions}`;
+
return (
-
+
-
-
- {tagline || t('hero.defaultTagline')}
-
-
- {renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
-
- {description && (
-
- {description}
-
- )}
-
-
- {buttonText || t('hero.exploreCollection')}
-
-
- {t('hero.learnMore')}
- →
-
+
+
+ {tagline && (
+
+ {tagline}
+
+ )}
+ {title && (
+
+ {renderTitle(title)}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
index 8049be3..e400e00 100644
--- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
+++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
@@ -100,16 +100,159 @@ function HeroSection({ payload, lang }: { payload: Record
, lang
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
- const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
- const title = localizedFromRecord(payload, 'title', resolvedLang)
- const description = localizedFromRecord(payload, 'description', resolvedLang)
- const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
+ const rawTagline = localizedFromRecord(payload, 'tagline', resolvedLang)
+ const rawTitle = localizedFromRecord(payload, 'title', resolvedLang)
+ const rawDescription = localizedFromRecord(payload, 'description', resolvedLang)
+ const rawButtonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
+ const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center'
+ const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
+ // If fields are explicitly defined as empty strings, respect them as empty
+ const hasTagline = payload.tagline !== undefined && payload.tagline !== null ? !!rawTagline?.trim() : true
+ const tagline = hasTagline ? (rawTagline || 'VIBRANT ELEGANCE') : ''
+
+ const hasTitle = payload.title !== undefined && payload.title !== null ? !!rawTitle?.trim() : true
+ const title = hasTitle ? (rawTitle || 'New Scent [Sanctuary]') : ''
+
+ const hasDescription = payload.description !== undefined && payload.description !== null ? !!rawDescription?.trim() : false
+ const description = hasDescription ? rawDescription : ''
+
+ const buttonText = rawButtonText !== undefined && rawButtonText !== null
+ ? rawButtonText.trim()
+ : 'Explore Collection'
+
+ const getMobilePositionClasses = (pos: string) => {
+ switch (pos) {
+ case 'top-left':
+ return {
+ wrapper: 'justify-start items-start text-left pt-20 pb-12 pl-6 pr-6',
+ content: 'items-start text-left',
+ actions: 'items-start justify-start'
+ }
+ case 'top-center':
+ return {
+ wrapper: 'justify-start items-center text-center pt-20 pb-12 px-6',
+ content: 'items-center text-center',
+ actions: 'items-center justify-center'
+ }
+ case 'top-right':
+ return {
+ wrapper: 'justify-start items-end text-right pt-20 pb-12 pr-6 pl-6',
+ content: 'items-end text-right',
+ actions: 'items-end justify-end'
+ }
+ case 'center-left':
+ return {
+ wrapper: 'justify-center items-start text-left py-16 pl-6 pr-6',
+ content: 'items-start text-left',
+ actions: 'items-start justify-start'
+ }
+ case 'center-right':
+ return {
+ wrapper: 'justify-center items-end text-right py-16 pr-6 pl-6',
+ content: 'items-end text-right',
+ actions: 'items-end justify-end'
+ }
+ case 'bottom-left':
+ return {
+ wrapper: 'justify-end items-start text-left pt-12 pb-16 pl-6 pr-6',
+ content: 'items-start text-left',
+ actions: 'items-start justify-start'
+ }
+ case 'bottom-center':
+ return {
+ wrapper: 'justify-end items-center text-center pt-12 pb-16 px-6',
+ content: 'items-center text-center',
+ actions: 'items-center justify-center'
+ }
+ case 'bottom-right':
+ return {
+ wrapper: 'justify-end items-end text-right pt-12 pb-16 pr-6 pl-6',
+ content: 'items-end text-right',
+ actions: 'items-end justify-end'
+ }
+ case 'center':
+ default:
+ return {
+ wrapper: 'justify-center items-center text-center py-16 px-6',
+ content: 'items-center text-center',
+ actions: 'items-center justify-center'
+ }
+ }
+ }
+
+ const getDesktopPositionClasses = (pos: string) => {
+ switch (pos) {
+ case 'top-left':
+ return {
+ wrapper: 'md:justify-start md:items-start md:text-left md:pt-32 md:pb-12 md:pl-20 md:pr-8',
+ content: 'md:items-start md:text-left',
+ actions: 'md:items-start md:justify-start'
+ }
+ case 'top-center':
+ return {
+ wrapper: 'md:justify-start md:items-center md:text-center md:pt-32 md:pb-12 md:px-8',
+ content: 'md:items-center md:text-center',
+ actions: 'md:items-center md:justify-center'
+ }
+ case 'top-right':
+ return {
+ wrapper: 'md:justify-start md:items-end md:text-right md:pt-32 md:pb-12 md:pr-20 md:pl-8',
+ content: 'md:items-end md:text-right',
+ actions: 'md:items-end md:justify-end'
+ }
+ case 'center-left':
+ return {
+ wrapper: 'md:justify-center md:items-start md:text-left md:py-16 md:pl-20 md:pr-8',
+ content: 'md:items-start md:text-left',
+ actions: 'md:items-start md:justify-start'
+ }
+ case 'center-right':
+ return {
+ wrapper: 'md:justify-center md:items-end md:text-right md:py-16 md:pr-20 md:pl-8',
+ content: 'md:items-end md:text-right',
+ actions: 'md:items-end md:justify-end'
+ }
+ case 'bottom-left':
+ return {
+ wrapper: 'md:justify-end md:items-start md:text-left md:pt-12 md:pb-24 md:pl-20 md:pr-8',
+ content: 'md:items-start md:text-left',
+ actions: 'md:items-start md:justify-start'
+ }
+ case 'bottom-center':
+ return {
+ wrapper: 'md:justify-end md:items-center md:text-center md:pt-12 md:pb-24 md:px-8',
+ content: 'md:items-center md:text-center',
+ actions: 'md:items-center md:justify-center'
+ }
+ case 'bottom-right':
+ return {
+ wrapper: 'md:justify-end md:items-end md:text-right md:pt-12 md:pb-24 md:pr-20 md:pl-8',
+ content: 'md:items-end md:text-right',
+ actions: 'md:items-end md:justify-end'
+ }
+ case 'center':
+ default:
+ return {
+ wrapper: 'md:justify-center md:items-center md:text-center md:py-16 md:px-8',
+ content: 'md:items-center md:text-center',
+ actions: 'md:items-center md:justify-center'
+ }
+ }
+ }
+
+ const mClasses = getMobilePositionClasses(mobilePosition)
+ const dClasses = getDesktopPositionClasses(desktopPosition)
+
+ const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`
+ const contentClasses = `${mClasses.content} ${dClasses.content}`
+ const actionsClasses = `${mClasses.actions} ${dClasses.actions}`
+
return (
-
+
-
-
+
+
{tagline && (
{tagline}
)}
-
- {renderAccentTitle(title || '', 'ui-accent-text font-medium')}
-
+ {title && (
+
+ {renderAccentTitle(title, 'ui-accent-text font-medium')}
+
+ )}
{description && (
-
+
{description}
)}
-
-
- {buttonText || 'Explore Collection'}
-
+
+ {buttonText && (
+
+ {buttonText}
+
+ )}
{t('dynamic.heroLearnMore', { lng: resolvedLang })}
→
diff --git a/packages/types/index.ts b/packages/types/index.ts
index c36fbaf..ceb3e3d 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -343,13 +343,23 @@ export interface ProductDetailSection {
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({
- tagline: LocalizedStringSchema,
- title: LocalizedStringSchema,
- description: LocalizedStringSchema,
+ tagline: LocalizedStringSchema.optional(),
+ title: LocalizedStringSchema.optional(),
+ description: LocalizedStringSchema.optional(),
imageUrl: z.string().min(1),
- buttonText: LocalizedStringSchema,
- buttonLink: z.string(),
+ buttonText: LocalizedStringSchema.optional(),
+ buttonLink: z.string().optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
+ buttonPosition: z.enum([
+ 'top-left', 'top-center', 'top-right',
+ 'center-left', 'center', 'center-right',
+ 'bottom-left', 'bottom-center', 'bottom-right'
+ ]).optional().default('center'),
+ mobileButtonPosition: z.enum([
+ 'top-left', 'top-center', 'top-right',
+ 'center-left', 'center', 'center-right',
+ 'bottom-left', 'bottom-center', 'bottom-right'
+ ]).optional(),
});
export type HeroSectionPayload = z.infer;