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

No files matched your search

@@ -7,6 +7,7 @@ import {
Smartphone,
Trash2,
} from 'lucide-react'
import ContentPositionPicker from '../page-builder/ContentPositionPicker'
type CategoryHeroCarouselBlockFormProps = {
payload: Record<string, unknown>
@@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({
subtitle: {},
buttonText: {},
focalPoint: { x: '50%', y: '50%' },
buttonPosition: 'bottom-left',
mobileButtonPosition: '',
})
const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
@@ -253,6 +256,17 @@ export default function CategoryHeroCarouselBlockForm({
/>
</label>
</div>
{/* Slide Button & Content Position */}
<ContentPositionPicker
compact
defaultDesktop="bottom-left"
desktopPosition={getString(slide.buttonPosition, 'bottom-left')}
mobilePosition={getString(slide.mobileButtonPosition) || undefined}
onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })}
onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })}
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
/>
</article>
)
})}
@@ -1,6 +1,7 @@
import { UploadCloud, Image as ImageIcon } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker';
import ContentPositionPicker from './ContentPositionPicker';
interface BannerFormProps {
editingSection: Partial<PageSection>;
@@ -97,6 +98,16 @@ export default function BannerForm({
className="admin-input w-full resize-none"
/>
</div>
<div className="md:col-span-2">
<ContentPositionPicker
defaultDesktop="center-left"
desktopPosition={editingSection.payload?.buttonPosition || 'center-left'}
mobilePosition={editingSection.payload?.mobileButtonPosition}
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
/>
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import { Monitor, Smartphone, RotateCcw } from 'lucide-react';
export const GRID_POSITIONS = [
{ id: 'top-left', label: 'Top Left (บนซ้าย)', short: 'TL', icon: '↖' },
{ id: 'top-center', label: 'Top Center (บนกลาง)', short: 'TC', icon: '↑' },
{ id: 'top-right', label: 'Top Right (บนขวา)', short: 'TR', icon: '↗' },
{ id: 'center-left', label: 'Center Left (กลางซ้าย)', short: 'CL', icon: '←' },
{ id: 'center', label: 'Center (กึ่งกลาง)', short: 'C', icon: '•' },
{ id: 'center-right', label: 'Center Right (กลางขวา)', short: 'CR', icon: '→' },
{ id: 'bottom-left', label: 'Bottom Left (ล่างซ้าย)', short: 'BL', icon: '↙' },
{ id: 'bottom-center', label: 'Bottom Center (ล่างกลาง)', short: 'BC', icon: '↓' },
{ id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' },
];
export interface ContentPositionPickerProps {
desktopPosition?: string;
mobilePosition?: string;
defaultDesktop?: string;
onChangeDesktop: (position: string) => void;
onChangeMobile: (position: string | undefined) => void;
title?: string;
subtitle?: string;
compact?: boolean;
}
export default function ContentPositionPicker({
desktopPosition,
mobilePosition,
defaultDesktop = 'center',
onChangeDesktop,
onChangeMobile,
title = 'ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position)',
subtitle = 'สามารถแยกตำแหน่งระหว่าง Desktop (PC) และ Mobile (มือถือ) ได้อิสระ',
compact = false,
}: ContentPositionPickerProps) {
const effectiveDesktop = desktopPosition || defaultDesktop;
const currentMobilePos = mobilePosition || effectiveDesktop;
return (
<div className={`space-y-3 ${compact ? 'p-3 bg-night-950/60 rounded-xl border border-white/5' : 'pt-2'}`}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 border-b border-white/5 pb-2">
<div>
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-300">
{title}
</label>
{subtitle && (
<p className="text-[11px] text-slate-400 mt-0.5">
{subtitle}
</p>
)}
</div>
</div>
<div className={`grid grid-cols-1 ${compact ? 'gap-3 sm:grid-cols-2' : 'lg:grid-cols-2 gap-4'}`}>
{/* Desktop (PC) */}
<div className="bg-night-950/40 border border-white/5 p-3.5 rounded-2xl space-y-2.5">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
<Monitor className="w-4 h-4 text-cyan-glow" />
Desktop (PC)
</span>
<span className="px-2 py-0.5 rounded-md bg-cyan-glow/10 text-cyan-glow font-mono font-bold text-[10px] border border-cyan-glow/20">
{effectiveDesktop}
</span>
</div>
<div className="flex items-center gap-3">
<div className="grid grid-cols-3 gap-1.5 p-1.5 bg-night-900 border border-white/10 rounded-xl w-28 h-28 shrink-0 shadow-inner">
{GRID_POSITIONS.map((pos) => {
const isSelected = effectiveDesktop === pos.id;
return (
<button
key={pos.id}
type="button"
onClick={() => onChangeDesktop(pos.id)}
className={`flex flex-col items-center justify-center rounded-lg transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow text-night-950 font-black shadow-md shadow-cyan-glow/20 scale-105 ring-1 ring-white/30'
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white'
}`}
title={`Desktop: ${pos.label}`}
>
<span className="text-xs leading-none">{pos.icon}</span>
<span className="text-[7px] font-mono mt-0.5">{pos.short}</span>
</button>
);
})}
</div>
<p className="text-[10px] text-slate-400 leading-relaxed">
ตำแหน่งแสดงบนหน้าจอคอมพิวเตอร์ / จอกว้าง (&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 { Plus, Trash2, UploadCloud, Image as ImageIcon, Sliders } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
import ContentPositionPicker from './ContentPositionPicker';
interface HeroCarouselFormProps {
editingSection: Partial<PageSection>;
@@ -318,7 +319,7 @@ export default function HeroCarouselForm({
</div>
{/* Accessibility Alt & Optional Overlay Text */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-2 border-t border-white/5">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-2 border-t border-white/5">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Alt Text
@@ -357,7 +358,31 @@ export default function HeroCarouselForm({
className="admin-input w-full text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Button Text (Optional)
</label>
<input
type="text"
value={getLocalizedSlideValue(slide, 'buttonText')}
onChange={(e) => updateLocalizedSlideValue(idx, 'buttonText', e.target.value)}
placeholder="e.g. Shop Now"
className="admin-input w-full text-xs"
/>
</div>
</div>
{/* Slide Button & Content Position */}
<ContentPositionPicker
compact
defaultDesktop="bottom-left"
desktopPosition={slide.buttonPosition}
mobilePosition={slide.mobileButtonPosition}
onChangeDesktop={(pos) => updateSlidePayloadValue(idx, 'buttonPosition', pos)}
onChangeMobile={(pos) => updateSlidePayloadValue(idx, 'mobileButtonPosition', pos || '')}
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
/>
</div>
))}
</div>
@@ -1,6 +1,7 @@
import { UploadCloud, Image as ImageIcon, Monitor, Smartphone, RotateCcw, Layers, Sliders } from 'lucide-react';
import { UploadCloud, Image as ImageIcon, Layers, Sliders } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
import FocalPointPicker from './FocalPointPicker';
import ContentPositionPicker from './ContentPositionPicker';
interface HeroFormProps {
editingSection: Partial<PageSection>;
@@ -12,18 +13,6 @@ interface HeroFormProps {
setIsMediaModalOpen: (open: boolean) => void;
}
const GRID_POSITIONS = [
{ id: 'top-left', label: 'Top Left (บนซ้าย)', short: 'TL', icon: '↖' },
{ id: 'top-center', label: 'Top Center (บนกลาง)', short: 'TC', icon: '↑' },
{ id: 'top-right', label: 'Top Right (บนขวา)', short: 'TR', icon: '↗' },
{ id: 'center-left', label: 'Center Left (กลางซ้าย)', short: 'CL', icon: '←' },
{ id: 'center', label: 'Center (กึ่งกลาง)', short: 'C', icon: '•' },
{ id: 'center-right', label: 'Center Right (กลางขวา)', short: 'CR', icon: '→' },
{ id: 'bottom-left', label: 'Bottom Left (ล่างซ้าย)', short: 'BL', icon: '↙' },
{ id: 'bottom-center', label: 'Bottom Center (ล่างกลาง)', short: 'BC', icon: '↓' },
{ id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' },
];
const OVERLAY_PRESETS = [
{ label: 'None (0%)', value: 0 },
{ label: 'Light (25%)', value: 25 },
@@ -237,116 +226,13 @@ export default function HeroForm({
</div>
{/* 9-Box Grid Button Position Selector (Desktop & Mobile) */}
<div className="md:col-span-2 space-y-4 pt-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2">
<div>
<label className="text-[11px] uppercase font-bold tracking-widest text-slate-300">
ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position)
</label>
<p className="text-[11px] text-slate-400 mt-0.5">
สามารถแยกตำแหน่งระหว่าง <strong>Desktop (PC)</strong> และ <strong>Mobile (มือถือ)</strong> ได้อิสระ
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Desktop (PC) */}
<div className="bg-night-950/40 border border-white/5 p-4 rounded-2xl space-y-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
<Monitor className="w-4 h-4 text-cyan-glow" />
Desktop (PC)
</span>
<span className="px-2 py-0.5 rounded-md bg-cyan-glow/10 text-cyan-glow font-mono font-bold text-[10px] border border-cyan-glow/20">
{editingSection.payload?.buttonPosition || 'center'}
</span>
</div>
<div className="flex items-center gap-4">
<div className="grid grid-cols-3 gap-1.5 p-1.5 bg-night-900 border border-white/10 rounded-xl w-32 h-32 shrink-0 shadow-inner">
{GRID_POSITIONS.map((pos) => {
const isSelected = (editingSection.payload?.buttonPosition || 'center') === pos.id;
return (
<button
key={pos.id}
type="button"
onClick={() => updatePayloadValue('buttonPosition', pos.id)}
className={`flex flex-col items-center justify-center rounded-lg transition-all cursor-pointer ${
isSelected
? 'bg-cyan-glow text-night-950 font-black shadow-md shadow-cyan-glow/20 scale-105 ring-1 ring-white/30'
: 'bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white'
}`}
title={`Desktop: ${pos.label}`}
>
<span className="text-xs leading-none">{pos.icon}</span>
<span className="text-[7px] font-mono mt-0.5">{pos.short}</span>
</button>
);
})}
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
ตำแหน่งที่จะแสดงเมื่อเปิดบนหน้าจอคอมพิวเตอร์ / จอกว้าง (ขนาด &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 className="md:col-span-2">
<ContentPositionPicker
desktopPosition={editingSection.payload?.buttonPosition}
mobilePosition={editingSection.payload?.mobileButtonPosition}
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
/>
</div>
</div>
);
@@ -1,6 +1,7 @@
import React from 'react';
import { UploadCloud, Image as ImageIcon, Video } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
import ContentPositionPicker from './ContentPositionPicker';
interface HeroVideoFormProps {
editingSection: Partial<PageSection>;
@@ -291,6 +292,14 @@ export default function HeroVideoForm({
</div>
</div>
</div>
{/* Button & Content Position (9-Box Grid for Desktop & Mobile) */}
<ContentPositionPicker
desktopPosition={payload.buttonPosition}
mobilePosition={payload.mobileButtonPosition}
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
/>
</div>
);
}
@@ -7,6 +7,7 @@ import {
Smartphone,
Trash2,
} from 'lucide-react'
import ContentPositionPicker from '../page-builder/ContentPositionPicker'
type ProductHeroCarouselBlockFormProps = {
payload: Record<string, unknown>
@@ -32,6 +33,8 @@ const createSlide = (): SlidePayload => ({
subtitle: {},
buttonText: {},
focalPoint: { x: '50%', y: '50%' },
buttonPosition: 'bottom-left',
mobileButtonPosition: '',
})
const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
@@ -253,6 +256,17 @@ export default function ProductHeroCarouselBlockForm({
/>
</label>
</div>
{/* Slide Button & Content Position */}
<ContentPositionPicker
compact
defaultDesktop="bottom-left"
desktopPosition={getString(slide.buttonPosition, 'bottom-left')}
mobilePosition={getString(slide.mobileButtonPosition) || undefined}
onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })}
onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })}
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
/>
</article>
)
})}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
// Trigger reload for HeroSectionPayloadSchema with mobileButtonPosition support
// Trigger reload: resilient position schemas with empty string handling
import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
+43 -60
View File
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import type { BannerSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
import { getHeroPositionClasses } from './DynamicHero';
interface DynamicBannerProps {
payload: BannerSectionPayload;
@@ -17,71 +18,53 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? payload.focalPoint : undefined;
const imageStyle = focalPoint ? { objectPosition: `${focalPoint.x} ${focalPoint.y}` } : undefined;
const desktopPosition = payload.buttonPosition || 'center-left';
const mobilePosition = payload.mobileButtonPosition || desktopPosition;
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
const bannerInner = (
<>
<div className="absolute inset-0 z-0">
<img
src={imageUrl}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
alt={title || "Banner"}
style={imageStyle}
/>
<div className="ui-banner-overlay"></div>
</div>
<div className={`ui-banner-content flex flex-col ${contentClasses}`}>
{title && (
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
{title}
</h3>
)}
{description && (
<p className="font-light text-base text-pop-bg/95 md:text-lg">
{description}
</p>
)}
{buttonText && (
<div className={`pt-4 flex ${actionsClasses}`}>
<span className="ui-button-secondary">
{buttonText}
</span>
</div>
)}
</div>
</>
);
return (
<section className="ui-section-band bg-pop-bg relative overflow-hidden">
{linkUrl ? (
<Link to={linkUrl} className="ui-banner-shell group block cursor-pointer">
<div className="absolute inset-0 z-0">
<img
src={imageUrl}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
alt={title || "Banner"}
style={imageStyle}
/>
<div className="ui-banner-overlay"></div>
</div>
<div className="ui-banner-content">
{title && (
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
{title}
</h3>
)}
{description && (
<p className="font-light text-base text-pop-bg/95 md:text-lg">
{description}
</p>
)}
{buttonText && (
<div className="pt-4">
<span className="ui-button-secondary">
{buttonText}
</span>
</div>
)}
</div>
<Link to={linkUrl} className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-pointer`}>
{bannerInner}
</Link>
) : (
<div className="ui-banner-shell group block cursor-default">
<div className="absolute inset-0 z-0">
<img
src={imageUrl}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
alt={title || "Banner"}
style={imageStyle}
/>
<div className="ui-banner-overlay"></div>
</div>
<div className="ui-banner-content">
{title && (
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
{title}
</h3>
)}
{description && (
<p className="font-light text-base text-pop-bg/95 md:text-lg">
{description}
</p>
)}
{buttonText && (
<div className="pt-4">
<span className="ui-button-secondary">
{buttonText}
</span>
</div>
)}
</div>
<div className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-default`}>
{bannerInner}
</div>
)}
</section>
+134 -129
View File
@@ -7,6 +7,138 @@ interface DynamicHeroProps {
payload: HeroSectionPayload;
}
// Map 9-box grid position to mobile flex classes
export const getHeroMobilePositionClasses = (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
export const getHeroDesktopPositionClasses = (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'
};
}
};
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;
@@ -53,134 +185,7 @@ 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}`;
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
const overlayStyle = (payload as any).overlayStyle || 'gradient';
const overlayOpacity = (payload as any).overlayOpacity !== undefined ? (payload as any).overlayOpacity : 45;
@@ -245,7 +250,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
</h1>
)}
{description && (
<p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2">
<p className={`ui-hero-copy !text-white/90 drop-shadow-md !pb-2 ${contentClasses.includes('text-left') ? '!mx-0' : contentClasses.includes('text-right') ? '!ml-auto !mr-0' : '!mx-auto'}`}>
{description}
</p>
)}
+29 -20
View File
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import type { HeroCarouselSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
import { getHeroPositionClasses } from './DynamicHero';
// Import Swiper styles
import 'swiper/css';
@@ -115,27 +116,35 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
/>
{/* Text Overlay */}
{(slide.title || slide.subtitle || slide.buttonText) && (
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col justify-end p-6 md:p-10">
<div className="max-w-xl space-y-1.5 md:space-y-2">
{slide.subtitle && (
<p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-pop-yellow drop-shadow-md">
{slide.subtitle}
</p>
)}
{slide.title && (
<h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">
{renderTitle(slide.title)}
</h3>
)}
{slide.buttonText && (
<span className="inline-block mt-2 px-4 py-1.5 bg-pop-pink text-white text-xs font-bold rounded-full shadow-lg hover:bg-pop-red transition-all">
{slide.buttonText}
</span>
)}
{(slide.title || slide.subtitle || slide.buttonText) && (() => {
const slideDesktopPos = (slide as any).buttonPosition || 'bottom-left';
const slideMobilePos = (slide as any).mobileButtonPosition || slideDesktopPos;
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(slideMobilePos, slideDesktopPos);
return (
<div className={`absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col p-6 md:p-10 ${wrapperClasses}`}>
<div className={`max-w-xl space-y-1.5 md:space-y-2 flex flex-col ${contentClasses}`}>
{slide.subtitle && (
<p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-pop-yellow drop-shadow-md">
{slide.subtitle}
</p>
)}
{slide.title && (
<h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">
{renderTitle(slide.title)}
</h3>
)}
{slide.buttonText && (
<div className={`flex ${actionsClasses} pt-1`}>
<span className="inline-block mt-2 px-4 py-1.5 bg-pop-pink text-white text-xs font-bold rounded-full shadow-lg hover:bg-pop-red transition-all">
{slide.buttonText}
</span>
</div>
)}
</div>
</div>
</div>
)}
);
})()}
</div>
);
+46 -35
View File
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import type { HeroVideoSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
import { getHeroPositionClasses } from './DynamicHero';
interface DynamicHeroVideoProps {
payload: HeroVideoSectionPayload;
@@ -97,46 +98,56 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
></div>
{/* Overlay Typography Content */}
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center animate-fade-in">
{tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md">
{tagline}
</p>
)}
{(() => {
const desktopPosition = (payload as any).buttonPosition || 'center';
const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition;
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
</h1>
return (
<div className={`relative z-20 mx-auto px-6 py-16 w-full max-w-5xl h-full flex flex-col animate-fade-in ${wrapperClasses}`}>
<div className={`flex flex-col space-y-6 max-w-3xl ${contentClasses}`}>
{tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md">
{tagline}
</p>
)}
{description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
{description}
</p>
)}
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
</h1>
{/* Dual Action Buttons */}
{(buttonText || secondaryButtonText) && (
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
{buttonText && payload.buttonLink && (
<Link
to={payload.buttonLink}
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-pop-pink hover:text-white transition-all shadow-lg rounded-sm"
>
{buttonText}
</Link>
)}
{description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
{description}
</p>
)}
{secondaryButtonText && payload.secondaryButtonLink && (
<Link
to={payload.secondaryButtonLink}
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
>
{secondaryButtonText}
</Link>
)}
{/* Dual Action Buttons */}
{(buttonText || secondaryButtonText) && (
<div className={`flex flex-wrap gap-4 pt-4 ${actionsClasses}`}>
{buttonText && payload.buttonLink && (
<Link
to={payload.buttonLink}
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-pop-pink hover:text-white transition-all shadow-lg rounded-sm"
>
{buttonText}
</Link>
)}
{secondaryButtonText && payload.secondaryButtonLink && (
<Link
to={payload.secondaryButtonLink}
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
>
{secondaryButtonText}
</Link>
)}
</div>
)}
</div>
</div>
)}
</div>
);
})()}
</section>
);
}
@@ -97,6 +97,136 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
)
}
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'
}
}
}
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}`,
}
}
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
@@ -125,132 +255,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
? 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}`
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition)
const overlayStyle = typeof payload.overlayStyle === 'string' ? payload.overlayStyle : 'gradient'
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 45
@@ -313,7 +318,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
</h1>
)}
{description && (
<p className="ui-hero-copy !text-white/90 drop-shadow-md !pb-2">
<p className={`ui-hero-copy !text-white/90 drop-shadow-md !pb-2 ${contentClasses.includes('text-left') ? '!mx-0' : contentClasses.includes('text-right') ? '!ml-auto !mr-0' : '!mx-auto'}`}>
{description}
</p>
)}
@@ -430,19 +435,26 @@ function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknow
</div>
{/* Optional Text Overlay */}
{(title || subtitle || buttonText) && (
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col justify-end p-6 md:p-10">
<div className="max-w-xl space-y-1.5 md:space-y-2">
{subtitle && <p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-brand-accent drop-shadow-md">{subtitle}</p>}
{title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>}
{buttonText && (
<span className="inline-block mt-2 px-4 py-1.5 bg-brand-primary text-white text-xs font-bold rounded-full shadow-lg hover:bg-brand-primary-hover transition-all">
{buttonText}
</span>
)}
{(title || subtitle || buttonText) && (() => {
const slideDesktopPos = typeof slide.buttonPosition === 'string' ? slide.buttonPosition : 'bottom-left'
const slideMobilePos = typeof slide.mobileButtonPosition === 'string' ? slide.mobileButtonPosition : slideDesktopPos
const { wrapperClasses: sWrap, contentClasses: sContent, actionsClasses: sActions } = getHeroPositionClasses(slideMobilePos, slideDesktopPos)
return (
<div className={`absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col p-6 md:p-10 ${sWrap}`}>
<div className={`max-w-xl space-y-1.5 md:space-y-2 flex flex-col ${sContent}`}>
{subtitle && <p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-brand-accent drop-shadow-md">{subtitle}</p>}
{title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>}
{buttonText && (
<div className={`flex ${sActions}`}>
<span className="inline-block mt-2 px-4 py-1.5 bg-brand-primary text-white text-xs font-bold rounded-full shadow-lg hover:bg-brand-primary-hover transition-all">
{buttonText}
</span>
</div>
)}
</div>
</div>
</div>
)}
)
})()}
</div>
)
@@ -491,8 +503,12 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
}
}
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition)
return (
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center bg-[#0B0F17] text-white`}>
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex bg-[#0B0F17] text-white`}>
{videoUrl ? (
<video
autoPlay={payload.autoplay !== false}
@@ -529,42 +545,44 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
<div className="absolute inset-0 z-10 bg-black transition-opacity" style={{ opacity: overlayOpacity / 100 }}></div>
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center">
{tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-brand-accent drop-shadow-md">
{tagline}
</p>
)}
{title && (
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
{renderAccentTitle(title)}
</h1>
)}
{description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
{description}
</p>
)}
{(buttonText || secondaryButtonText) && (
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
{buttonText && buttonLink && (
<Link
href={buttonLink}
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-brand-primary hover:text-white transition-all shadow-lg rounded-sm"
>
{buttonText}
</Link>
)}
{secondaryButtonText && secondaryButtonLink && (
<Link
href={secondaryButtonLink}
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
>
{secondaryButtonText}
</Link>
)}
</div>
)}
<div className={`relative z-20 mx-auto px-6 py-16 w-full max-w-5xl h-full flex flex-col ${wrapperClasses}`}>
<div className={`flex flex-col space-y-6 max-w-3xl ${contentClasses}`}>
{tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-brand-accent drop-shadow-md">
{tagline}
</p>
)}
{title && (
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
{renderAccentTitle(title)}
</h1>
)}
{description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
{description}
</p>
)}
{(buttonText || secondaryButtonText) && (
<div className={`flex flex-wrap gap-4 pt-4 ${actionsClasses}`}>
{buttonText && buttonLink && (
<Link
href={buttonLink}
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-brand-primary hover:text-white transition-all shadow-lg rounded-sm"
>
{buttonText}
</Link>
)}
{secondaryButtonText && secondaryButtonLink && (
<Link
href={secondaryButtonLink}
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
>
{secondaryButtonText}
</Link>
)}
</div>
)}
</div>
</div>
</section>
)
@@ -1103,8 +1121,12 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center-left'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const { wrapperClasses: bWrap, contentClasses: bContent, actionsClasses: bActions } = getHeroPositionClasses(mobilePosition, desktopPosition)
const content = (
<div className="ui-banner-shell">
<div className={`ui-banner-shell flex flex-col ${bWrap}`}>
<Image
src={imageUrl}
alt={title || 'Banner'}
@@ -1114,10 +1136,14 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
<div className="ui-banner-overlay" />
<div className="ui-banner-content">
<div className={`ui-banner-content flex flex-col space-y-4 max-w-2xl ${bContent}`}>
{title && <h2 className="text-3xl font-semibold text-brand-primary drop-shadow-md md:text-5xl">{title}</h2>}
{description && <p className="text-surface-page/90 drop-shadow-sm">{description}</p>}
{buttonText && <span className="inline-flex cursor-pointer rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main transition-colors hover:bg-brand-primary hover:text-surface-card">{buttonText}</span>}
{buttonText && (
<div className={`flex ${bActions}`}>
<span className="inline-flex cursor-pointer rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main transition-colors hover:bg-brand-primary hover:text-surface-card">{buttonText}</span>
</div>
)}
</div>
</div>
)
+26 -10
View File
@@ -599,6 +599,24 @@ export interface CategorySection {
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const ContentPositionSchema = z.enum([
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right'
]);
export type ContentPosition = z.infer<typeof ContentPositionSchema>;
export const OptionalContentPositionSchema = z
.union([ContentPositionSchema, z.literal(''), z.null()])
.optional()
.transform((val) => (val ? (val as ContentPosition) : undefined));
export const DesktopContentPositionSchema = (fallback: ContentPosition) =>
z
.union([ContentPositionSchema, z.literal(''), z.null()])
.optional()
.transform((val) => (val ? (val as ContentPosition) : fallback));
export const HeroSectionPayloadSchema = z.object({
tagline: LocalizedStringSchema.optional(),
title: LocalizedStringSchema.optional(),
@@ -607,16 +625,8 @@ export const HeroSectionPayloadSchema = z.object({
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: z.enum([
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right'
]).optional().default('center'),
mobileButtonPosition: z.enum([
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right'
]).optional(),
buttonPosition: DesktopContentPositionSchema('center'),
mobileButtonPosition: OptionalContentPositionSchema,
overlayStyle: z.enum(['gradient', 'solid', 'none']).optional().default('gradient'),
overlayOpacity: z.number().min(0).max(100).optional(),
});
@@ -649,6 +659,8 @@ export const BannerSectionPayloadSchema = z.object({
description: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('center-left'),
mobileButtonPosition: OptionalContentPositionSchema,
});
export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>;
@@ -725,6 +737,8 @@ export const HeroVideoSectionPayloadSchema = z.object({
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('center'),
mobileButtonPosition: OptionalContentPositionSchema,
});
export type HeroVideoSectionPayload = z.infer<typeof HeroVideoSectionPayloadSchema>;
@@ -754,6 +768,8 @@ export const HeroCarouselSlideSchema = z.object({
subtitle: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('bottom-left'),
mobileButtonPosition: OptionalContentPositionSchema,
});
export type HeroCarouselSlide = z.infer<typeof HeroCarouselSlideSchema>;