x
This commit is contained in:
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">
|
||||||
|
ตำแหน่งที่จะแสดงเมื่อเปิดบนหน้าจอคอมพิวเตอร์ / จอกว้าง (ขนาด ≥ 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,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,
|
||||||
|
|||||||
@@ -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">→</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">→</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
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user