This commit is contained in:
Your Name committed 2026-09-28 07:45:46 +07:00
1 parent a39f5b8102
commit 6d3b13d360
5 files changed
+502 -64

No files matched your search

@@ -1,4 +1,4 @@
import { UploadCloud, Image as ImageIcon } from 'lucide-react'; import { UploadCloud, Image as ImageIcon, Monitor, Smartphone, RotateCcw } from 'lucide-react';
import { type PageSection } from '@homaroma/types'; import { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker'; import FocalPointPicker from './FocalPointPicker';
@@ -12,6 +12,18 @@ interface HeroFormProps {
setIsMediaModalOpen: (open: boolean) => void; setIsMediaModalOpen: (open: boolean) => void;
} }
const GRID_POSITIONS = [
{ id: 'top-left', label: 'Top Left (บนซ้าย)', short: 'TL', icon: '↖' },
{ id: 'top-center', label: 'Top Center (บนกลาง)', short: 'TC', icon: '↑' },
{ id: 'top-right', label: 'Top Right (บนขวา)', short: 'TR', icon: '↗' },
{ id: 'center-left', label: 'Center Left (กลางซ้าย)', short: 'CL', icon: '←' },
{ id: 'center', label: 'Center (กึ่งกลาง)', short: 'C', icon: '•' },
{ id: 'center-right', label: 'Center Right (กลางขวา)', short: 'CR', icon: '→' },
{ id: 'bottom-left', label: 'Bottom Left (ล่างซ้าย)', short: 'BL', icon: '↙' },
{ id: 'bottom-center', label: 'Bottom Center (ล่างกลาง)', short: 'BC', icon: '↓' },
{ id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' },
];
export default function HeroForm({ export default function HeroForm({
editingSection, editingSection,
getLocalizedValue, getLocalizedValue,
@@ -59,35 +71,35 @@ export default function HeroForm({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Tagline</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Tagline (เว้นว่างได้)</label>
<input <input
type="text" type="text"
required
value={getLocalizedValue(editingSection.payload, 'tagline')} value={getLocalizedValue(editingSection.payload, 'tagline')}
onChange={(e) => updateLocalizedValue('tagline', e.target.value)} onChange={(e) => updateLocalizedValue('tagline', e.target.value)}
placeholder="เว้นว่างได้หากไม่ต้องการแสดง"
className="admin-input w-full" className="admin-input w-full"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title (เว้นว่างได้)</label>
<input <input
type="text" type="text"
required
value={getLocalizedValue(editingSection.payload, 'title')} value={getLocalizedValue(editingSection.payload, 'title')}
onChange={(e) => updateLocalizedValue('title', e.target.value)} onChange={(e) => updateLocalizedValue('title', e.target.value)}
placeholder="เว้นว่างได้หากไม่ต้องการแสดง"
className="admin-input w-full" className="admin-input w-full"
/> />
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for golden accent serif text (e.g. Inhale [Serenity]).</p> <p className="text-[9px] text-slate-500 font-medium">Use [brackets] for golden accent serif text (e.g. Inhale [Serenity]).</p>
</div> </div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description (เว้นว่างได้)</label>
<textarea <textarea
rows={3} rows={3}
required
value={getLocalizedValue(editingSection.payload, 'description')} value={getLocalizedValue(editingSection.payload, 'description')}
onChange={(e) => updateLocalizedValue('description', e.target.value)} onChange={(e) => updateLocalizedValue('description', e.target.value)}
placeholder="เว้นว่างได้หากไม่ต้องการแสดง"
className="admin-input w-full resize-none" className="admin-input w-full resize-none"
/> />
</div> </div>
@@ -96,9 +108,9 @@ export default function HeroForm({
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
<input <input
type="text" type="text"
required
value={getLocalizedValue(editingSection.payload, 'buttonText')} value={getLocalizedValue(editingSection.payload, 'buttonText')}
onChange={(e) => updateLocalizedValue('buttonText', e.target.value)} onChange={(e) => updateLocalizedValue('buttonText', e.target.value)}
placeholder="เช่น Explore Collection"
className="admin-input w-full" className="admin-input w-full"
/> />
</div> </div>
@@ -107,12 +119,125 @@ export default function HeroForm({
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link</label>
<input <input
type="text" type="text"
required
value={editingSection.payload?.buttonLink || ''} value={editingSection.payload?.buttonLink || ''}
onChange={(e) => updatePayloadValue('buttonLink', e.target.value)} onChange={(e) => updatePayloadValue('buttonLink', e.target.value)}
placeholder="เช่น /shop"
className="admin-input w-full" className="admin-input w-full"
/> />
</div> </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">
ตำแหน่งที่จะแสดงเมื่อเปิดบนหน้าจอคอมพิวเตอร์ / จอกว้าง (ขนาด &ge; 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>
</div> </div>
); );
} }
+1
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
// Trigger reload for HeroSectionPayloadSchema with mobileButtonPosition support
import { import {
LocalizedStringSchema, LocalizedStringSchema,
HeroSectionPayloadSchema, HeroSectionPayloadSchema,
+186 -31
View File
@@ -11,12 +11,30 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const { imageUrl, buttonLink } = payload; const { imageUrl, buttonLink } = payload;
const tagline = getLocalizedField(payload.tagline, lang); const desktopPosition = (payload as any).buttonPosition || 'center';
const title = getLocalizedField(payload.title, lang); const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition;
const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang);
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined; 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) => { const renderTitle = (text: string) => {
if (!text) return null; if (!text) return null;
return text.split('[').map((part, i) => { return text.split('[').map((part, i) => {
@@ -35,8 +53,137 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
}); });
}; };
// Map 9-box grid position to mobile flex classes
const getMobilePositionClasses = (pos: string) => {
switch (pos) {
case 'top-left':
return {
wrapper: 'justify-start items-start text-left pt-20 pb-12 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
};
case 'top-center':
return {
wrapper: 'justify-start items-center text-center pt-20 pb-12 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
};
case 'top-right':
return {
wrapper: 'justify-start items-end text-right pt-20 pb-12 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
};
case 'center-left':
return {
wrapper: 'justify-center items-start text-left py-16 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
};
case 'center-right':
return {
wrapper: 'justify-center items-end text-right py-16 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
};
case 'bottom-left':
return {
wrapper: 'justify-end items-start text-left pt-12 pb-16 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
};
case 'bottom-center':
return {
wrapper: 'justify-end items-center text-center pt-12 pb-16 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
};
case 'bottom-right':
return {
wrapper: 'justify-end items-end text-right pt-12 pb-16 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
};
case 'center':
default:
return {
wrapper: 'justify-center items-center text-center py-16 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
};
}
};
// Map 9-box grid position to desktop (md:) flex classes
const getDesktopPositionClasses = (pos: string) => {
switch (pos) {
case 'top-left':
return {
wrapper: 'md:justify-start md:items-start md:text-left md:pt-28 md:pb-12 md:pl-16 md:pr-6',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
};
case 'top-center':
return {
wrapper: 'md:justify-start md:items-center md:text-center md:pt-28 md:pb-12 md:px-6',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
};
case 'top-right':
return {
wrapper: 'md:justify-start md:items-end md:text-right md:pt-28 md:pb-12 md:pr-16 md:pl-6',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
};
case 'center-left':
return {
wrapper: 'md:justify-center md:items-start md:text-left md:py-16 md:pl-16 md:pr-6',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
};
case 'center-right':
return {
wrapper: 'md:justify-center md:items-end md:text-right md:py-16 md:pr-16 md:pl-6',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
};
case 'bottom-left':
return {
wrapper: 'md:justify-end md:items-start md:text-left md:pt-12 md:pb-24 md:pl-16 md:pr-6',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
};
case 'bottom-center':
return {
wrapper: 'md:justify-end md:items-center md:text-center md:pt-12 md:pb-24 md:px-6',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
};
case 'bottom-right':
return {
wrapper: 'md:justify-end md:items-end md:text-right md:pt-12 md:pb-24 md:pr-16 md:pl-6',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
};
case 'center':
default:
return {
wrapper: 'md:justify-center md:items-center md:text-center md:py-16 md:px-6',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
};
}
};
const mClasses = getMobilePositionClasses(mobilePosition);
const dClasses = getDesktopPositionClasses(desktopPosition);
const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`;
const contentClasses = `${mClasses.content} ${dClasses.content}`;
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`;
return ( return (
<section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex items-center justify-center bg-night-950 overflow-hidden"> <section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex bg-night-950 overflow-hidden">
<div className="absolute inset-0 w-full h-full z-0"> <div className="absolute inset-0 w-full h-full z-0">
<img <img
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`} src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
@@ -51,32 +198,40 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/45 to-black/30 md:bg-black/35" /> <div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/45 to-black/30 md:bg-black/35" />
</div> </div>
<div className="relative z-10 mx-auto py-16 px-6 sm:px-8 w-full max-w-4xl space-y-6 text-center animate-fade-in flex flex-col justify-center items-center"> <div className={`relative z-10 w-full min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${wrapperClasses} animate-fade-in`}>
<p className="text-white/80 font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md"> <div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}>
{tagline || t('hero.defaultTagline')} {tagline && (
</p> <p className="text-white/80 font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
<h1 className="ui-hero-title !text-white drop-shadow-lg"> {tagline}
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))} </p>
</h1> )}
{description && ( {title && (
<p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2"> <h1 className="ui-hero-title !text-white drop-shadow-lg">
{description} {renderTitle(title)}
</p> </h1>
)} )}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-center justify-center pt-2"> {description && (
<Link <p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2">
to={buttonLink || "/shop"} {description}
className="ui-button-secondary shadow-lg shadow-black/30" </p>
> )}
{buttonText || t('hero.exploreCollection')} <div className={`flex flex-col sm:flex-row gap-4 sm:gap-6 w-full ${actionsClasses} pt-1`}>
</Link> {buttonText && (
<a <Link
href="#categories" to={buttonLink || "/shop"}
className="ui-dark-link !text-white group flex items-center gap-1.5 drop-shadow-md hover:underline" className="ui-button-secondary shadow-lg shadow-black/30"
> >
<span>{t('hero.learnMore')}</span> {buttonText}
<span className="group-hover:translate-x-1 transition-transform">&rarr;</span> </Link>
</a> )}
<a
href="#categories"
className="ui-dark-link !text-white group flex items-center gap-1.5 drop-shadow-md hover:underline"
>
<span>{t('hero.learnMore')}</span>
<span className="group-hover:translate-x-1 transition-transform">&rarr;</span>
</a>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -100,16 +100,159 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
const { t } = useTranslation() const { t } = useTranslation()
const i18nLang = useLang() const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang const resolvedLang = lang === 'all' ? i18nLang : lang
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang) const rawTagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang) const rawTitle = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang) const rawDescription = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang) const rawButtonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png' const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop' 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 const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
// If fields are explicitly defined as empty strings, respect them as empty
const hasTagline = payload.tagline !== undefined && payload.tagline !== null ? !!rawTagline?.trim() : true
const tagline = hasTagline ? (rawTagline || 'VIBRANT ELEGANCE') : ''
const hasTitle = payload.title !== undefined && payload.title !== null ? !!rawTitle?.trim() : true
const title = hasTitle ? (rawTitle || 'New Scent [Sanctuary]') : ''
const hasDescription = payload.description !== undefined && payload.description !== null ? !!rawDescription?.trim() : false
const description = hasDescription ? rawDescription : ''
const buttonText = rawButtonText !== undefined && rawButtonText !== null
? rawButtonText.trim()
: 'Explore Collection'
const getMobilePositionClasses = (pos: string) => {
switch (pos) {
case 'top-left':
return {
wrapper: 'justify-start items-start text-left pt-20 pb-12 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
}
case 'top-center':
return {
wrapper: 'justify-start items-center text-center pt-20 pb-12 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
}
case 'top-right':
return {
wrapper: 'justify-start items-end text-right pt-20 pb-12 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
}
case 'center-left':
return {
wrapper: 'justify-center items-start text-left py-16 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
}
case 'center-right':
return {
wrapper: 'justify-center items-end text-right py-16 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
}
case 'bottom-left':
return {
wrapper: 'justify-end items-start text-left pt-12 pb-16 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
}
case 'bottom-center':
return {
wrapper: 'justify-end items-center text-center pt-12 pb-16 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
}
case 'bottom-right':
return {
wrapper: 'justify-end items-end text-right pt-12 pb-16 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
}
case 'center':
default:
return {
wrapper: 'justify-center items-center text-center py-16 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
}
}
}
const getDesktopPositionClasses = (pos: string) => {
switch (pos) {
case 'top-left':
return {
wrapper: 'md:justify-start md:items-start md:text-left md:pt-32 md:pb-12 md:pl-20 md:pr-8',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
}
case 'top-center':
return {
wrapper: 'md:justify-start md:items-center md:text-center md:pt-32 md:pb-12 md:px-8',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
}
case 'top-right':
return {
wrapper: 'md:justify-start md:items-end md:text-right md:pt-32 md:pb-12 md:pr-20 md:pl-8',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
}
case 'center-left':
return {
wrapper: 'md:justify-center md:items-start md:text-left md:py-16 md:pl-20 md:pr-8',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
}
case 'center-right':
return {
wrapper: 'md:justify-center md:items-end md:text-right md:py-16 md:pr-20 md:pl-8',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
}
case 'bottom-left':
return {
wrapper: 'md:justify-end md:items-start md:text-left md:pt-12 md:pb-24 md:pl-20 md:pr-8',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
}
case 'bottom-center':
return {
wrapper: 'md:justify-end md:items-center md:text-center md:pt-12 md:pb-24 md:px-8',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
}
case 'bottom-right':
return {
wrapper: 'md:justify-end md:items-end md:text-right md:pt-12 md:pb-24 md:pr-20 md:pl-8',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
}
case 'center':
default:
return {
wrapper: 'md:justify-center md:items-center md:text-center md:py-16 md:px-8',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
}
}
}
const mClasses = getMobilePositionClasses(mobilePosition)
const dClasses = getDesktopPositionClasses(desktopPosition)
const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`
const contentClasses = `${mClasses.content} ${dClasses.content}`
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`
return ( return (
<section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex items-center justify-center bg-night-950 overflow-hidden"> <section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex bg-night-950 overflow-hidden">
<div className="absolute inset-0 w-full h-full z-0"> <div className="absolute inset-0 w-full h-full z-0">
<Image <Image
src={imageUrl} src={imageUrl}
@@ -121,31 +264,35 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/45 to-black/30 md:bg-black/35" /> <div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/45 to-black/30 md:bg-black/35" />
</div> </div>
<div className="relative z-10 mx-auto py-16 px-6 sm:px-8 w-full max-w-4xl space-y-6 text-center flex flex-col justify-center items-center"> <div className={`relative z-10 w-full min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${wrapperClasses}`}>
<div className="space-y-6"> <div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}>
{tagline && ( {tagline && (
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-[#EAEFEA] drop-shadow-md md:text-sm"> <p className="text-xs font-semibold uppercase tracking-[0.3em] text-[#EAEFEA] drop-shadow-md md:text-sm">
{tagline} {tagline}
</p> </p>
)} )}
<h1 className="ui-hero-title !text-white drop-shadow-lg"> {title && (
{renderAccentTitle(title || '', 'ui-accent-text font-medium')} <h1 className="ui-hero-title !text-white drop-shadow-lg">
</h1> {renderAccentTitle(title, 'ui-accent-text font-medium')}
</h1>
)}
{description && ( {description && (
<p className="ui-hero-copy !text-white/90 drop-shadow-md"> <p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2">
{description} {description}
</p> </p>
)} )}
<div className="flex flex-col items-center justify-center gap-4 sm:gap-6 sm:flex-row pt-2"> <div className={`flex flex-col sm:flex-row gap-4 sm:gap-6 w-full ${actionsClasses} pt-1`}>
<Link {buttonText && (
href={buttonLink} <Link
className="ui-hero-button shadow-lg shadow-black/30" href={buttonLink}
> className="ui-hero-button shadow-lg shadow-black/30"
{buttonText || 'Explore Collection'} >
</Link> {buttonText}
</Link>
)}
<a <a
href="#categories" href="#categories"
className="ui-hero-link group !text-white flex items-center gap-1.5 drop-shadow-md" className="ui-hero-link group !text-white flex items-center gap-1.5 drop-shadow-md hover:underline"
> >
<span>{t('dynamic.heroLearnMore', { lng: resolvedLang })}</span> <span>{t('dynamic.heroLearnMore', { lng: resolvedLang })}</span>
<span className="transition-transform group-hover:translate-x-1">→</span> <span className="transition-transform group-hover:translate-x-1">→</span>
+15 -5
View File
@@ -343,13 +343,23 @@ export interface ProductDetailSection {
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]); export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({ export const HeroSectionPayloadSchema = z.object({
tagline: LocalizedStringSchema, tagline: LocalizedStringSchema.optional(),
title: LocalizedStringSchema, title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema, description: LocalizedStringSchema.optional(),
imageUrl: z.string().min(1), imageUrl: z.string().min(1),
buttonText: LocalizedStringSchema, buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string(), buttonLink: z.string().optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: z.enum([
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right'
]).optional().default('center'),
mobileButtonPosition: z.enum([
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right'
]).optional(),
}); });
export type HeroSectionPayload = z.infer<typeof HeroSectionPayloadSchema>; export type HeroSectionPayload = z.infer<typeof HeroSectionPayloadSchema>;