diff --git a/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx b/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx index 991a624..73af690 100644 --- a/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx +++ b/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx @@ -7,6 +7,7 @@ import { Smartphone, Trash2, } from 'lucide-react' +import ContentPositionPicker from '../page-builder/ContentPositionPicker' type CategoryHeroCarouselBlockFormProps = { payload: Record @@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({ subtitle: {}, buttonText: {}, focalPoint: { x: '50%', y: '50%' }, + buttonPosition: 'bottom-left', + mobileButtonPosition: '', }) const getSlides = (payload: Record): SlidePayload[] => @@ -253,6 +256,17 @@ export default function CategoryHeroCarouselBlockForm({ /> + + {/* Slide Button & Content Position */} + updateSlide(slideIndex, { buttonPosition: pos })} + onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })} + title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)" + /> ) })} diff --git a/apps/admin/src/components/page-builder/BannerForm.tsx b/apps/admin/src/components/page-builder/BannerForm.tsx index dc5890a..bd6814c 100644 --- a/apps/admin/src/components/page-builder/BannerForm.tsx +++ b/apps/admin/src/components/page-builder/BannerForm.tsx @@ -1,6 +1,7 @@ import { UploadCloud, Image as ImageIcon } from 'lucide-react'; import { type PageSection } from '@homaroma/types'; import FocalPointPicker from './FocalPointPicker'; +import ContentPositionPicker from './ContentPositionPicker'; interface BannerFormProps { editingSection: Partial; @@ -97,6 +98,16 @@ export default function BannerForm({ className="admin-input w-full resize-none" /> + +
+ updatePayloadValue('buttonPosition', pos)} + onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)} + /> +
); } diff --git a/apps/admin/src/components/page-builder/ContentPositionPicker.tsx b/apps/admin/src/components/page-builder/ContentPositionPicker.tsx new file mode 100644 index 0000000..8ddbc28 --- /dev/null +++ b/apps/admin/src/components/page-builder/ContentPositionPicker.tsx @@ -0,0 +1,153 @@ +import { Monitor, Smartphone, RotateCcw } from 'lucide-react'; + +export 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 interface ContentPositionPickerProps { + desktopPosition?: string; + mobilePosition?: string; + defaultDesktop?: string; + onChangeDesktop: (position: string) => void; + onChangeMobile: (position: string | undefined) => void; + title?: string; + subtitle?: string; + compact?: boolean; +} + +export default function ContentPositionPicker({ + desktopPosition, + mobilePosition, + defaultDesktop = 'center', + onChangeDesktop, + onChangeMobile, + title = 'ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position)', + subtitle = 'สามารถแยกตำแหน่งระหว่าง Desktop (PC) และ Mobile (มือถือ) ได้อิสระ', + compact = false, +}: ContentPositionPickerProps) { + const effectiveDesktop = desktopPosition || defaultDesktop; + const currentMobilePos = mobilePosition || effectiveDesktop; + + return ( +
+
+
+ + {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ +
+ {/* Desktop (PC) */} +
+
+ + + Desktop (PC) + + + {effectiveDesktop} + +
+ +
+
+ {GRID_POSITIONS.map((pos) => { + const isSelected = effectiveDesktop === pos.id; + return ( + + ); + })} +
+

+ ตำแหน่งแสดงบนหน้าจอคอมพิวเตอร์ / จอกว้าง (≥ 768px) +

+
+
+ + {/* Mobile (Smartphone) */} +
+
+ + + Mobile (มือถือ) + +
+ {mobilePosition && ( + + )} + + {mobilePosition || `${effectiveDesktop} (ตาม PC)`} + +
+
+ +
+
+ {GRID_POSITIONS.map((pos) => { + const isSelected = currentMobilePos === pos.id; + const isExplicit = mobilePosition === pos.id; + return ( + + ); + })} +
+

+ ตำแหน่งแสดงบนโทรศัพท์มือถือ (หากไม่เลือก จะใช้ตาม Desktop) +

+
+
+
+
+ ); +} diff --git a/apps/admin/src/components/page-builder/HeroCarouselForm.tsx b/apps/admin/src/components/page-builder/HeroCarouselForm.tsx index be1dbe5..f342d0b 100644 --- a/apps/admin/src/components/page-builder/HeroCarouselForm.tsx +++ b/apps/admin/src/components/page-builder/HeroCarouselForm.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Plus, Trash2, UploadCloud, Image as ImageIcon, Sliders } from 'lucide-react'; import { type PageSection } from '@homaroma/types'; +import ContentPositionPicker from './ContentPositionPicker'; interface HeroCarouselFormProps { editingSection: Partial; @@ -318,7 +319,7 @@ export default function HeroCarouselForm({ {/* Accessibility Alt & Optional Overlay Text */} -
+
+ +
+ + updateLocalizedSlideValue(idx, 'buttonText', e.target.value)} + placeholder="e.g. Shop Now" + className="admin-input w-full text-xs" + /> +
+ + {/* Slide Button & Content Position */} + updateSlidePayloadValue(idx, 'buttonPosition', pos)} + onChangeMobile={(pos) => updateSlidePayloadValue(idx, 'mobileButtonPosition', pos || '')} + title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)" + />
))} diff --git a/apps/admin/src/components/page-builder/HeroForm.tsx b/apps/admin/src/components/page-builder/HeroForm.tsx index 3320731..dfa2439 100644 --- a/apps/admin/src/components/page-builder/HeroForm.tsx +++ b/apps/admin/src/components/page-builder/HeroForm.tsx @@ -1,6 +1,7 @@ -import { UploadCloud, Image as ImageIcon, Monitor, Smartphone, RotateCcw, Layers, Sliders } from 'lucide-react'; +import { UploadCloud, Image as ImageIcon, Layers, Sliders } from 'lucide-react'; import { type PageSection } from '@homaroma/types'; import FocalPointPicker from './FocalPointPicker'; +import ContentPositionPicker from './ContentPositionPicker'; interface HeroFormProps { editingSection: Partial; @@ -12,18 +13,6 @@ 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: '↘' }, -]; - const OVERLAY_PRESETS = [ { label: 'None (0%)', value: 0 }, { label: 'Light (25%)', value: 25 }, @@ -237,116 +226,13 @@ export default function HeroForm({ {/* 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 อัตโนมัติ) -

-
-
-
+
+ updatePayloadValue('buttonPosition', pos)} + onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)} + />
); diff --git a/apps/admin/src/components/page-builder/HeroVideoForm.tsx b/apps/admin/src/components/page-builder/HeroVideoForm.tsx index b00eb5f..c33e17f 100644 --- a/apps/admin/src/components/page-builder/HeroVideoForm.tsx +++ b/apps/admin/src/components/page-builder/HeroVideoForm.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { UploadCloud, Image as ImageIcon, Video } from 'lucide-react'; import { type PageSection } from '@homaroma/types'; +import ContentPositionPicker from './ContentPositionPicker'; interface HeroVideoFormProps { editingSection: Partial; @@ -291,6 +292,14 @@ export default function HeroVideoForm({ + + {/* Button & Content Position (9-Box Grid for Desktop & Mobile) */} + updatePayloadValue('buttonPosition', pos)} + onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)} + /> ); } diff --git a/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx b/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx index 9e5fc67..3e028e6 100644 --- a/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx +++ b/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx @@ -7,6 +7,7 @@ import { Smartphone, Trash2, } from 'lucide-react' +import ContentPositionPicker from '../page-builder/ContentPositionPicker' type ProductHeroCarouselBlockFormProps = { payload: Record @@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({ subtitle: {}, buttonText: {}, focalPoint: { x: '50%', y: '50%' }, + buttonPosition: 'bottom-left', + mobileButtonPosition: '', }) const getSlides = (payload: Record): SlidePayload[] => @@ -253,6 +256,17 @@ export default function ProductHeroCarouselBlockForm({ /> + + {/* Slide Button & Content Position */} + updateSlide(slideIndex, { buttonPosition: pos })} + onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })} + title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)" + /> ) })} diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 8998c0f..10a8f18 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 for HeroSectionPayloadSchema with mobileButtonPosition support +// Trigger reload: resilient position schemas with empty string handling import { LocalizedStringSchema, HeroSectionPayloadSchema, diff --git a/apps/spa/src/components/DynamicBanner.tsx b/apps/spa/src/components/DynamicBanner.tsx index a1d7f6d..d4a46f5 100644 --- a/apps/spa/src/components/DynamicBanner.tsx +++ b/apps/spa/src/components/DynamicBanner.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'; import type { BannerSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getHeroPositionClasses } from './DynamicHero'; interface DynamicBannerProps { payload: BannerSectionPayload; @@ -17,71 +18,53 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) { const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? payload.focalPoint : undefined; const imageStyle = focalPoint ? { objectPosition: `${focalPoint.x} ${focalPoint.y}` } : undefined; + const desktopPosition = payload.buttonPosition || 'center-left'; + const mobilePosition = payload.mobileButtonPosition || desktopPosition; + const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition); + + const bannerInner = ( + <> +
+ {title +
+
+ +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} + {buttonText && ( +
+ + {buttonText} + +
+ )} +
+ + ); + return (
{linkUrl ? ( - -
- {title -
-
- -
- {title && ( -

- {title} -

- )} - {description && ( -

- {description} -

- )} - {buttonText && ( -
- - {buttonText} - -
- )} -
+ + {bannerInner} ) : ( -
-
- {title -
-
- -
- {title && ( -

- {title} -

- )} - {description && ( -

- {description} -

- )} - {buttonText && ( -
- - {buttonText} - -
- )} -
+
+ {bannerInner}
)}
diff --git a/apps/spa/src/components/DynamicHero.tsx b/apps/spa/src/components/DynamicHero.tsx index 8e4d805..290ab53 100644 --- a/apps/spa/src/components/DynamicHero.tsx +++ b/apps/spa/src/components/DynamicHero.tsx @@ -7,6 +7,138 @@ interface DynamicHeroProps { payload: HeroSectionPayload; } +// Map 9-box grid position to mobile flex classes +export const getHeroMobilePositionClasses = (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 +export const getHeroDesktopPositionClasses = (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' + }; + } +}; + +export function getHeroPositionClasses(mobilePos: string, desktopPos: string) { + const mClasses = getHeroMobilePositionClasses(mobilePos); + const dClasses = getHeroDesktopPositionClasses(desktopPos); + return { + wrapperClasses: `${mClasses.wrapper} ${dClasses.wrapper}`, + contentClasses: `${mClasses.content} ${dClasses.content}`, + actionsClasses: `${mClasses.actions} ${dClasses.actions}`, + }; +} + export default function DynamicHero({ payload }: DynamicHeroProps) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -53,134 +185,7 @@ 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}`; + const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition); const overlayStyle = (payload as any).overlayStyle || 'gradient'; const overlayOpacity = (payload as any).overlayOpacity !== undefined ? (payload as any).overlayOpacity : 45; @@ -245,7 +250,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) { )} {description && ( -

+

{description}

)} diff --git a/apps/spa/src/components/DynamicHeroCarousel.tsx b/apps/spa/src/components/DynamicHeroCarousel.tsx index 9cfd69a..3a99fe2 100644 --- a/apps/spa/src/components/DynamicHeroCarousel.tsx +++ b/apps/spa/src/components/DynamicHeroCarousel.tsx @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'; import type { HeroCarouselSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getHeroPositionClasses } from './DynamicHero'; // Import Swiper styles import 'swiper/css'; @@ -115,27 +116,35 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp /> {/* Text Overlay */} - {(slide.title || slide.subtitle || slide.buttonText) && ( -
-
- {slide.subtitle && ( -

- {slide.subtitle} -

- )} - {slide.title && ( -

- {renderTitle(slide.title)} -

- )} - {slide.buttonText && ( - - {slide.buttonText} - - )} + {(slide.title || slide.subtitle || slide.buttonText) && (() => { + const slideDesktopPos = (slide as any).buttonPosition || 'bottom-left'; + const slideMobilePos = (slide as any).mobileButtonPosition || slideDesktopPos; + const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(slideMobilePos, slideDesktopPos); + + return ( +
+
+ {slide.subtitle && ( +

+ {slide.subtitle} +

+ )} + {slide.title && ( +

+ {renderTitle(slide.title)} +

+ )} + {slide.buttonText && ( +
+ + {slide.buttonText} + +
+ )} +
-
- )} + ); + })()}
); diff --git a/apps/spa/src/components/DynamicHeroVideo.tsx b/apps/spa/src/components/DynamicHeroVideo.tsx index 93ee14d..0852044 100644 --- a/apps/spa/src/components/DynamicHeroVideo.tsx +++ b/apps/spa/src/components/DynamicHeroVideo.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'; import type { HeroVideoSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; +import { getHeroPositionClasses } from './DynamicHero'; interface DynamicHeroVideoProps { payload: HeroVideoSectionPayload; @@ -97,46 +98,56 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) { > {/* Overlay Typography Content */} -
- {tagline && ( -

- {tagline} -

- )} + {(() => { + const desktopPosition = (payload as any).buttonPosition || 'center'; + const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition; + const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition); -

- {renderTitle(title) || renderTitle(t('hero.defaultTitle'))} -

+ return ( +
+
+ {tagline && ( +

+ {tagline} +

+ )} - {description && ( -

- {description} -

- )} +

+ {renderTitle(title) || renderTitle(t('hero.defaultTitle'))} +

- {/* Dual Action Buttons */} - {(buttonText || secondaryButtonText) && ( -
- {buttonText && payload.buttonLink && ( - - {buttonText} - - )} + {description && ( +

+ {description} +

+ )} - {secondaryButtonText && payload.secondaryButtonLink && ( - - {secondaryButtonText} - - )} + {/* Dual Action Buttons */} + {(buttonText || secondaryButtonText) && ( +
+ {buttonText && payload.buttonLink && ( + + {buttonText} + + )} + + {secondaryButtonText && payload.secondaryButtonLink && ( + + {secondaryButtonText} + + )} +
+ )} +
- )} -
+ ); + })()} ); } diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx index eac1960..746d10e 100644 --- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx +++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx @@ -97,6 +97,136 @@ function AnnouncementBar({ payload, lang }: { payload: Record, ) } +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' + } + } +} + +function getHeroPositionClasses(mobilePos: string, desktopPos: string) { + const mClasses = getMobilePositionClasses(mobilePos) + const dClasses = getDesktopPositionClasses(desktopPos) + return { + wrapperClasses: `${mClasses.wrapper} ${dClasses.wrapper}`, + contentClasses: `${mClasses.content} ${dClasses.content}`, + actionsClasses: `${mClasses.actions} ${dClasses.actions}`, + } +} + function HeroSection({ payload, lang }: { payload: Record, lang: string }) { const { t } = useTranslation() const i18nLang = useLang() @@ -125,132 +255,7 @@ function HeroSection({ payload, lang }: { payload: Record, lang ? 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}` + const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition) const overlayStyle = typeof payload.overlayStyle === 'string' ? payload.overlayStyle : 'gradient' const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 45 @@ -313,7 +318,7 @@ function HeroSection({ payload, lang }: { payload: Record, lang )} {description && ( -

+

{description}

)} @@ -430,19 +435,26 @@ function HeroCarouselSection({ payload, lang }: { payload: Record {/* Optional Text Overlay */} - {(title || subtitle || buttonText) && ( -
-
- {subtitle &&

{subtitle}

} - {title &&

{renderAccentTitle(title)}

} - {buttonText && ( - - {buttonText} - - )} + {(title || subtitle || buttonText) && (() => { + const slideDesktopPos = typeof slide.buttonPosition === 'string' ? slide.buttonPosition : 'bottom-left' + const slideMobilePos = typeof slide.mobileButtonPosition === 'string' ? slide.mobileButtonPosition : slideDesktopPos + const { wrapperClasses: sWrap, contentClasses: sContent, actionsClasses: sActions } = getHeroPositionClasses(slideMobilePos, slideDesktopPos) + return ( +
+
+ {subtitle &&

{subtitle}

} + {title &&

{renderAccentTitle(title)}

} + {buttonText && ( +
+ + {buttonText} + +
+ )} +
-
- )} + ) + })()}
) @@ -491,8 +503,12 @@ function HeroVideoSection({ payload, lang }: { payload: Record, } } + const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center' + const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition + const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition) + return ( -
+
{videoUrl ? (
) @@ -1103,8 +1121,12 @@ function BannerSection({ payload, lang }: { payload: Record, la const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : '' const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined + const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center-left' + const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition + const { wrapperClasses: bWrap, contentClasses: bContent, actionsClasses: bActions } = getHeroPositionClasses(mobilePosition, desktopPosition) + const content = ( -
+
{title, la style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }} />
-
+
{title &&

{title}

} {description &&

{description}

} - {buttonText && {buttonText}} + {buttonText && ( +
+ {buttonText} +
+ )}
) diff --git a/packages/types/index.ts b/packages/types/index.ts index 00ccf29..35a1022 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -599,6 +599,24 @@ export interface CategorySection { export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]); +export const ContentPositionSchema = z.enum([ + 'top-left', 'top-center', 'top-right', + 'center-left', 'center', 'center-right', + 'bottom-left', 'bottom-center', 'bottom-right' +]); +export type ContentPosition = z.infer; + +export const OptionalContentPositionSchema = z + .union([ContentPositionSchema, z.literal(''), z.null()]) + .optional() + .transform((val) => (val ? (val as ContentPosition) : undefined)); + +export const DesktopContentPositionSchema = (fallback: ContentPosition) => + z + .union([ContentPositionSchema, z.literal(''), z.null()]) + .optional() + .transform((val) => (val ? (val as ContentPosition) : fallback)); + export const HeroSectionPayloadSchema = z.object({ tagline: LocalizedStringSchema.optional(), title: LocalizedStringSchema.optional(), @@ -607,16 +625,8 @@ export const HeroSectionPayloadSchema = z.object({ 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(), + buttonPosition: DesktopContentPositionSchema('center'), + mobileButtonPosition: OptionalContentPositionSchema, overlayStyle: z.enum(['gradient', 'solid', 'none']).optional().default('gradient'), overlayOpacity: z.number().min(0).max(100).optional(), }); @@ -649,6 +659,8 @@ export const BannerSectionPayloadSchema = z.object({ description: LocalizedStringSchema.optional(), buttonText: LocalizedStringSchema.optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), + buttonPosition: DesktopContentPositionSchema('center-left'), + mobileButtonPosition: OptionalContentPositionSchema, }); export type BannerSectionPayload = z.infer; @@ -725,6 +737,8 @@ export const HeroVideoSectionPayloadSchema = z.object({ backgroundColor: z.string().optional(), textColor: z.string().optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), + buttonPosition: DesktopContentPositionSchema('center'), + mobileButtonPosition: OptionalContentPositionSchema, }); export type HeroVideoSectionPayload = z.infer; @@ -754,6 +768,8 @@ export const HeroCarouselSlideSchema = z.object({ subtitle: LocalizedStringSchema.optional(), buttonText: LocalizedStringSchema.optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), + buttonPosition: DesktopContentPositionSchema('bottom-left'), + mobileButtonPosition: OptionalContentPositionSchema, }); export type HeroCarouselSlide = z.infer;