This commit is contained in:
Your Name committed 2026-10-04 18:59:27 +07:00
1 parent b641acbeb7
commit e349ef5b84
14 files changed
+468 -306

No files matched your search

@@ -7,6 +7,7 @@ import {
Smartphone, Smartphone,
Trash2, Trash2,
} from 'lucide-react' } from 'lucide-react'
import ContentPositionPicker from '../page-builder/ContentPositionPicker'
type CategoryHeroCarouselBlockFormProps = { type CategoryHeroCarouselBlockFormProps = {
payload: Record<string, unknown> payload: Record<string, unknown>
@@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({
subtitle: {}, subtitle: {},
buttonText: {}, buttonText: {},
focalPoint: { x: '50%', y: '50%' }, focalPoint: { x: '50%', y: '50%' },
buttonPosition: 'bottom-left',
mobileButtonPosition: '',
}) })
const getSlides = (payload: Record<string, unknown>): SlidePayload[] => const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
@@ -253,6 +256,17 @@ export default function CategoryHeroCarouselBlockForm({
/> />
</label> </label>
</div> </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> </article>
) )
})} })}
@@ -1,6 +1,7 @@
import { UploadCloud, Image as ImageIcon } from 'lucide-react'; import { UploadCloud, Image as ImageIcon } from 'lucide-react';
import { type PageSection } from '@homaroma/types'; import { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker'; import FocalPointPicker from './FocalPointPicker';
import ContentPositionPicker from './ContentPositionPicker';
interface BannerFormProps { interface BannerFormProps {
editingSection: Partial<PageSection>; editingSection: Partial<PageSection>;
@@ -97,6 +98,16 @@ export default function BannerForm({
className="admin-input w-full resize-none" className="admin-input w-full resize-none"
/> />
</div> </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> </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">
ตำแหน่งแสดงบนหน้าจอคอมพิวเตอร์ / จอกว้าง (&ge; 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 React from 'react';
import { Plus, Trash2, UploadCloud, Image as ImageIcon, Sliders } from 'lucide-react'; import { Plus, Trash2, UploadCloud, Image as ImageIcon, Sliders } from 'lucide-react';
import { type PageSection } from '@homaroma/types'; import { type PageSection } from '@homaroma/types';
import ContentPositionPicker from './ContentPositionPicker';
interface HeroCarouselFormProps { interface HeroCarouselFormProps {
editingSection: Partial<PageSection>; editingSection: Partial<PageSection>;
@@ -318,7 +319,7 @@ export default function HeroCarouselForm({
</div> </div>
{/* Accessibility Alt & Optional Overlay Text */} {/* 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"> <div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400"> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Alt Text Alt Text
@@ -357,8 +358,32 @@ export default function HeroCarouselForm({
className="admin-input w-full text-xs" className="admin-input w-full text-xs"
/> />
</div> </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>
</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> </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 { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker'; import FocalPointPicker from './FocalPointPicker';
import ContentPositionPicker from './ContentPositionPicker';
interface HeroFormProps { interface HeroFormProps {
editingSection: Partial<PageSection>; editingSection: Partial<PageSection>;
@@ -12,18 +13,6 @@ 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: '↘' },
];
const OVERLAY_PRESETS = [ const OVERLAY_PRESETS = [
{ label: 'None (0%)', value: 0 }, { label: 'None (0%)', value: 0 },
{ label: 'Light (25%)', value: 25 }, { label: 'Light (25%)', value: 25 },
@@ -237,116 +226,13 @@ export default function HeroForm({
</div> </div>
{/* 9-Box Grid Button Position Selector (Desktop & Mobile) */} {/* 9-Box Grid Button Position Selector (Desktop & Mobile) */}
<div className="md:col-span-2 space-y-4 pt-2"> <div className="md:col-span-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2"> <ContentPositionPicker
<div> desktopPosition={editingSection.payload?.buttonPosition}
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-300"> mobilePosition={editingSection.payload?.mobileButtonPosition}
ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position) onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
</label> onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
<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> </div>
); );
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { UploadCloud, Image as ImageIcon, Video } from 'lucide-react'; import { UploadCloud, Image as ImageIcon, Video } from 'lucide-react';
import { type PageSection } from '@homaroma/types'; import { type PageSection } from '@homaroma/types';
import ContentPositionPicker from './ContentPositionPicker';
interface HeroVideoFormProps { interface HeroVideoFormProps {
editingSection: Partial<PageSection>; editingSection: Partial<PageSection>;
@@ -291,6 +292,14 @@ export default function HeroVideoForm({
</div> </div>
</div> </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> </div>
); );
} }
@@ -7,6 +7,7 @@ import {
Smartphone, Smartphone,
Trash2, Trash2,
} from 'lucide-react' } from 'lucide-react'
import ContentPositionPicker from '../page-builder/ContentPositionPicker'
type ProductHeroCarouselBlockFormProps = { type ProductHeroCarouselBlockFormProps = {
payload: Record<string, unknown> payload: Record<string, unknown>
@@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({
subtitle: {}, subtitle: {},
buttonText: {}, buttonText: {},
focalPoint: { x: '50%', y: '50%' }, focalPoint: { x: '50%', y: '50%' },
buttonPosition: 'bottom-left',
mobileButtonPosition: '',
}) })
const getSlides = (payload: Record<string, unknown>): SlidePayload[] => const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
@@ -253,6 +256,17 @@ export default function ProductHeroCarouselBlockForm({
/> />
</label> </label>
</div> </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> </article>
) )
})} })}
+1 -1
View File
@@ -1,6 +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 // Trigger reload: resilient position schemas with empty string handling
import { import {
LocalizedStringSchema, LocalizedStringSchema,
HeroSectionPayloadSchema, HeroSectionPayloadSchema,
+43 -60
View File
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import type { BannerSectionPayload } from '@homaroma/types'; import type { BannerSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; import { getLocalizedField } from '../utils/localization';
import { getHeroPositionClasses } from './DynamicHero';
interface DynamicBannerProps { interface DynamicBannerProps {
payload: BannerSectionPayload; payload: BannerSectionPayload;
@@ -17,71 +18,53 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? payload.focalPoint : undefined; const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? payload.focalPoint : undefined;
const imageStyle = focalPoint ? { objectPosition: `${focalPoint.x} ${focalPoint.y}` } : 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 ( return (
<section className="ui-section-band bg-pop-bg relative overflow-hidden"> <section className="ui-section-band bg-pop-bg relative overflow-hidden">
{linkUrl ? ( {linkUrl ? (
<Link to={linkUrl} className="ui-banner-shell group block cursor-pointer"> <Link to={linkUrl} className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-pointer`}>
<div className="absolute inset-0 z-0"> {bannerInner}
<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> </Link>
) : ( ) : (
<div className="ui-banner-shell group block cursor-default"> <div className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-default`}>
<div className="absolute inset-0 z-0"> {bannerInner}
<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> </div>
)} )}
</section> </section>
+63 -58
View File
@@ -7,54 +7,8 @@ interface DynamicHeroProps {
payload: HeroSectionPayload; payload: HeroSectionPayload;
} }
export default function DynamicHero({ payload }: DynamicHeroProps) { // Map 9-box grid position to mobile flex classes
const { t, i18n } = useTranslation(); export const getHeroMobilePositionClasses = (pos: string) => {
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) => {
switch (pos) { switch (pos) {
case 'top-left': case 'top-left':
return { return {
@@ -112,10 +66,10 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
actions: 'items-center justify-center' actions: 'items-center justify-center'
}; };
} }
}; };
// Map 9-box grid position to desktop (md:) flex classes // Map 9-box grid position to desktop (md:) flex classes
const getDesktopPositionClasses = (pos: string) => { export const getHeroDesktopPositionClasses = (pos: string) => {
switch (pos) { switch (pos) {
case 'top-left': case 'top-left':
return { return {
@@ -173,14 +127,65 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
actions: 'md:items-center md:justify-center' actions: 'md:items-center md:justify-center'
}; };
} }
};
export function getHeroPositionClasses(mobilePos: string, desktopPos: string) {
const mClasses = getHeroMobilePositionClasses(mobilePos);
const dClasses = getHeroDesktopPositionClasses(desktopPos);
return {
wrapperClasses: `${mClasses.wrapper} ${dClasses.wrapper}`,
contentClasses: `${mClasses.content} ${dClasses.content}`,
actionsClasses: `${mClasses.actions} ${dClasses.actions}`,
};
}
export default function DynamicHero({ payload }: DynamicHeroProps) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
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 mClasses = getMobilePositionClasses(mobilePosition); const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
const dClasses = getDesktopPositionClasses(desktopPosition);
const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}`;
const contentClasses = `${mClasses.content} ${dClasses.content}`;
const actionsClasses = `${mClasses.actions} ${dClasses.actions}`;
const overlayStyle = (payload as any).overlayStyle || 'gradient'; const overlayStyle = (payload as any).overlayStyle || 'gradient';
const overlayOpacity = (payload as any).overlayOpacity !== undefined ? (payload as any).overlayOpacity : 45; const overlayOpacity = (payload as any).overlayOpacity !== undefined ? (payload as any).overlayOpacity : 45;
@@ -245,7 +250,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
</h1> </h1>
)} )}
{description && ( {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} {description}
</p> </p>
)} )}
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import type { HeroCarouselSectionPayload } from '@homaroma/types'; import type { HeroCarouselSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; import { getLocalizedField } from '../utils/localization';
import { getHeroPositionClasses } from './DynamicHero';
// Import Swiper styles // Import Swiper styles
import 'swiper/css'; import 'swiper/css';
@@ -115,9 +116,14 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
/> />
{/* Text Overlay */} {/* Text Overlay */}
{(slide.title || slide.subtitle || slide.buttonText) && ( {(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"> const slideDesktopPos = (slide as any).buttonPosition || 'bottom-left';
<div className="max-w-xl space-y-1.5 md:space-y-2"> 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 && ( {slide.subtitle && (
<p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-pop-yellow drop-shadow-md"> <p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-pop-yellow drop-shadow-md">
{slide.subtitle} {slide.subtitle}
@@ -129,13 +135,16 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
</h3> </h3>
)} )}
{slide.buttonText && ( {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"> <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} {slide.buttonText}
</span> </span>
</div>
)} )}
</div> </div>
</div> </div>
)} );
})()}
</div> </div>
); );
+13 -2
View File
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import type { HeroVideoSectionPayload } from '@homaroma/types'; import type { HeroVideoSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; import { getLocalizedField } from '../utils/localization';
import { getHeroPositionClasses } from './DynamicHero';
interface DynamicHeroVideoProps { interface DynamicHeroVideoProps {
payload: HeroVideoSectionPayload; payload: HeroVideoSectionPayload;
@@ -97,7 +98,14 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
></div> ></div>
{/* Overlay Typography Content */} {/* 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 && ( {tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md"> <p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md">
{tagline} {tagline}
@@ -116,7 +124,7 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
{/* Dual Action Buttons */} {/* Dual Action Buttons */}
{(buttonText || secondaryButtonText) && ( {(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 && ( {buttonText && payload.buttonLink && (
<Link <Link
to={payload.buttonLink} to={payload.buttonLink}
@@ -137,6 +145,9 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
</div> </div>
)} )}
</div> </div>
</div>
);
})()}
</section> </section>
); );
} }
@@ -97,35 +97,7 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
) )
} }
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { const getMobilePositionClasses = (pos: 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) { switch (pos) {
case 'top-left': case 'top-left':
return { return {
@@ -183,9 +155,9 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
actions: 'items-center justify-center' actions: 'items-center justify-center'
} }
} }
} }
const getDesktopPositionClasses = (pos: string) => { const getDesktopPositionClasses = (pos: string) => {
switch (pos) { switch (pos) {
case 'top-left': case 'top-left':
return { return {
@@ -243,14 +215,47 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
actions: 'md:items-center md:justify-center' actions: 'md:items-center md:justify-center'
} }
} }
}
function getHeroPositionClasses(mobilePos: string, desktopPos: string) {
const mClasses = getMobilePositionClasses(mobilePos)
const dClasses = getDesktopPositionClasses(desktopPos)
return {
wrapperClasses: `${mClasses.wrapper} ${dClasses.wrapper}`,
contentClasses: `${mClasses.content} ${dClasses.content}`,
actionsClasses: `${mClasses.actions} ${dClasses.actions}`,
} }
}
const mClasses = getMobilePositionClasses(mobilePosition) function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const dClasses = getDesktopPositionClasses(desktopPosition) 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
const wrapperClasses = `${mClasses.wrapper} ${dClasses.wrapper}` // If fields are explicitly defined as empty strings, respect them as empty
const contentClasses = `${mClasses.content} ${dClasses.content}` const hasTagline = payload.tagline !== undefined && payload.tagline !== null ? !!rawTagline?.trim() : true
const actionsClasses = `${mClasses.actions} ${dClasses.actions}` 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 overlayStyle = typeof payload.overlayStyle === 'string' ? payload.overlayStyle : 'gradient'
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 45 const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 45
@@ -313,7 +318,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
</h1> </h1>
)} )}
{description && ( {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} {description}
</p> </p>
)} )}
@@ -430,19 +435,26 @@ function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknow
</div> </div>
{/* Optional Text Overlay */} {/* Optional Text Overlay */}
{(title || subtitle || buttonText) && ( {(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"> const slideDesktopPos = typeof slide.buttonPosition === 'string' ? slide.buttonPosition : 'bottom-left'
<div className="max-w-xl space-y-1.5 md:space-y-2"> 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>} {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>} {title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>}
{buttonText && ( {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"> <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} {buttonText}
</span> </span>
</div>
)} )}
</div> </div>
</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 ( 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 ? ( {videoUrl ? (
<video <video
autoPlay={payload.autoplay !== false} 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="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 && ( {tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-brand-accent drop-shadow-md"> <p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-brand-accent drop-shadow-md">
{tagline} {tagline}
@@ -546,7 +563,7 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
</p> </p>
)} )}
{(buttonText || secondaryButtonText) && ( {(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 && ( {buttonText && buttonLink && (
<Link <Link
href={buttonLink} href={buttonLink}
@@ -566,6 +583,7 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
</div> </div>
)} )}
</div> </div>
</div>
</section> </section>
) )
} }
@@ -1103,8 +1121,12 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : '' 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 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 = ( const content = (
<div className="ui-banner-shell"> <div className={`ui-banner-shell flex flex-col ${bWrap}`}>
<Image <Image
src={imageUrl} src={imageUrl}
alt={title || 'Banner'} alt={title || 'Banner'}
@@ -1114,10 +1136,14 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }} style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/> />
<div className="ui-banner-overlay" /> <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>} {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>} {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>
</div> </div>
) )
+26 -10
View File
@@ -599,6 +599,24 @@ export interface CategorySection {
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 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({ export const HeroSectionPayloadSchema = z.object({
tagline: LocalizedStringSchema.optional(), tagline: LocalizedStringSchema.optional(),
title: LocalizedStringSchema.optional(), title: LocalizedStringSchema.optional(),
@@ -607,16 +625,8 @@ export const HeroSectionPayloadSchema = z.object({
buttonText: LocalizedStringSchema.optional(), buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(), 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([ buttonPosition: DesktopContentPositionSchema('center'),
'top-left', 'top-center', 'top-right', mobileButtonPosition: OptionalContentPositionSchema,
'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(),
overlayStyle: z.enum(['gradient', 'solid', 'none']).optional().default('gradient'), overlayStyle: z.enum(['gradient', 'solid', 'none']).optional().default('gradient'),
overlayOpacity: z.number().min(0).max(100).optional(), overlayOpacity: z.number().min(0).max(100).optional(),
}); });
@@ -649,6 +659,8 @@ export const BannerSectionPayloadSchema = z.object({
description: LocalizedStringSchema.optional(), description: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(), buttonText: LocalizedStringSchema.optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('center-left'),
mobileButtonPosition: OptionalContentPositionSchema,
}); });
export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>; export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>;
@@ -725,6 +737,8 @@ export const HeroVideoSectionPayloadSchema = z.object({
backgroundColor: z.string().optional(), backgroundColor: z.string().optional(),
textColor: z.string().optional(), textColor: z.string().optional(),
focalPoint: z.object({ x: z.string(), y: 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>; export type HeroVideoSectionPayload = z.infer<typeof HeroVideoSectionPayloadSchema>;
@@ -754,6 +768,8 @@ export const HeroCarouselSlideSchema = z.object({
subtitle: LocalizedStringSchema.optional(), subtitle: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(), buttonText: LocalizedStringSchema.optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('bottom-left'),
mobileButtonPosition: OptionalContentPositionSchema,
}); });
export type HeroCarouselSlide = z.infer<typeof HeroCarouselSlideSchema>; export type HeroCarouselSlide = z.infer<typeof HeroCarouselSlideSchema>;