x
This commit is contained in:
1 parent
b641acbeb7
commit
e349ef5b84
14 files changed
+461
-299
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
Smartphone,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import ContentPositionPicker from '../page-builder/ContentPositionPicker'
|
||||
|
||||
type CategoryHeroCarouselBlockFormProps = {
|
||||
payload: Record<string, unknown>
|
||||
@@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({
|
||||
subtitle: {},
|
||||
buttonText: {},
|
||||
focalPoint: { x: '50%', y: '50%' },
|
||||
buttonPosition: 'bottom-left',
|
||||
mobileButtonPosition: '',
|
||||
})
|
||||
|
||||
const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
|
||||
@@ -253,6 +256,17 @@ export default function CategoryHeroCarouselBlockForm({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Slide Button & Content Position */}
|
||||
<ContentPositionPicker
|
||||
compact
|
||||
defaultDesktop="bottom-left"
|
||||
desktopPosition={getString(slide.buttonPosition, 'bottom-left')}
|
||||
mobilePosition={getString(slide.mobileButtonPosition) || undefined}
|
||||
onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })}
|
||||
onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })}
|
||||
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<PageSection>;
|
||||
@@ -97,6 +98,16 @@ export default function BannerForm({
|
||||
className="admin-input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<ContentPositionPicker
|
||||
defaultDesktop="center-left"
|
||||
desktopPosition={editingSection.payload?.buttonPosition || 'center-left'}
|
||||
mobilePosition={editingSection.payload?.mobileButtonPosition}
|
||||
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
|
||||
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`space-y-3 ${compact ? 'p-3 bg-night-950/60 rounded-xl border border-white/5' : 'pt-2'}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 border-b border-white/5 pb-2">
|
||||
<div>
|
||||
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-300">
|
||||
{title}
|
||||
</label>
|
||||
{subtitle && (
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-1 ${compact ? 'gap-3 sm:grid-cols-2' : 'lg:grid-cols-2 gap-4'}`}>
|
||||
{/* Desktop (PC) */}
|
||||
<div className="bg-night-950/40 border border-white/5 p-3.5 rounded-2xl space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||
<Monitor className="w-4 h-4 text-cyan-glow" />
|
||||
Desktop (PC)
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-md bg-cyan-glow/10 text-cyan-glow font-mono font-bold text-[10px] border border-cyan-glow/20">
|
||||
{effectiveDesktop}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid grid-cols-3 gap-1.5 p-1.5 bg-night-900 border border-white/10 rounded-xl w-28 h-28 shrink-0 shadow-inner">
|
||||
{GRID_POSITIONS.map((pos) => {
|
||||
const isSelected = effectiveDesktop === pos.id;
|
||||
return (
|
||||
<button
|
||||
key={pos.id}
|
||||
type="button"
|
||||
onClick={() => onChangeDesktop(pos.id)}
|
||||
className={`flex flex-col items-center justify-center rounded-lg transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-cyan-glow text-night-950 font-black shadow-md shadow-cyan-glow/20 scale-105 ring-1 ring-white/30'
|
||||
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title={`Desktop: ${pos.label}`}
|
||||
>
|
||||
<span className="text-xs leading-none">{pos.icon}</span>
|
||||
<span className="text-[7px] font-mono mt-0.5">{pos.short}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed">
|
||||
ตำแหน่งแสดงบนหน้าจอคอมพิวเตอร์ / จอกว้าง (≥ 768px)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile (Smartphone) */}
|
||||
<div className="bg-night-950/40 border border-white/5 p-3.5 rounded-2xl space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||
<Smartphone className="w-4 h-4 text-pop-pink" />
|
||||
Mobile (มือถือ)
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{mobilePosition && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChangeMobile(undefined)}
|
||||
className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-white px-2 py-0.5 rounded bg-white/5 hover:bg-white/10 border border-white/10 transition-colors cursor-pointer"
|
||||
title="รีเซ็ตให้เหมือน Desktop"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
ตาม PC
|
||||
</button>
|
||||
)}
|
||||
<span className="px-2 py-0.5 rounded-md bg-pop-pink/10 text-pop-pink font-mono font-bold text-[10px] border border-pop-pink/20">
|
||||
{mobilePosition || `${effectiveDesktop} (ตาม PC)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid grid-cols-3 gap-1.5 p-1.5 bg-night-900 border border-white/10 rounded-xl w-28 h-28 shrink-0 shadow-inner">
|
||||
{GRID_POSITIONS.map((pos) => {
|
||||
const isSelected = currentMobilePos === pos.id;
|
||||
const isExplicit = mobilePosition === pos.id;
|
||||
return (
|
||||
<button
|
||||
key={pos.id}
|
||||
type="button"
|
||||
onClick={() => onChangeMobile(pos.id)}
|
||||
className={`flex flex-col items-center justify-center rounded-lg transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? isExplicit
|
||||
? 'bg-pop-pink text-white font-black shadow-md shadow-pop-pink/20 scale-105 ring-1 ring-white/30'
|
||||
: 'bg-pop-pink/40 text-white font-bold ring-1 ring-pop-pink/40'
|
||||
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title={`Mobile: ${pos.label}`}
|
||||
>
|
||||
<span className="text-xs leading-none">{pos.icon}</span>
|
||||
<span className="text-[7px] font-mono mt-0.5">{pos.short}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed">
|
||||
ตำแหน่งแสดงบนโทรศัพท์มือถือ (หากไม่เลือก จะใช้ตาม Desktop)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PageSection>;
|
||||
@@ -318,7 +319,7 @@ export default function HeroCarouselForm({
|
||||
</div>
|
||||
|
||||
{/* Accessibility Alt & Optional Overlay Text */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-2 border-t border-white/5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-2 border-t border-white/5">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
Alt Text
|
||||
@@ -357,8 +358,32 @@ export default function HeroCarouselForm({
|
||||
className="admin-input w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
Button Text (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedSlideValue(slide, 'buttonText')}
|
||||
onChange={(e) => updateLocalizedSlideValue(idx, 'buttonText', e.target.value)}
|
||||
placeholder="e.g. Shop Now"
|
||||
className="admin-input w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide Button & Content Position */}
|
||||
<ContentPositionPicker
|
||||
compact
|
||||
defaultDesktop="bottom-left"
|
||||
desktopPosition={slide.buttonPosition}
|
||||
mobilePosition={slide.mobileButtonPosition}
|
||||
onChangeDesktop={(pos) => updateSlidePayloadValue(idx, 'buttonPosition', pos)}
|
||||
onChangeMobile={(pos) => updateSlidePayloadValue(idx, 'mobileButtonPosition', pos || '')}
|
||||
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<PageSection>;
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{/* 9-Box Grid Button Position Selector (Desktop & Mobile) */}
|
||||
<div className="md:col-span-2 space-y-4 pt-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2">
|
||||
<div>
|
||||
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-300">
|
||||
ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position)
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
สามารถแยกตำแหน่งระหว่าง <strong>Desktop (PC)</strong> และ <strong>Mobile (มือถือ)</strong> ได้อิสระ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Desktop (PC) */}
|
||||
<div className="bg-night-950/40 border border-white/5 p-4 rounded-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||
<Monitor className="w-4 h-4 text-cyan-glow" />
|
||||
Desktop (PC)
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-md bg-cyan-glow/10 text-cyan-glow font-mono font-bold text-[10px] border border-cyan-glow/20">
|
||||
{editingSection.payload?.buttonPosition || 'center'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid grid-cols-3 gap-1.5 p-1.5 bg-night-900 border border-white/10 rounded-xl w-32 h-32 shrink-0 shadow-inner">
|
||||
{GRID_POSITIONS.map((pos) => {
|
||||
const isSelected = (editingSection.payload?.buttonPosition || 'center') === pos.id;
|
||||
return (
|
||||
<button
|
||||
key={pos.id}
|
||||
type="button"
|
||||
onClick={() => updatePayloadValue('buttonPosition', pos.id)}
|
||||
className={`flex flex-col items-center justify-center rounded-lg transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-cyan-glow text-night-950 font-black shadow-md shadow-cyan-glow/20 scale-105 ring-1 ring-white/30'
|
||||
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title={`Desktop: ${pos.label}`}
|
||||
>
|
||||
<span className="text-xs leading-none">{pos.icon}</span>
|
||||
<span className="text-[7px] font-mono mt-0.5">{pos.short}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
ตำแหน่งที่จะแสดงเมื่อเปิดบนหน้าจอคอมพิวเตอร์ / จอกว้าง (ขนาด ≥ 768px)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile (Smartphone) */}
|
||||
<div className="bg-night-950/40 border border-white/5 p-4 rounded-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||
<Smartphone className="w-4 h-4 text-pop-pink" />
|
||||
Mobile (มือถือ)
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editingSection.payload?.mobileButtonPosition && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updatePayloadValue('mobileButtonPosition', undefined)}
|
||||
className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-white px-2 py-0.5 rounded bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
|
||||
title="รีเซ็ตให้เหมือน Desktop"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
ตาม PC
|
||||
</button>
|
||||
)}
|
||||
<span className="px-2 py-0.5 rounded-md bg-pop-pink/10 text-pop-pink font-mono font-bold text-[10px] border border-pop-pink/20">
|
||||
{editingSection.payload?.mobileButtonPosition || `${editingSection.payload?.buttonPosition || 'center'} (ตาม PC)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid grid-cols-3 gap-1.5 p-1.5 bg-night-900 border border-white/10 rounded-xl w-32 h-32 shrink-0 shadow-inner">
|
||||
{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 (
|
||||
<button
|
||||
key={pos.id}
|
||||
type="button"
|
||||
onClick={() => updatePayloadValue('mobileButtonPosition', pos.id)}
|
||||
className={`flex flex-col items-center justify-center rounded-lg transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? isExplicit
|
||||
? 'bg-pop-pink text-white font-black shadow-md shadow-pop-pink/20 scale-105 ring-1 ring-white/30'
|
||||
: 'bg-pop-pink/40 text-white font-bold ring-1 ring-pop-pink/40'
|
||||
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title={`Mobile: ${pos.label}`}
|
||||
>
|
||||
<span className="text-xs leading-none">{pos.icon}</span>
|
||||
<span className="text-[7px] font-mono mt-0.5">{pos.short}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
ตำแหน่งที่จะแสดงเมื่อเปิดบนโทรศัพท์มือถือ (หากไม่เลือก จะใช้ตาม Desktop อัตโนมัติ)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<ContentPositionPicker
|
||||
desktopPosition={editingSection.payload?.buttonPosition}
|
||||
mobilePosition={editingSection.payload?.mobileButtonPosition}
|
||||
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
|
||||
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<PageSection>;
|
||||
@@ -291,6 +292,14 @@ export default function HeroVideoForm({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button & Content Position (9-Box Grid for Desktop & Mobile) */}
|
||||
<ContentPositionPicker
|
||||
desktopPosition={payload.buttonPosition}
|
||||
mobilePosition={payload.mobileButtonPosition}
|
||||
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
|
||||
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Smartphone,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import ContentPositionPicker from '../page-builder/ContentPositionPicker'
|
||||
|
||||
type ProductHeroCarouselBlockFormProps = {
|
||||
payload: Record<string, unknown>
|
||||
@@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({
|
||||
subtitle: {},
|
||||
buttonText: {},
|
||||
focalPoint: { x: '50%', y: '50%' },
|
||||
buttonPosition: 'bottom-left',
|
||||
mobileButtonPosition: '',
|
||||
})
|
||||
|
||||
const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
|
||||
@@ -253,6 +256,17 @@ export default function ProductHeroCarouselBlockForm({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Slide Button & Content Position */}
|
||||
<ContentPositionPicker
|
||||
compact
|
||||
defaultDesktop="bottom-left"
|
||||
desktopPosition={getString(slide.buttonPosition, 'bottom-left')}
|
||||
mobilePosition={getString(slide.mobileButtonPosition) || undefined}
|
||||
onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })}
|
||||
onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })}
|
||||
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||
alt={title || "Banner"}
|
||||
style={imageStyle}
|
||||
/>
|
||||
<div className="ui-banner-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div className={`ui-banner-content flex flex-col ${contentClasses}`}>
|
||||
{title && (
|
||||
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="font-light text-base text-pop-bg/95 md:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{buttonText && (
|
||||
<div className={`pt-4 flex ${actionsClasses}`}>
|
||||
<span className="ui-button-secondary">
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="ui-section-band bg-pop-bg relative overflow-hidden">
|
||||
{linkUrl ? (
|
||||
<Link to={linkUrl} className="ui-banner-shell group block cursor-pointer">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||
alt={title || "Banner"}
|
||||
style={imageStyle}
|
||||
/>
|
||||
<div className="ui-banner-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div className="ui-banner-content">
|
||||
{title && (
|
||||
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="font-light text-base text-pop-bg/95 md:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{buttonText && (
|
||||
<div className="pt-4">
|
||||
<span className="ui-button-secondary">
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to={linkUrl} className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-pointer`}>
|
||||
{bannerInner}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="ui-banner-shell group block cursor-default">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||
alt={title || "Banner"}
|
||||
style={imageStyle}
|
||||
/>
|
||||
<div className="ui-banner-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div className="ui-banner-content">
|
||||
{title && (
|
||||
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="font-light text-base text-pop-bg/95 md:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{buttonText && (
|
||||
<div className="pt-4">
|
||||
<span className="ui-button-secondary">
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-default`}>
|
||||
{bannerInner}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -7,54 +7,8 @@ interface DynamicHeroProps {
|
||||
payload: HeroSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { imageUrl, buttonLink } = payload;
|
||||
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) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']');
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-pop-yellow font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
// Map 9-box grid position to mobile flex classes
|
||||
const getMobilePositionClasses = (pos: string) => {
|
||||
export const getHeroMobilePositionClasses = (pos: string) => {
|
||||
switch (pos) {
|
||||
case 'top-left':
|
||||
return {
|
||||
@@ -115,7 +69,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
};
|
||||
|
||||
// Map 9-box grid position to desktop (md:) flex classes
|
||||
const getDesktopPositionClasses = (pos: string) => {
|
||||
export const getHeroDesktopPositionClasses = (pos: string) => {
|
||||
switch (pos) {
|
||||
case 'top-left':
|
||||
return {
|
||||
@@ -175,12 +129,63 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const mClasses = getMobilePositionClasses(mobilePosition);
|
||||
const dClasses = getDesktopPositionClasses(desktopPosition);
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`;
|
||||
const contentClasses = `${mClasses.content} ${dClasses.content}`;
|
||||
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`;
|
||||
export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { imageUrl, buttonLink } = payload;
|
||||
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) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']');
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-pop-yellow font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
</h1>
|
||||
)}
|
||||
{description && (
|
||||
<p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2">
|
||||
<p className={`ui-hero-copy !text-white/90 drop-shadow-md !pb-2 ${contentClasses.includes('text-left') ? '!mx-0' : contentClasses.includes('text-right') ? '!ml-auto !mr-0' : '!mx-auto'}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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,9 +116,14 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
|
||||
/>
|
||||
|
||||
{/* Text Overlay */}
|
||||
{(slide.title || slide.subtitle || slide.buttonText) && (
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col justify-end p-6 md:p-10">
|
||||
<div className="max-w-xl space-y-1.5 md:space-y-2">
|
||||
{(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 (
|
||||
<div className={`absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col p-6 md:p-10 ${wrapperClasses}`}>
|
||||
<div className={`max-w-xl space-y-1.5 md:space-y-2 flex flex-col ${contentClasses}`}>
|
||||
{slide.subtitle && (
|
||||
<p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-pop-yellow drop-shadow-md">
|
||||
{slide.subtitle}
|
||||
@@ -129,13 +135,16 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
|
||||
</h3>
|
||||
)}
|
||||
{slide.buttonText && (
|
||||
<div className={`flex ${actionsClasses} pt-1`}>
|
||||
<span className="inline-block mt-2 px-4 py-1.5 bg-pop-pink text-white text-xs font-bold rounded-full shadow-lg hover:bg-pop-red transition-all">
|
||||
{slide.buttonText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,7 +98,14 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
|
||||
></div>
|
||||
|
||||
{/* Overlay Typography Content */}
|
||||
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center animate-fade-in">
|
||||
{(() => {
|
||||
const desktopPosition = (payload as any).buttonPosition || 'center';
|
||||
const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition;
|
||||
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
|
||||
|
||||
return (
|
||||
<div className={`relative z-20 mx-auto px-6 py-16 w-full max-w-5xl h-full flex flex-col animate-fade-in ${wrapperClasses}`}>
|
||||
<div className={`flex flex-col space-y-6 max-w-3xl ${contentClasses}`}>
|
||||
{tagline && (
|
||||
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md">
|
||||
{tagline}
|
||||
@@ -116,7 +124,7 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
|
||||
|
||||
{/* Dual Action Buttons */}
|
||||
{(buttonText || secondaryButtonText) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
|
||||
<div className={`flex flex-wrap gap-4 pt-4 ${actionsClasses}`}>
|
||||
{buttonText && payload.buttonLink && (
|
||||
<Link
|
||||
to={payload.buttonLink}
|
||||
@@ -137,6 +145,9 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -97,34 +97,6 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const { t } = useTranslation()
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
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':
|
||||
@@ -245,12 +217,45 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
||||
}
|
||||
}
|
||||
|
||||
const mClasses = getMobilePositionClasses(mobilePosition)
|
||||
const dClasses = getDesktopPositionClasses(desktopPosition)
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
|
||||
const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`
|
||||
const contentClasses = `${mClasses.content} ${dClasses.content}`
|
||||
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`
|
||||
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const { t } = useTranslation()
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
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 { 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<string, unknown>, lang
|
||||
</h1>
|
||||
)}
|
||||
{description && (
|
||||
<p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2">
|
||||
<p className={`ui-hero-copy !text-white/90 drop-shadow-md !pb-2 ${contentClasses.includes('text-left') ? '!mx-0' : contentClasses.includes('text-right') ? '!ml-auto !mr-0' : '!mx-auto'}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -430,19 +435,26 @@ function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknow
|
||||
</div>
|
||||
|
||||
{/* Optional Text Overlay */}
|
||||
{(title || subtitle || buttonText) && (
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col justify-end p-6 md:p-10">
|
||||
<div className="max-w-xl space-y-1.5 md:space-y-2">
|
||||
{(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 (
|
||||
<div className={`absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col p-6 md:p-10 ${sWrap}`}>
|
||||
<div className={`max-w-xl space-y-1.5 md:space-y-2 flex flex-col ${sContent}`}>
|
||||
{subtitle && <p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-brand-accent drop-shadow-md">{subtitle}</p>}
|
||||
{title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>}
|
||||
{buttonText && (
|
||||
<div className={`flex ${sActions}`}>
|
||||
<span className="inline-block mt-2 px-4 py-1.5 bg-brand-primary text-white text-xs font-bold rounded-full shadow-lg hover:bg-brand-primary-hover transition-all">
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -491,8 +503,12 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center bg-[#0B0F17] text-white`}>
|
||||
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex bg-[#0B0F17] text-white`}>
|
||||
{videoUrl ? (
|
||||
<video
|
||||
autoPlay={payload.autoplay !== false}
|
||||
@@ -529,7 +545,8 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
|
||||
<div className="absolute inset-0 z-10 bg-black transition-opacity" style={{ opacity: overlayOpacity / 100 }}></div>
|
||||
|
||||
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center">
|
||||
<div className={`relative z-20 mx-auto px-6 py-16 w-full max-w-5xl h-full flex flex-col ${wrapperClasses}`}>
|
||||
<div className={`flex flex-col space-y-6 max-w-3xl ${contentClasses}`}>
|
||||
{tagline && (
|
||||
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-brand-accent drop-shadow-md">
|
||||
{tagline}
|
||||
@@ -546,7 +563,7 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
</p>
|
||||
)}
|
||||
{(buttonText || secondaryButtonText) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
|
||||
<div className={`flex flex-wrap gap-4 pt-4 ${actionsClasses}`}>
|
||||
{buttonText && buttonLink && (
|
||||
<Link
|
||||
href={buttonLink}
|
||||
@@ -566,6 +583,7 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1103,8 +1121,12 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, 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 = (
|
||||
<div className="ui-banner-shell">
|
||||
<div className={`ui-banner-shell flex flex-col ${bWrap}`}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title || 'Banner'}
|
||||
@@ -1114,10 +1136,14 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
<div className="ui-banner-overlay" />
|
||||
<div className="ui-banner-content">
|
||||
<div className={`ui-banner-content flex flex-col space-y-4 max-w-2xl ${bContent}`}>
|
||||
{title && <h2 className="text-3xl font-semibold text-brand-primary drop-shadow-md md:text-5xl">{title}</h2>}
|
||||
{description && <p className="text-surface-page/90 drop-shadow-sm">{description}</p>}
|
||||
{buttonText && <span className="inline-flex cursor-pointer rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main transition-colors hover:bg-brand-primary hover:text-surface-card">{buttonText}</span>}
|
||||
{buttonText && (
|
||||
<div className={`flex ${bActions}`}>
|
||||
<span className="inline-flex cursor-pointer rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main transition-colors hover:bg-brand-primary hover:text-surface-card">{buttonText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+26
-10
@@ -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<typeof ContentPositionSchema>;
|
||||
|
||||
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<typeof BannerSectionPayloadSchema>;
|
||||
|
||||
@@ -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<typeof HeroVideoSectionPayloadSchema>;
|
||||
|
||||
@@ -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<typeof HeroCarouselSlideSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user