This commit is contained in:
Your Name committed 2026-10-16 06:26:13 +07:00
1 parent 64bcd6388a
commit 4f8fa1ae09
8 files changed
+400 -120

No files matched your search

@@ -9,7 +9,7 @@ interface HeroFormProps {
getLocalizedValue: (payload: any, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void;
updatePayloadValue: (field: string, value: any) => void;
setUploadTarget: (target: { type: 'main' | 'slide'; index?: number } | null) => void;
setUploadTarget: (target: { type: 'main' | 'slide' | 'gallery' | 'tile' | 'seoOgImage' | 'mobileImage'; index?: number } | null) => void;
setIsMediaModalOpen: (open: boolean) => void;
}
@@ -31,12 +31,104 @@ export default function HeroForm({
}: HeroFormProps) {
const currentOverlayStyle = editingSection.payload?.overlayStyle || 'gradient';
const currentOverlayOpacity = editingSection.payload?.overlayOpacity !== undefined ? editingSection.payload.overlayOpacity : 45;
const payload = editingSection.payload || {};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Hero image uploading */}
<div className="md:col-span-2 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Image</label>
{/* Global Dimension & Display Settings */}
<div className="md:col-span-2 p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="flex items-center gap-2 text-xs font-bold text-cyan-glow uppercase tracking-wider">
<Sliders className="w-3.5 h-3.5" />
<span>Carousel Dimension & Display Settings</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Desktop Aspect Ratio (Wide & Short)
</label>
<select
value={payload.aspectRatio || '21/7'}
onChange={(e) => updatePayloadValue('aspectRatio', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="21/6">21:6 (Ultra Wide / Extra Short ~320px)</option>
<option value="21/7">21:7 (Wide Compact - Beautrium Style ~380px)</option>
<option value="21/9">21:9 (Standard Cinematic Wide ~450px)</option>
<option value="16/5">16:5 (Wide Banner)</option>
<option value="16/6">16:6 (Compact Banner)</option>
<option value="4/1">4:1 (Panoramic Ultra Slim)</option>
<option value="5/1">5:1 (Ultra Slim Ribbon)</option>
<option value="full_screen">Full Screen / Original Hero (~90vh)</option>
</select>
<p className="text-[9px] text-slate-500">BEAUTRIUM style uses 21:7 or 21:6 for wide banners without eating vertical screen space.</p>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Mobile Aspect Ratio
</label>
<select
value={payload.mobileAspectRatio || '16/9'}
onChange={(e) => updatePayloadValue('mobileAspectRatio', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="16/9">16:9 (Standard Mobile Widescreen)</option>
<option value="4/3">4:3 (Taller Mobile View)</option>
<option value="1/1">1:1 (Square Mobile Banner)</option>
<option value="21/9">21:9 (Compact Mobile Strip)</option>
<option value="full_screen">Full Screen Mobile (~85vh)</option>
</select>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Header Spacing (ตำแหน่งเทียบกับ Header Menu เมื่ออยู่บล็อกแรก)
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => updatePayloadValue('headerSpacing', 'below_header')}
className={`p-3 rounded-xl border text-left transition cursor-pointer flex flex-col gap-1 ${
(payload.headerSpacing || 'below_header') === 'below_header'
? 'border-cyan-glow bg-cyan-glow/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-white">ต่อจาก Header Menu (แนะนำ)</span>
{(payload.headerSpacing || 'below_header') === 'below_header' && (
<span className="text-[10px] font-bold text-cyan-glow uppercase">Selected</span>
)}
</div>
<p className="text-[10px] text-slate-400">เว้นระยะด้านบนอัตโนมัติ ไม่โดน Header Menu ลอยทับแถบแบนเนอร์</p>
</button>
<button
type="button"
onClick={() => updatePayloadValue('headerSpacing', 'under_header')}
className={`p-3 rounded-xl border text-left transition cursor-pointer flex flex-col gap-1 ${
payload.headerSpacing === 'under_header'
? 'border-cyan-glow bg-cyan-glow/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-white">อยู่ใต้ Header Menu (Overlay)</span>
{payload.headerSpacing === 'under_header' && (
<span className="text-[10px] font-bold text-cyan-glow uppercase">Selected</span>
)}
</div>
<p className="text-[10px] text-slate-400">Header เมนูจะโปร่งแสงและลอยทับรูปภาพแบนเนอร์ส่วนบนสุด</p>
</button>
</div>
</div>
</div>
</div>
{/* Hero Desktop Image */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Image (Desktop)</label>
<div
className="relative h-40 w-full rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => {
@@ -60,6 +152,51 @@ export default function HeroForm({
</div>
</div>
{/* Hero Mobile Image (Optional) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Mobile Image (จอมือถือ - เว้นว่างได้)
</label>
{editingSection.payload?.mobileImageUrl && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
updatePayloadValue('mobileImageUrl', '');
}}
className="text-[10px] text-rose-400 hover:text-rose-300 transition cursor-pointer"
>
ลบรูปโมบาย
</button>
)}
</div>
<div
className="relative h-40 w-full rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => {
setUploadTarget({ type: 'mobileImage' });
setIsMediaModalOpen(true);
}}
>
{editingSection.payload?.mobileImageUrl ? (
<>
<img src={editingSection.payload.mobileImageUrl} className="w-full h-full object-cover" alt="Hero Mobile Background" />
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
<UploadCloud className="w-10 h-10 text-white" />
</div>
</>
) : (
<div className="text-center space-y-2">
<ImageIcon className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">
{editingSection.payload?.imageUrl ? 'ใช้รูปเดียวกับ Desktop' : 'Select Mobile Image'}
</p>
<p className="text-[9px] text-slate-500">แนะนำสำหรับสัดส่วน 1:1 หรือ 4:3 บนมือถือ</p>
</div>
)}
</div>
</div>
{editingSection.payload?.imageUrl && (
<div className="md:col-span-2">
<FocalPointPicker
+10 -2
View File
@@ -147,7 +147,7 @@ export default function PageBuilderPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery' | 'tile' | 'seoOgImage'; index?: number } | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery' | 'tile' | 'seoOgImage' | 'mobileImage'; index?: number } | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
const [isCopied, setIsCopied] = useState(false);
@@ -160,6 +160,11 @@ export default function PageBuilderPage() {
const handleMediaSelect = (url: string) => {
if (uploadTarget?.type === 'seoOgImage') {
setPage(prev => (prev ? { ...prev, ogImageUrl: url } : prev));
} else if (uploadTarget?.type === 'mobileImage') {
setEditingSection(prev => {
if (!prev) return null;
return { ...prev, payload: { ...(prev.payload || {}), mobileImageUrl: url } };
});
} else if (uploadTarget?.type === 'tile' && typeof uploadTarget.index === 'number') {
const idx = uploadTarget.index;
setEditingSection(prev => {
@@ -733,7 +738,10 @@ export default function PageBuilderPage() {
description: 'Transform your room into a peaceful escape.',
imageUrl: '/images/hero.png',
buttonText: 'Explore',
buttonLink: '/shop'
buttonLink: '/shop',
aspectRatio: '21/7',
mobileAspectRatio: '16/9',
headerSpacing: 'below_header'
};
break;
case 'HERO_CAROUSEL':
+2 -2
View File
@@ -95,8 +95,8 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
HERO: {
type: "HERO",
requiredFields: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink"],
optionalFields: ["focalPoint", "contentWidth", "textColorScheme", "buttonPosition", "mobileButtonPosition", "overlayStyle", "overlayOpacity", "titleSize"],
renderedFields: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink", "focalPoint", "contentWidth", "textColorScheme", "buttonPosition", "mobileButtonPosition", "overlayStyle", "overlayOpacity", "titleSize"],
optionalFields: ["focalPoint", "contentWidth", "textColorScheme", "buttonPosition", "mobileButtonPosition", "overlayStyle", "overlayOpacity", "titleSize", "aspectRatio", "mobileAspectRatio", "headerSpacing", "mobileImageUrl"],
renderedFields: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink", "focalPoint", "contentWidth", "textColorScheme", "buttonPosition", "mobileButtonPosition", "overlayStyle", "overlayOpacity", "titleSize", "aspectRatio", "mobileAspectRatio", "headerSpacing", "mobileImageUrl"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Replace sample image URLs with this tenant's approved media URLs before writing. The website must allow the media host."],
+51 -3
View File
@@ -258,23 +258,71 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
const titleSizeClass = getHeroTitleSizeClass(titleSize);
const textWrapperWidthClass = titleSize === 'sm' || contentWidth === 'compact' ? 'max-w-xl' : titleSize === 'md' ? 'max-w-2xl' : 'max-w-3xl';
const aspectRatio = (payload as any).aspectRatio;
const mobileAspectRatio = (payload as any).mobileAspectRatio;
const mobileImageUrl = (payload as any).mobileImageUrl;
const isFullScreen = !aspectRatio || aspectRatio === 'full_screen';
const getAspectClass = () => {
switch (aspectRatio) {
case '21/6': return 'md:aspect-[21/6]';
case '21/7': return 'md:aspect-[21/7]';
case '21/9': return 'md:aspect-[21/9]';
case '16/5': return 'md:aspect-[16/5]';
case '16/6': return 'md:aspect-[16/6]';
case '4/1': return 'md:aspect-[4/1]';
case '5/1': return 'md:aspect-[5/1]';
case 'full_screen': return 'md:h-[90vh] md:aspect-[21/9]';
default: return 'md:aspect-[21/7]';
}
};
const getMobileAspectClass = () => {
switch (mobileAspectRatio) {
case '16/9': return 'aspect-[16/9]';
case '4/3': return 'aspect-[4/3]';
case '1/1': return 'aspect-[1/1]';
case '21/9': return 'aspect-[21/9]';
case 'full_screen': return 'min-h-[85vh]';
default: return 'aspect-[16/9]';
}
};
const sectionHeightClass = isFullScreen
? 'min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9]'
: `${getMobileAspectClass()} ${getAspectClass()}`;
const innerHeightClass = isFullScreen
? 'min-h-[85vh] sm:min-h-[90vh] md:min-h-full'
: 'h-full justify-center';
return (
<section className={`relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<section className={`relative w-full ${sectionHeightClass} flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<div className="absolute inset-0 w-full h-full z-0">
<img
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
className="w-full h-full object-cover"
className={`w-full h-full object-cover ${mobileImageUrl ? 'hidden md:block' : ''}`}
alt="Hero Background"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{mobileImageUrl && (
<img
src={mobileImageUrl}
className="w-full h-full object-cover block md:hidden"
alt="Hero Background Mobile"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
{/* Dynamic Dark / Light Gradient / Solid Overlay */}
{renderOverlay()}
</div>
<div className={`relative z-10 min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${containerClasses} ${wrapperClasses} animate-fade-in`}>
<div className={`relative z-10 ${innerHeightClass} flex flex-col ${containerClasses} ${wrapperClasses} animate-fade-in`}>
<div className={`w-full ${textWrapperWidthClass} flex flex-col space-y-5 ${contentClasses}`}>
{tagline && (
<p className={`${isDarkText ? 'text-pop-pink' : 'text-pop-yellow'} font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md`}>
@@ -21,6 +21,7 @@ function aspectClasses(
'4/3': 'aspect-[4/3]',
'1/1': 'aspect-square',
'21/9': 'aspect-[21/9]',
full_screen: 'min-h-[85vh]',
auto: 'min-h-[22rem]',
}[mobile ?? '16/9']
@@ -32,6 +33,7 @@ function aspectClasses(
'16/6': 'md:aspect-[16/6]',
'4/1': 'md:aspect-[4/1]',
'5/1': 'md:aspect-[5/1]',
full_screen: 'md:aspect-auto md:min-h-[90vh]',
auto: 'md:aspect-auto md:min-h-[30rem]',
}[desktop ?? '21/7']
@@ -374,21 +374,74 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
)
}
const aspectRatio = typeof payload.aspectRatio === 'string' ? payload.aspectRatio : undefined
const mobileAspectRatio = typeof payload.mobileAspectRatio === 'string' ? payload.mobileAspectRatio : undefined
const mobileImageUrl = typeof payload.mobileImageUrl === 'string' && payload.mobileImageUrl ? payload.mobileImageUrl : imageUrl
const isFullScreen = !aspectRatio || aspectRatio === 'full_screen'
const getAspectClass = () => {
switch (aspectRatio) {
case '21/6': return 'md:aspect-[21/6]'
case '21/7': return 'md:aspect-[21/7]'
case '21/9': return 'md:aspect-[21/9]'
case '16/5': return 'md:aspect-[16/5]'
case '16/6': return 'md:aspect-[16/6]'
case '4/1': return 'md:aspect-[4/1]'
case '5/1': return 'md:aspect-[5/1]'
case 'full_screen': return 'md:h-[90vh] md:aspect-[21/9]'
default: return 'md:aspect-[21/7]'
}
}
const getMobileAspectClass = () => {
switch (mobileAspectRatio) {
case '16/9': return 'aspect-[16/9]'
case '4/3': return 'aspect-[4/3]'
case '1/1': return 'aspect-[1/1]'
case '21/9': return 'aspect-[21/9]'
case 'full_screen': return 'min-h-[85vh]'
default: return 'aspect-[16/9]'
}
}
const sectionHeightClass = isFullScreen
? 'min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9]'
: `${getMobileAspectClass()} ${getAspectClass()}`
const innerHeightClass = isFullScreen
? 'min-h-[85vh] sm:min-h-[90vh] md:min-h-full'
: 'h-full justify-center'
return (
<section className={`relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<section className={`relative w-full ${sectionHeightClass} flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<div className="absolute inset-0 w-full h-full z-0">
<Image
src={imageUrl}
alt={title || ''}
fill
unoptimized
className="object-cover md:scale-[1.02]"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
{/* Desktop Image */}
<div className={`w-full h-full ${mobileImageUrl !== imageUrl ? 'hidden md:block' : ''}`}>
<Image
src={imageUrl}
alt={title || ''}
fill
unoptimized
className="object-cover md:scale-[1.02]"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Mobile Image (if provided) */}
{mobileImageUrl !== imageUrl && (
<div className="block md:hidden w-full h-full relative">
<Image
src={mobileImageUrl}
alt={title || ''}
fill
unoptimized
className="object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
)}
{/* Dynamic Dark / Light Gradient / Solid Overlay */}
{renderOverlay()}
</div>
<div className={`relative z-10 min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${containerClasses} ${wrapperClasses}`}>
<div className={`relative z-10 ${innerHeightClass} flex flex-col ${containerClasses} ${wrapperClasses}`}>
<div className={`w-full ${textWrapperWidthClass} flex flex-col space-y-5 ${contentClasses}`}>
{tagline && (
<p className={`text-xs font-semibold uppercase tracking-[0.3em] drop-shadow-md md:text-sm ${isDarkText ? 'text-brand-primary' : 'text-brand-accent'}`}>