This commit is contained in:
Your Name committed 2026-10-04 19:56:23 +07:00
1 parent e349ef5b84
commit 8d415ecef7
14 files changed
+321 -68

No files matched your search

@@ -265,6 +265,10 @@ export default function CategoryHeroCarouselBlockForm({
mobilePosition={getString(slide.mobileButtonPosition) || undefined} mobilePosition={getString(slide.mobileButtonPosition) || undefined}
onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })} onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })}
onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })} onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })}
contentWidth={getString(slide.contentWidth, 'standard')}
onChangeContentWidth={(w) => updateSlide(slideIndex, { contentWidth: w })}
textColorScheme={getString(slide.textColorScheme, 'light')}
onChangeTextColorScheme={(scheme) => updateSlide(slideIndex, { textColorScheme: scheme })}
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)" title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
/> />
</article> </article>
@@ -106,6 +106,10 @@ export default function BannerForm({
mobilePosition={editingSection.payload?.mobileButtonPosition} mobilePosition={editingSection.payload?.mobileButtonPosition}
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)} onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)} onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
contentWidth={editingSection.payload?.contentWidth}
onChangeContentWidth={(w) => updatePayloadValue('contentWidth', w)}
textColorScheme={editingSection.payload?.textColorScheme}
onChangeTextColorScheme={(scheme) => updatePayloadValue('textColorScheme', scheme)}
/> />
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import { Monitor, Smartphone, RotateCcw } from 'lucide-react'; import { Monitor, Smartphone, RotateCcw, Maximize2, Sun, Moon, Palette } from 'lucide-react';
export const GRID_POSITIONS = [ export const GRID_POSITIONS = [
{ id: 'top-left', label: 'Top Left (บนซ้าย)', short: 'TL', icon: '↖' }, { id: 'top-left', label: 'Top Left (บนซ้าย)', short: 'TL', icon: '↖' },
@@ -12,12 +12,39 @@ export const GRID_POSITIONS = [
{ id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' }, { id: 'bottom-right', label: 'Bottom Right (ล่างขวา)', short: 'BR', icon: '↘' },
]; ];
export const WIDTH_OPTIONS = [
{ id: 'full', label: 'ขอบนอกสุด (Full Bleed)', desc: 'เต็มความกว้างจอ ชิดขอบซ้าย/ขวา' },
{ id: 'standard', label: 'กรอบมาตรฐาน (1400px)', desc: 'ตามระยะเว็บหลัก max-w-7xl (แนะนำ)' },
{ id: 'compact', label: 'กรอบกระชับ (768px)', desc: 'กล่องกะทัดรัด max-w-3xl' },
] as const;
export const COLOR_SCHEME_OPTIONS = [
{
id: 'light',
label: 'สว่าง (Light / ตัวอักษรสีขาว)',
desc: 'สำหรับภาพพื้นหลังโทนเข้ม หรือมี Overlay มืด (ค่าเริ่มต้น)',
icon: Sun,
badgeBg: 'bg-white/10 text-white border-white/20',
},
{
id: 'dark',
label: 'มืด (Dark / ตัวอักษรสีเข้ม)',
desc: 'สำหรับภาพพื้นหลังโทนขาว หรือภาพสว่างสดใส',
icon: Moon,
badgeBg: 'bg-neutral-800 text-slate-200 border-white/10',
},
] as const;
export interface ContentPositionPickerProps { export interface ContentPositionPickerProps {
desktopPosition?: string; desktopPosition?: string;
mobilePosition?: string; mobilePosition?: string;
defaultDesktop?: string; defaultDesktop?: string;
onChangeDesktop: (position: string) => void; onChangeDesktop: (position: string) => void;
onChangeMobile: (position: string | undefined) => void; onChangeMobile: (position: string | undefined) => void;
contentWidth?: string;
onChangeContentWidth?: (width: string) => void;
textColorScheme?: 'light' | 'dark' | string;
onChangeTextColorScheme?: (scheme: 'light' | 'dark') => void;
title?: string; title?: string;
subtitle?: string; subtitle?: string;
compact?: boolean; compact?: boolean;
@@ -29,6 +56,10 @@ export default function ContentPositionPicker({
defaultDesktop = 'center', defaultDesktop = 'center',
onChangeDesktop, onChangeDesktop,
onChangeMobile, onChangeMobile,
contentWidth,
onChangeContentWidth,
textColorScheme,
onChangeTextColorScheme,
title = 'ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position)', title = 'ตำแหน่งปุ่ม & เนื้อหา (Button & Content Position)',
subtitle = 'สามารถแยกตำแหน่งระหว่าง Desktop (PC) และ Mobile (มือถือ) ได้อิสระ', subtitle = 'สามารถแยกตำแหน่งระหว่าง Desktop (PC) และ Mobile (มือถือ) ได้อิสระ',
compact = false, compact = false,
@@ -148,6 +179,82 @@ export default function ContentPositionPicker({
</div> </div>
</div> </div>
</div> </div>
{/* Container Width / Outer Boundary Selection */}
{onChangeContentWidth && (
<div className="bg-night-950/40 border border-white/5 p-3 rounded-2xl space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300 flex items-center gap-1.5">
<Maximize2 className="w-3.5 h-3.5 text-cyan-glow" />
ความกว้างกรอบเนื้อหา (Content Width / Boundary)
</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">
{contentWidth || 'standard'}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{WIDTH_OPTIONS.map((opt) => {
const isSelected = (contentWidth || 'standard') === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChangeContentWidth(opt.id)}
className={`px-3 py-2 rounded-xl text-left transition-all border cursor-pointer ${
isSelected
? 'bg-cyan-glow/15 border-cyan-glow/50 text-white shadow-sm ring-1 ring-cyan-glow/30'
: 'bg-night-900 border-white/5 text-slate-400 hover:text-white hover:border-white/10'
}`}
>
<div className="text-xs font-bold leading-tight">{opt.label}</div>
<div className="text-[9px] text-slate-500 mt-0.5 leading-tight">{opt.desc}</div>
</button>
);
})}
</div>
</div>
)}
{/* Text Color Scheme Preset Selection */}
{onChangeTextColorScheme && (
<div className="bg-night-950/40 border border-white/5 p-3 rounded-2xl space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300 flex items-center gap-1.5">
<Palette className="w-3.5 h-3.5 text-pop-pink" />
โทนสีตัวอักษร (Text Color Scheme Preset)
</span>
<span className={`px-2 py-0.5 rounded-md font-mono font-bold text-[10px] border ${(textColorScheme || 'light') === 'dark' ? 'bg-amber-400/10 text-amber-300 border-amber-400/20' : 'bg-cyan-glow/10 text-cyan-glow border-cyan-glow/20'}`}>
{(textColorScheme || 'light').toUpperCase()}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{COLOR_SCHEME_OPTIONS.map((opt) => {
const isSelected = (textColorScheme || 'light') === opt.id;
const Icon = opt.icon;
return (
<button
key={opt.id}
type="button"
onClick={() => onChangeTextColorScheme(opt.id as 'light' | 'dark')}
className={`px-3 py-2.5 rounded-xl text-left transition-all border cursor-pointer flex items-start gap-2.5 ${
isSelected
? 'bg-pop-pink/15 border-pop-pink/50 text-white shadow-sm ring-1 ring-pop-pink/30'
: 'bg-night-900 border-white/5 text-slate-400 hover:text-white hover:border-white/10'
}`}
>
<div className={`p-1.5 rounded-lg border shrink-0 mt-0.5 ${opt.badgeBg}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="text-xs font-bold leading-tight">{opt.label}</div>
<div className="text-[9px] text-slate-500 mt-0.5 leading-tight">{opt.desc}</div>
</div>
</button>
);
})}
</div>
</div>
)}
</div> </div>
); );
} }
@@ -381,6 +381,10 @@ export default function HeroCarouselForm({
mobilePosition={slide.mobileButtonPosition} mobilePosition={slide.mobileButtonPosition}
onChangeDesktop={(pos) => updateSlidePayloadValue(idx, 'buttonPosition', pos)} onChangeDesktop={(pos) => updateSlidePayloadValue(idx, 'buttonPosition', pos)}
onChangeMobile={(pos) => updateSlidePayloadValue(idx, 'mobileButtonPosition', pos || '')} onChangeMobile={(pos) => updateSlidePayloadValue(idx, 'mobileButtonPosition', pos || '')}
contentWidth={slide.contentWidth}
onChangeContentWidth={(w) => updateSlidePayloadValue(idx, 'contentWidth', w)}
textColorScheme={slide.textColorScheme}
onChangeTextColorScheme={(scheme) => updateSlidePayloadValue(idx, 'textColorScheme', scheme)}
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)" title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)"
/> />
</div> </div>
@@ -225,13 +225,17 @@ export default function HeroForm({
/> />
</div> </div>
{/* 9-Box Grid Button Position Selector (Desktop & Mobile) */} {/* 9-Box Grid Button Position & Content Width & Text Color Scheme Selector */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<ContentPositionPicker <ContentPositionPicker
desktopPosition={editingSection.payload?.buttonPosition} desktopPosition={editingSection.payload?.buttonPosition}
mobilePosition={editingSection.payload?.mobileButtonPosition} mobilePosition={editingSection.payload?.mobileButtonPosition}
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)} onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)} onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
contentWidth={editingSection.payload?.contentWidth}
onChangeContentWidth={(w) => updatePayloadValue('contentWidth', w)}
textColorScheme={editingSection.payload?.textColorScheme}
onChangeTextColorScheme={(scheme) => updatePayloadValue('textColorScheme', scheme)}
/> />
</div> </div>
</div> </div>
@@ -293,12 +293,16 @@ export default function HeroVideoForm({
</div> </div>
</div> </div>
{/* Button & Content Position (9-Box Grid for Desktop & Mobile) */} {/* Button & Content Position (9-Box Grid for Desktop & Mobile) & Color Scheme */}
<ContentPositionPicker <ContentPositionPicker
desktopPosition={payload.buttonPosition} desktopPosition={payload.buttonPosition}
mobilePosition={payload.mobileButtonPosition} mobilePosition={payload.mobileButtonPosition}
onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)} onChangeDesktop={(pos) => updatePayloadValue('buttonPosition', pos)}
onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)} onChangeMobile={(pos) => updatePayloadValue('mobileButtonPosition', pos)}
contentWidth={payload.contentWidth}
onChangeContentWidth={(w) => updatePayloadValue('contentWidth', w)}
textColorScheme={payload.textColorScheme}
onChangeTextColorScheme={(scheme) => updatePayloadValue('textColorScheme', scheme)}
/> />
</div> </div>
); );
@@ -265,6 +265,10 @@ export default function ProductHeroCarouselBlockForm({
mobilePosition={getString(slide.mobileButtonPosition) || undefined} mobilePosition={getString(slide.mobileButtonPosition) || undefined}
onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })} onChangeDesktop={(pos) => updateSlide(slideIndex, { buttonPosition: pos })}
onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })} onChangeMobile={(pos) => updateSlide(slideIndex, { mobileButtonPosition: pos || '' })}
contentWidth={getString(slide.contentWidth, 'standard')}
onChangeContentWidth={(w) => updateSlide(slideIndex, { contentWidth: w })}
textColorScheme={getString(slide.textColorScheme, 'light')}
onChangeTextColorScheme={(scheme) => updateSlide(slideIndex, { textColorScheme: scheme })}
title="ตำแหน่งปุ่ม & ข้อความของสไลด์นี้ (Slide Content Position)" 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: resilient position schemas with empty string handling // Trigger reload: resilient position and contentWidth support
import { import {
LocalizedStringSchema, LocalizedStringSchema,
HeroSectionPayloadSchema, HeroSectionPayloadSchema,
+15 -6
View File
@@ -20,8 +20,17 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
const desktopPosition = payload.buttonPosition || 'center-left'; const desktopPosition = payload.buttonPosition || 'center-left';
const mobilePosition = payload.mobileButtonPosition || desktopPosition; const mobilePosition = payload.mobileButtonPosition || desktopPosition;
const contentWidth = (payload as any).contentWidth || 'standard';
const textColorScheme = (payload as any).textColorScheme || 'light';
const isDarkText = textColorScheme === 'dark';
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition); const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
const shellWidthClass = contentWidth === 'full'
? '!max-w-none !rounded-none md:!rounded-3xl mx-4 md:mx-8'
: contentWidth === 'compact'
? '!max-w-4xl'
: '!max-w-7xl';
const bannerInner = ( const bannerInner = (
<> <>
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
@@ -31,23 +40,23 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
alt={title || "Banner"} alt={title || "Banner"}
style={imageStyle} style={imageStyle}
/> />
<div className="ui-banner-overlay"></div> <div className={isDarkText ? "absolute inset-0 bg-white/40 backdrop-blur-[1px]" : "ui-banner-overlay"}></div>
</div> </div>
<div className={`ui-banner-content flex flex-col ${contentClasses}`}> <div className={`ui-banner-content flex flex-col ${contentClasses}`}>
{title && ( {title && (
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl"> <h3 className={`font-serif text-3xl font-light leading-tight md:text-5xl ${isDarkText ? '!text-pop-black' : 'text-pop-pink'}`}>
{title} {title}
</h3> </h3>
)} )}
{description && ( {description && (
<p className="font-light text-base text-pop-bg/95 md:text-lg"> <p className={`font-light text-base md:text-lg ${isDarkText ? '!text-pop-black/80' : 'text-pop-bg/95'}`}>
{description} {description}
</p> </p>
)} )}
{buttonText && ( {buttonText && (
<div className={`pt-4 flex ${actionsClasses}`}> <div className={`pt-4 flex ${actionsClasses}`}>
<span className="ui-button-secondary"> <span className={isDarkText ? "ui-button-primary" : "ui-button-secondary"}>
{buttonText} {buttonText}
</span> </span>
</div> </div>
@@ -59,11 +68,11 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
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 flex flex-col ${wrapperClasses} cursor-pointer`}> <Link to={linkUrl} className={`ui-banner-shell group flex flex-col ${shellWidthClass} ${wrapperClasses} cursor-pointer`}>
{bannerInner} {bannerInner}
</Link> </Link>
) : ( ) : (
<div className={`ui-banner-shell group flex flex-col ${wrapperClasses} cursor-default`}> <div className={`ui-banner-shell group flex flex-col ${shellWidthClass} ${wrapperClasses} cursor-default`}>
{bannerInner} {bannerInner}
</div> </div>
)} )}
+30 -10
View File
@@ -139,12 +139,25 @@ export function getHeroPositionClasses(mobilePos: string, desktopPos: string) {
}; };
} }
export function getHeroContainerWidthClasses(contentWidth?: string) {
switch (contentWidth) {
case 'full':
return 'w-full max-w-none px-6 md:px-12 lg:px-16';
case 'compact':
return 'w-full max-w-4xl mx-auto px-6';
case 'standard':
default:
return 'w-full max-w-7xl mx-auto px-6 md:px-10 lg:px-12';
}
}
export default function DynamicHero({ payload }: DynamicHeroProps) { export default function DynamicHero({ payload }: DynamicHeroProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const { imageUrl, buttonLink } = payload; const { imageUrl, buttonLink } = payload;
const desktopPosition = (payload as any).buttonPosition || 'center'; const desktopPosition = (payload as any).buttonPosition || 'center';
const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition; const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition;
const contentWidth = (payload as any).contentWidth || 'standard';
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined; const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined;
// Localized values // Localized values
@@ -167,6 +180,9 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
? rawButtonText.trim() ? rawButtonText.trim()
: t('hero.exploreCollection'); : t('hero.exploreCollection');
const textColorScheme = (payload as any).textColorScheme || 'light';
const isDarkText = textColorScheme === 'dark';
const renderTitle = (text: string) => { const renderTitle = (text: string) => {
if (!text) return null; if (!text) return null;
return text.split('[').map((part, i) => { return text.split('[').map((part, i) => {
@@ -174,7 +190,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
const [accent, rest] = part.split(']'); const [accent, rest] = part.split(']');
return ( return (
<span key={i}> <span key={i}>
<span className="text-pop-yellow font-medium italic"> <span className={`${isDarkText ? 'text-pop-pink' : 'text-pop-yellow'} font-medium italic`}>
{accent} {accent}
</span> </span>
{rest} {rest}
@@ -201,7 +217,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
return ( return (
<div <div
className="absolute inset-0 transition-opacity duration-300" className="absolute inset-0 transition-opacity duration-300"
style={{ backgroundColor: `rgba(0, 0, 0, ${opacityRatio})` }} style={{ backgroundColor: isDarkText ? `rgba(255, 255, 255, ${opacityRatio})` : `rgba(0, 0, 0, ${opacityRatio})` }}
/> />
); );
} }
@@ -211,18 +227,22 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
const midAlpha = opacityRatio; const midAlpha = opacityRatio;
const topAlpha = Math.max(0, opacityRatio * 0.65); const topAlpha = Math.max(0, opacityRatio * 0.65);
const rgb = isDarkText ? '255, 255, 255' : '0, 0, 0';
return ( return (
<div <div
className="absolute inset-0 transition-opacity duration-300" className="absolute inset-0 transition-opacity duration-300"
style={{ style={{
background: `linear-gradient(to top, rgba(0, 0, 0, ${bottomAlpha}) 0%, rgba(0, 0, 0, ${midAlpha}) 50%, rgba(0, 0, 0, ${topAlpha}) 100%)`, background: `linear-gradient(to top, rgba(${rgb}, ${bottomAlpha}) 0%, rgba(${rgb}, ${midAlpha}) 50%, rgba(${rgb}, ${topAlpha}) 100%)`,
}} }}
/> />
); );
}; };
const containerClasses = getHeroContainerWidthClasses(contentWidth);
return ( return (
<section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex bg-night-950 overflow-hidden"> <section className={`relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<div className="absolute inset-0 w-full h-full z-0"> <div className="absolute inset-0 w-full h-full z-0">
<img <img
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`} src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
@@ -233,24 +253,24 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} }}
/> />
{/* Dynamic Dark Gradient / Solid Overlay */} {/* Dynamic Dark / Light Gradient / Solid Overlay */}
{renderOverlay()} {renderOverlay()}
</div> </div>
<div className={`relative z-10 w-full min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${wrapperClasses} animate-fade-in`}> <div className={`relative z-10 min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${containerClasses} ${wrapperClasses} animate-fade-in`}>
<div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}> <div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}>
{tagline && ( {tagline && (
<p className="text-pop-yellow font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md"> <p className={`${isDarkText ? 'text-pop-pink' : 'text-pop-yellow'} font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md`}>
{tagline} {tagline}
</p> </p>
)} )}
{title && ( {title && (
<h1 className="ui-hero-title !text-white drop-shadow-lg"> <h1 className={`ui-hero-title ${isDarkText ? '!text-pop-black' : '!text-white'} drop-shadow-lg`}>
{renderTitle(title)} {renderTitle(title)}
</h1> </h1>
)} )}
{description && ( {description && (
<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'}`}> <p className={`ui-hero-copy ${isDarkText ? '!text-pop-black/85' : '!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>
)} )}
@@ -265,7 +285,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
)} )}
<a <a
href="#categories" href="#categories"
className="ui-dark-link group flex items-center gap-1.5 drop-shadow-md hover:underline" className={`group flex items-center gap-1.5 drop-shadow-md hover:underline ${isDarkText ? 'text-pop-black hover:text-pop-pink' : 'ui-dark-link'}`}
> >
<span>{t('hero.learnMore')}</span> <span>{t('hero.learnMore')}</span>
<span className="group-hover:translate-x-1 transition-transform">&rarr;</span> <span className="group-hover:translate-x-1 transition-transform">&rarr;</span>
@@ -4,7 +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 { getHeroPositionClasses, getHeroContainerWidthClasses } from './DynamicHero';
// Import Swiper styles // Import Swiper styles
import 'swiper/css'; import 'swiper/css';
@@ -119,18 +119,23 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
{(slide.title || slide.subtitle || slide.buttonText) && (() => { {(slide.title || slide.subtitle || slide.buttonText) && (() => {
const slideDesktopPos = (slide as any).buttonPosition || 'bottom-left'; const slideDesktopPos = (slide as any).buttonPosition || 'bottom-left';
const slideMobilePos = (slide as any).mobileButtonPosition || slideDesktopPos; const slideMobilePos = (slide as any).mobileButtonPosition || slideDesktopPos;
const slideWidth = (slide as any).contentWidth || 'standard';
const slideColor = (slide as any).textColorScheme || 'light';
const isDarkSlide = slideColor === 'dark';
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(slideMobilePos, slideDesktopPos); const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(slideMobilePos, slideDesktopPos);
const containerClasses = getHeroContainerWidthClasses(slideWidth);
return ( 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={`absolute inset-0 z-10 flex flex-col py-6 md:py-10 ${isDarkSlide ? 'bg-gradient-to-t from-white/80 via-white/30 to-transparent' : 'bg-gradient-to-t from-black/70 via-black/20 to-transparent'} ${wrapperClasses}`}>
<div className={`max-w-xl space-y-1.5 md:space-y-2 flex flex-col ${contentClasses}`}> <div className={`${containerClasses} flex flex-col ${contentClasses}`}>
<div className={`space-y-1.5 md:space-y-2 flex flex-col ${contentClasses} ${slideWidth === 'full' ? 'max-w-2xl' : 'max-w-xl'}`}>
{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 drop-shadow-md ${isDarkSlide ? 'text-pop-pink' : 'text-pop-yellow'}`}>
{slide.subtitle} {slide.subtitle}
</p> </p>
)} )}
{slide.title && ( {slide.title && (
<h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight"> <h3 className={`text-lg md:text-3xl font-bold drop-shadow-md leading-tight ${isDarkSlide ? 'text-pop-black' : 'text-white'}`}>
{renderTitle(slide.title)} {renderTitle(slide.title)}
</h3> </h3>
)} )}
@@ -143,6 +148,7 @@ export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProp
)} )}
</div> </div>
</div> </div>
</div>
); );
})()} })()}
</div> </div>
+28 -15
View File
@@ -2,7 +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'; import { getHeroPositionClasses, getHeroContainerWidthClasses } from './DynamicHero';
interface DynamicHeroVideoProps { interface DynamicHeroVideoProps {
payload: HeroVideoSectionPayload; payload: HeroVideoSectionPayload;
@@ -92,32 +92,36 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
) : null} ) : null}
{/* Customizable Overlay Opacity */} {/* Customizable Overlay Opacity */}
{(() => {
const textColorScheme = (payload as any).textColorScheme || 'light';
const isDarkText = textColorScheme === 'dark';
const desktopPosition = (payload as any).buttonPosition || 'center';
const mobilePosition = (payload as any).mobileButtonPosition || desktopPosition;
const contentWidth = (payload as any).contentWidth || 'standard';
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition);
const containerClasses = getHeroContainerWidthClasses(contentWidth);
return (
<>
<div <div
className="absolute inset-0 z-10 bg-black transition-opacity" className={`absolute inset-0 z-10 transition-opacity ${isDarkText ? 'bg-white' : 'bg-black'}`}
style={{ opacity: overlayOpacity / 100 }} style={{ opacity: overlayOpacity / 100 }}
></div> ></div>
{/* Overlay Typography Content */} <div className={`relative z-20 py-16 h-full flex flex-col animate-fade-in ${containerClasses} ${wrapperClasses}`}>
{(() => {
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}`}> <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 drop-shadow-md">
{tagline} {tagline}
</p> </p>
)} )}
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white"> <h1 className={`font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg ${isDarkText ? 'text-pop-black' : 'text-white'}`}>
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))} {renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
</h1> </h1>
{description && ( {description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow"> <p className={`max-w-2xl text-sm md:text-lg font-light leading-relaxed drop-shadow ${isDarkText ? 'text-pop-black/80' : 'text-white/90'}`}>
{description} {description}
</p> </p>
)} )}
@@ -128,7 +132,11 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
{buttonText && payload.buttonLink && ( {buttonText && payload.buttonLink && (
<Link <Link
to={payload.buttonLink} 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" className={`px-8 py-3.5 font-medium text-xs uppercase tracking-widest transition-all shadow-lg rounded-sm ${
isDarkText
? 'bg-neutral-900 text-white hover:bg-pop-pink'
: 'bg-white text-neutral-900 hover:bg-pop-pink hover:text-white'
}`}
> >
{buttonText} {buttonText}
</Link> </Link>
@@ -137,7 +145,11 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
{secondaryButtonText && payload.secondaryButtonLink && ( {secondaryButtonText && payload.secondaryButtonLink && (
<Link <Link
to={payload.secondaryButtonLink} 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" className={`px-8 py-3.5 bg-transparent border font-medium text-xs uppercase tracking-widest transition-all rounded-sm backdrop-blur-sm ${
isDarkText
? 'border-neutral-900/40 text-neutral-900 hover:bg-neutral-900/10 hover:border-neutral-900'
: 'border-white/40 text-white hover:bg-white/10 hover:border-white'
}`}
> >
{secondaryButtonText} {secondaryButtonText}
</Link> </Link>
@@ -146,6 +158,7 @@ export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
)} )}
</div> </div>
</div> </div>
</>
); );
})()} })()}
</section> </section>
@@ -227,6 +227,18 @@ function getHeroPositionClasses(mobilePos: string, desktopPos: string) {
} }
} }
function getHeroContainerWidthClasses(contentWidth?: string) {
switch (contentWidth) {
case 'full':
return 'w-full max-w-none px-6 md:px-12 lg:px-16'
case 'compact':
return 'w-full max-w-4xl mx-auto px-6'
case 'standard':
default:
return 'w-full max-w-7xl mx-auto px-6 md:px-10 lg:px-12'
}
}
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const i18nLang = useLang() const i18nLang = useLang()
@@ -239,6 +251,9 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop' const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center' const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const contentWidth = typeof payload.contentWidth === 'string' ? payload.contentWidth : 'standard'
const textColorScheme = typeof payload.textColorScheme === 'string' ? payload.textColorScheme : 'light'
const isDarkText = textColorScheme === 'dark'
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
// If fields are explicitly defined as empty strings, respect them as empty // If fields are explicitly defined as empty strings, respect them as empty
@@ -256,6 +271,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
: 'Explore Collection' : 'Explore Collection'
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition) const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition)
const containerClasses = getHeroContainerWidthClasses(contentWidth)
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
@@ -266,12 +282,13 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
} }
const opacityRatio = overlayOpacity / 100 const opacityRatio = overlayOpacity / 100
const rgb = isDarkText ? '255, 255, 255' : '0, 0, 0'
if (overlayStyle === 'solid') { if (overlayStyle === 'solid') {
return ( return (
<div <div
className="absolute inset-0 transition-opacity duration-300" className="absolute inset-0 transition-opacity duration-300"
style={{ backgroundColor: `rgba(0, 0, 0, ${opacityRatio})` }} style={{ backgroundColor: `rgba(${rgb}, ${opacityRatio})` }}
/> />
) )
} }
@@ -285,14 +302,14 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
<div <div
className="absolute inset-0 transition-opacity duration-300" className="absolute inset-0 transition-opacity duration-300"
style={{ style={{
background: `linear-gradient(to top, rgba(0, 0, 0, ${bottomAlpha}) 0%, rgba(0, 0, 0, ${midAlpha}) 50%, rgba(0, 0, 0, ${topAlpha}) 100%)`, background: `linear-gradient(to top, rgba(${rgb}, ${bottomAlpha}) 0%, rgba(${rgb}, ${midAlpha}) 50%, rgba(${rgb}, ${topAlpha}) 100%)`,
}} }}
/> />
) )
} }
return ( return (
<section className="relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex bg-night-950 overflow-hidden"> <section className={`relative w-full min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9] flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<div className="absolute inset-0 w-full h-full z-0"> <div className="absolute inset-0 w-full h-full z-0">
<Image <Image
src={imageUrl} src={imageUrl}
@@ -302,23 +319,23 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
className="object-cover md:scale-[1.02]" className="object-cover md:scale-[1.02]"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }} style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/> />
{/* Dynamic Dark Gradient / Solid Overlay */} {/* Dynamic Dark / Light Gradient / Solid Overlay */}
{renderOverlay()} {renderOverlay()}
</div> </div>
<div className={`relative z-10 w-full min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${wrapperClasses}`}> <div className={`relative z-10 min-h-[85vh] sm:min-h-[90vh] md:min-h-full flex flex-col ${containerClasses} ${wrapperClasses}`}>
<div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}> <div className={`w-full max-w-3xl flex flex-col space-y-5 ${contentClasses}`}>
{tagline && ( {tagline && (
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-brand-accent drop-shadow-md md:text-sm"> <p className={`text-xs font-semibold uppercase tracking-[0.3em] drop-shadow-md md:text-sm ${isDarkText ? 'text-brand-primary' : 'text-brand-accent'}`}>
{tagline} {tagline}
</p> </p>
)} )}
{title && ( {title && (
<h1 className="ui-hero-title !text-white drop-shadow-lg"> <h1 className={`ui-hero-title ${isDarkText ? '!text-text-main' : '!text-white'} drop-shadow-lg`}>
{renderAccentTitle(title, 'ui-accent-text font-medium')} {renderAccentTitle(title, isDarkText ? 'text-brand-primary font-medium' : 'ui-accent-text font-medium')}
</h1> </h1>
)} )}
{description && ( {description && (
<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'}`}> <p className={`ui-hero-copy ${isDarkText ? '!text-text-main/80' : '!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>
)} )}
@@ -333,7 +350,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
)} )}
<a <a
href="#categories" href="#categories"
className="ui-hero-link group flex items-center gap-1.5 drop-shadow-md hover:underline" className={`group flex items-center gap-1.5 drop-shadow-md hover:underline ${isDarkText ? '!text-text-main hover:!text-brand-primary' : 'ui-hero-link'}`}
> >
<span>{t('dynamic.heroLearnMore', { lng: resolvedLang })}</span> <span>{t('dynamic.heroLearnMore', { lng: resolvedLang })}</span>
<span className="transition-transform group-hover:translate-x-1">→</span> <span className="transition-transform group-hover:translate-x-1">→</span>
@@ -438,12 +455,17 @@ function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknow
{(title || subtitle || buttonText) && (() => { {(title || subtitle || buttonText) && (() => {
const slideDesktopPos = typeof slide.buttonPosition === 'string' ? slide.buttonPosition : 'bottom-left' const slideDesktopPos = typeof slide.buttonPosition === 'string' ? slide.buttonPosition : 'bottom-left'
const slideMobilePos = typeof slide.mobileButtonPosition === 'string' ? slide.mobileButtonPosition : slideDesktopPos const slideMobilePos = typeof slide.mobileButtonPosition === 'string' ? slide.mobileButtonPosition : slideDesktopPos
const slideWidth = typeof slide.contentWidth === 'string' ? slide.contentWidth : 'standard'
const slideColor = typeof slide.textColorScheme === 'string' ? slide.textColorScheme : 'light'
const isDarkSlide = slideColor === 'dark'
const { wrapperClasses: sWrap, contentClasses: sContent, actionsClasses: sActions } = getHeroPositionClasses(slideMobilePos, slideDesktopPos) const { wrapperClasses: sWrap, contentClasses: sContent, actionsClasses: sActions } = getHeroPositionClasses(slideMobilePos, slideDesktopPos)
const sContainer = getHeroContainerWidthClasses(slideWidth)
return ( 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={`absolute inset-0 z-10 flex flex-col py-6 md:py-10 ${isDarkSlide ? 'bg-gradient-to-t from-white/80 via-white/30 to-transparent' : 'bg-gradient-to-t from-black/70 via-black/20 to-transparent'} ${sWrap}`}>
<div className={`max-w-xl space-y-1.5 md:space-y-2 flex flex-col ${sContent}`}> <div className={`w-full ${sContainer} 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>} <div className={`space-y-1.5 md:space-y-2 flex flex-col ${sContent} ${slideWidth === 'full' ? 'max-w-2xl' : 'max-w-xl'}`}>
{title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>} {subtitle && <p className={`text-[10px] md:text-xs uppercase font-bold tracking-widest drop-shadow-md ${isDarkSlide ? 'text-brand-primary' : 'text-brand-accent'}`}>{subtitle}</p>}
{title && <h3 className={`text-lg md:text-3xl font-bold drop-shadow-md leading-tight ${isDarkSlide ? 'text-text-main' : 'text-white'}`}>{renderAccentTitle(title)}</h3>}
{buttonText && ( {buttonText && (
<div className={`flex ${sActions}`}> <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">
@@ -453,6 +475,7 @@ function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknow
)} )}
</div> </div>
</div> </div>
</div>
) )
})()} })()}
</div> </div>
@@ -505,10 +528,14 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center' const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const contentWidth = typeof payload.contentWidth === 'string' ? payload.contentWidth : 'standard'
const textColorScheme = typeof payload.textColorScheme === 'string' ? payload.textColorScheme : 'light'
const isDarkText = textColorScheme === 'dark'
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition) const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition)
const containerClasses = getHeroContainerWidthClasses(contentWidth)
return ( return (
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex bg-[#0B0F17] text-white`}> <section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex ${isDarkText ? 'bg-surface-page text-text-main' : 'bg-[#0B0F17] text-white'}`}>
{videoUrl ? ( {videoUrl ? (
<video <video
autoPlay={payload.autoplay !== false} autoPlay={payload.autoplay !== false}
@@ -543,22 +570,22 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
/> />
) : null} ) : null}
<div className="absolute inset-0 z-10 bg-black transition-opacity" style={{ opacity: overlayOpacity / 100 }}></div> <div className={`absolute inset-0 z-10 transition-opacity ${isDarkText ? 'bg-white' : 'bg-black'}`} style={{ opacity: overlayOpacity / 100 }}></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={`relative z-20 py-16 h-full flex flex-col ${containerClasses} ${wrapperClasses}`}>
<div className={`flex flex-col space-y-6 max-w-3xl ${contentClasses}`}> <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] drop-shadow-md ${isDarkText ? 'text-brand-primary' : 'text-brand-accent'}`}>
{tagline} {tagline}
</p> </p>
)} )}
{title && ( {title && (
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white"> <h1 className={`font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg ${isDarkText ? 'text-text-main' : 'text-white'}`}>
{renderAccentTitle(title)} {renderAccentTitle(title, isDarkText ? 'text-brand-primary font-medium' : undefined)}
</h1> </h1>
)} )}
{description && ( {description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow"> <p className={`max-w-2xl text-sm md:text-lg font-light leading-relaxed drop-shadow ${isDarkText ? 'text-text-main/80' : 'opacity-90'}`}>
{description} {description}
</p> </p>
)} )}
@@ -567,7 +594,11 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
{buttonText && buttonLink && ( {buttonText && buttonLink && (
<Link <Link
href={buttonLink} 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" className={`px-8 py-3.5 font-medium text-xs uppercase tracking-widest transition-all shadow-lg rounded-sm ${
isDarkText
? 'bg-brand-primary text-white hover:bg-brand-primary-hover'
: 'bg-white text-neutral-900 hover:bg-brand-primary hover:text-white'
}`}
> >
{buttonText} {buttonText}
</Link> </Link>
@@ -575,7 +606,11 @@ function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>,
{secondaryButtonText && secondaryButtonLink && ( {secondaryButtonText && secondaryButtonLink && (
<Link <Link
href={secondaryButtonLink} 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" className={`px-8 py-3.5 bg-transparent border font-medium text-xs uppercase tracking-widest transition-all rounded-sm backdrop-blur-sm ${
isDarkText
? 'border-neutral-900/40 text-neutral-900 hover:bg-neutral-900/10 hover:border-neutral-900'
: 'border-white/40 text-white hover:bg-white/10 hover:border-white'
}`}
> >
{secondaryButtonText} {secondaryButtonText}
</Link> </Link>
@@ -1123,10 +1158,19 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center-left' const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center-left'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const contentWidth = typeof payload.contentWidth === 'string' ? payload.contentWidth : 'standard'
const textColorScheme = typeof payload.textColorScheme === 'string' ? payload.textColorScheme : 'light'
const isDarkText = textColorScheme === 'dark'
const { wrapperClasses: bWrap, contentClasses: bContent, actionsClasses: bActions } = getHeroPositionClasses(mobilePosition, desktopPosition) const { wrapperClasses: bWrap, contentClasses: bContent, actionsClasses: bActions } = getHeroPositionClasses(mobilePosition, desktopPosition)
const shellWidthClass = contentWidth === 'full'
? '!max-w-none !rounded-none md:!rounded-3xl mx-4 md:mx-8'
: contentWidth === 'compact'
? '!max-w-4xl'
: '!max-w-7xl'
const content = ( const content = (
<div className={`ui-banner-shell flex flex-col ${bWrap}`}> <div className={`ui-banner-shell flex flex-col ${shellWidthClass} ${bWrap}`}>
<Image <Image
src={imageUrl} src={imageUrl}
alt={title || 'Banner'} alt={title || 'Banner'}
@@ -1135,13 +1179,17 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }} style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/> />
<div className="ui-banner-overlay" /> <div className={isDarkText ? "absolute inset-0 bg-white/40 backdrop-blur-[1px]" : "ui-banner-overlay"} />
<div className={`ui-banner-content flex flex-col space-y-4 max-w-2xl ${bContent}`}> <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 drop-shadow-md md:text-5xl ${isDarkText ? '!text-text-main' : 'text-brand-primary'}`}>{title}</h2>}
{description && <p className="text-surface-page/90 drop-shadow-sm">{description}</p>} {description && <p className={`drop-shadow-sm ${isDarkText ? '!text-text-main/80' : 'text-surface-page/90'}`}>{description}</p>}
{buttonText && ( {buttonText && (
<div className={`flex ${bActions}`}> <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> <span className={`inline-flex cursor-pointer rounded-full px-6 py-3 font-semibold transition-colors ${
isDarkText
? 'bg-brand-primary text-white hover:bg-brand-primary-hover shadow-md'
: 'bg-surface-card text-text-main hover:bg-brand-primary hover:text-surface-card'
}`}>{buttonText}</span>
</div> </div>
)} )}
</div> </div>
+26
View File
@@ -617,6 +617,24 @@ export const DesktopContentPositionSchema = (fallback: ContentPosition) =>
.optional() .optional()
.transform((val) => (val ? (val as ContentPosition) : fallback)); .transform((val) => (val ? (val as ContentPosition) : fallback));
export const ContentWidthSchema = z.enum(['full', 'standard', 'compact']);
export type ContentWidth = z.infer<typeof ContentWidthSchema>;
export const DefaultContentWidthSchema = (fallback: ContentWidth = 'standard') =>
z
.union([ContentWidthSchema, z.literal(''), z.null()])
.optional()
.transform((val) => (val ? (val as ContentWidth) : fallback));
export const TextColorSchemeSchema = z.enum(['light', 'dark']);
export type TextColorScheme = z.infer<typeof TextColorSchemeSchema>;
export const DefaultTextColorSchemeSchema = (fallback: TextColorScheme = 'light') =>
z
.union([TextColorSchemeSchema, z.literal(''), z.null()])
.optional()
.transform((val) => (val ? (val as TextColorScheme) : fallback));
export const HeroSectionPayloadSchema = z.object({ export const HeroSectionPayloadSchema = z.object({
tagline: LocalizedStringSchema.optional(), tagline: LocalizedStringSchema.optional(),
title: LocalizedStringSchema.optional(), title: LocalizedStringSchema.optional(),
@@ -627,6 +645,8 @@ export const HeroSectionPayloadSchema = z.object({
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('center'), buttonPosition: DesktopContentPositionSchema('center'),
mobileButtonPosition: OptionalContentPositionSchema, mobileButtonPosition: OptionalContentPositionSchema,
contentWidth: DefaultContentWidthSchema('standard'),
textColorScheme: DefaultTextColorSchemeSchema('light'),
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(),
}); });
@@ -661,6 +681,8 @@ export const BannerSectionPayloadSchema = z.object({
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('center-left'), buttonPosition: DesktopContentPositionSchema('center-left'),
mobileButtonPosition: OptionalContentPositionSchema, mobileButtonPosition: OptionalContentPositionSchema,
contentWidth: DefaultContentWidthSchema('standard'),
textColorScheme: DefaultTextColorSchemeSchema('light'),
}); });
export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>; export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>;
@@ -739,6 +761,8 @@ export const HeroVideoSectionPayloadSchema = z.object({
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('center'), buttonPosition: DesktopContentPositionSchema('center'),
mobileButtonPosition: OptionalContentPositionSchema, mobileButtonPosition: OptionalContentPositionSchema,
contentWidth: DefaultContentWidthSchema('standard'),
textColorScheme: DefaultTextColorSchemeSchema('light'),
}); });
export type HeroVideoSectionPayload = z.infer<typeof HeroVideoSectionPayloadSchema>; export type HeroVideoSectionPayload = z.infer<typeof HeroVideoSectionPayloadSchema>;
@@ -770,6 +794,8 @@ export const HeroCarouselSlideSchema = z.object({
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(), focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
buttonPosition: DesktopContentPositionSchema('bottom-left'), buttonPosition: DesktopContentPositionSchema('bottom-left'),
mobileButtonPosition: OptionalContentPositionSchema, mobileButtonPosition: OptionalContentPositionSchema,
contentWidth: DefaultContentWidthSchema('standard'),
textColorScheme: DefaultTextColorSchemeSchema('light'),
}); });
export type HeroCarouselSlide = z.infer<typeof HeroCarouselSlideSchema>; export type HeroCarouselSlide = z.infer<typeof HeroCarouselSlideSchema>;