This commit is contained in:
Your Name committed 2026-09-25 07:28:27 +07:00
1 parent 3ac0cb4f53
commit 5e724fae20
17 files changed
+2404 -278

No files matched your search

+7 -1
View File
@@ -22,6 +22,9 @@
- "scrollRevealStyle": "fade-up" | "fade-in" | "slide-left" | "zoom-in" | "none" - "scrollRevealStyle": "fade-up" | "fade-in" | "slide-left" | "zoom-in" | "none"
- "scrollRevealDuration": number (เช่น 600, 700, 800) - "scrollRevealDuration": number (เช่น 600, 700, 800)
- "headerStyle": "single_line" | "two_tier" - "headerStyle": "single_line" | "two_tier"
- "headerBehavior": "hide_on_scroll" | "always_sticky"
- "mobileHeaderStyle": "logo_center" | "logo_left" | "search_bar_below"
- "mobileHeaderBehavior": "hide_on_scroll" | "always_sticky"
[รายละเอียดของ themeTokens แต่ละฟิลด์]: [รายละเอียดของ themeTokens แต่ละฟิลด์]:
• กลุ่มสีแบรนด์และไฮไลต์ (Brand & Accents): • กลุ่มสีแบรนด์และไฮไลต์ (Brand & Accents):
@@ -130,7 +133,10 @@
"scrollRevealEnabled": true, "scrollRevealEnabled": true,
"scrollRevealStyle": "fade-up", "scrollRevealStyle": "fade-up",
"scrollRevealDuration": 700, "scrollRevealDuration": 700,
"headerStyle": "single_line" "headerStyle": "single_line",
"headerBehavior": "hide_on_scroll",
"mobileHeaderStyle": "logo_center",
"mobileHeaderBehavior": "hide_on_scroll"
} }
``` ```
+3
View File
@@ -9,6 +9,7 @@ import CategoriesPage from './pages/CategoriesPage'
import CategoryEditPage from './pages/CategoryEditPage' import CategoryEditPage from './pages/CategoryEditPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import PageBuilderPage from './pages/PageBuilderPage' import PageBuilderPage from './pages/PageBuilderPage'
import PageReorderPage from './pages/PageReorderPage'
import CmsMockupPage from './pages/CmsMockupPage' import CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage' import RawMaterialsPage from './pages/RawMaterialsPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
@@ -98,6 +99,8 @@ function App() {
<Route path="/categories/:id/edit" element={<CategoryEditPage />} /> <Route path="/categories/:id/edit" element={<CategoryEditPage />} />
<Route path="/customers" element={<CustomersPage />} /> <Route path="/customers" element={<CustomersPage />} />
<Route path="/page-builder" element={<PageBuilderPage />} /> <Route path="/page-builder" element={<PageBuilderPage />} />
<Route path="/page-builder/:id/reorder" element={<PageReorderPage />} />
<Route path="/pages/:id/reorder" element={<PageReorderPage />} />
<Route path="/cms-mockup" element={<CmsMockupPage />} /> <Route path="/cms-mockup" element={<CmsMockupPage />} />
<Route path="/orders" element={<OrdersPage />} /> <Route path="/orders" element={<OrdersPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} /> <Route path="/orders/:id" element={<OrderDetailPage />} />
@@ -111,6 +111,38 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
</div> </div>
) )
}, },
// 2.8 HERO_CAROUSEL (Wide Promo Slider)
{
type: 'HERO_CAROUSEL',
category: 'hero',
title: 'HERO CAROUSEL (Wide Promo Slider)',
description: 'BEAUTRIUM-style edge-to-edge promotional banner carousel with compact height, autoplay, and navigation dots.',
badge: 'New · Beautrium',
renderPreview: () => (
<div className="relative w-full aspect-[21/7] rounded-xl overflow-hidden bg-slate-900 flex items-center justify-between px-2 text-center border border-cyan-glow/20">
<img
src="https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=400&q=80"
alt="promo banner slider"
className="absolute inset-0 w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-transparent to-black/60 z-10" />
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">‹</div>
<div className="relative z-20 space-y-0.5">
<span className="text-[6px] uppercase font-bold text-amber-300 tracking-widest block drop-shadow">MEGA PROMO DEALS</span>
<h5 className="text-[9px] font-extrabold text-white drop-shadow leading-tight">SUMMER SALE UP TO [70% OFF]</h5>
<div className="flex gap-1 justify-center pt-1">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-glow" />
<span className="w-1.5 h-1.5 rounded-full bg-white/40" />
<span className="w-1.5 h-1.5 rounded-full bg-white/40" />
</div>
</div>
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">›</div>
</div>
)
},
// 3. ANNOUNCEMENT_BAR // 3. ANNOUNCEMENT_BAR
{ {
type: 'ANNOUNCEMENT_BAR', type: 'ANNOUNCEMENT_BAR',
@@ -0,0 +1,368 @@
import React from 'react';
import { Plus, Trash2, UploadCloud, Image as ImageIcon, Sliders } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
interface HeroCarouselFormProps {
editingSection: Partial<PageSection>;
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
editingLang: string;
getLocalizedSlideValue: (slide: any, field: string) => string;
updateLocalizedSlideValue: (idx: number, field: string, value: string) => void;
updateSlidePayloadValue: (idx: number, field: string, value: string | number) => void;
setUploadTarget: (target: { type: 'main' | 'slide'; index?: number } | null) => void;
setIsMediaModalOpen: (open: boolean) => void;
}
export default function HeroCarouselForm({
editingSection,
setEditingSection,
getLocalizedSlideValue,
updateLocalizedSlideValue,
updateSlidePayloadValue,
setUploadTarget,
setIsMediaModalOpen,
}: HeroCarouselFormProps) {
const payload = editingSection.payload || {};
const slides = Array.isArray(payload.slides) ? payload.slides : [];
const handleUpdatePayload = (key: string, value: any) => {
setEditingSection({
...editingSection,
payload: {
...payload,
[key]: value,
},
});
};
const handleAddSlide = () => {
const newSlide = {
imageUrl: 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=1920&q=80',
mobileImageUrl: '',
linkUrl: '/shop',
alt: 'Promo Banner',
title: '',
subtitle: '',
buttonText: '',
};
handleUpdatePayload('slides', [...slides, newSlide]);
};
const handleRemoveSlide = (idx: number) => {
const updated = slides.filter((_: any, i: number) => i !== idx);
handleUpdatePayload('slides', updated);
};
return (
<div className="space-y-6">
{/* Global Slider Settings */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="flex items-center gap-2 text-xs font-bold text-cyan-glow uppercase tracking-wider">
<Sliders className="w-3.5 h-3.5" />
<span>Carousel Dimension & Display Settings</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Desktop Aspect Ratio (Wide & Short)
</label>
<select
value={payload.aspectRatio || '21/7'}
onChange={(e) => handleUpdatePayload('aspectRatio', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="21/6">21:6 (Ultra Wide / Extra Short ~320px)</option>
<option value="21/7">21:7 (Wide Compact - Beautrium Style ~380px)</option>
<option value="21/9">21:9 (Standard Cinematic Wide ~450px)</option>
<option value="16/5">16:5 (Wide Banner)</option>
<option value="16/6">16:6 (Compact Banner)</option>
<option value="4/1">4:1 (Panoramic Ultra Slim)</option>
<option value="5/1">5:1 (Ultra Slim Ribbon)</option>
</select>
<p className="text-[9px] text-slate-500">BEAUTRIUM style uses 21:7 or 21:6 for wide banners without eating vertical screen space.</p>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Mobile Aspect Ratio
</label>
<select
value={payload.mobileAspectRatio || '16/9'}
onChange={(e) => handleUpdatePayload('mobileAspectRatio', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="16/9">16:9 (Standard Mobile Widescreen)</option>
<option value="4/3">4:3 (Taller Mobile View)</option>
<option value="1/1">1:1 (Square Mobile Banner)</option>
<option value="21/9">21:9 (Compact Mobile Strip)</option>
</select>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Header Spacing (ตำแหน่งเทียบกับ Header Menu เมื่ออยู่บล็อกแรก)
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleUpdatePayload('headerSpacing', 'below_header')}
className={`p-3 rounded-xl border text-left transition cursor-pointer flex flex-col gap-1 ${
(payload.headerSpacing || 'below_header') === 'below_header'
? 'border-cyan-glow bg-cyan-glow/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-white">ต่อจาก Header Menu (แนะนำ)</span>
{(payload.headerSpacing || 'below_header') === 'below_header' && (
<span className="text-[10px] font-bold text-cyan-glow uppercase">Selected</span>
)}
</div>
<p className="text-[10px] text-slate-400">เว้นระยะด้านบนอัตโนมัติ ไม่โดน Header Menu ลอยทับแถบสไลด์</p>
</button>
<button
type="button"
onClick={() => handleUpdatePayload('headerSpacing', 'under_header')}
className={`p-3 rounded-xl border text-left transition cursor-pointer flex flex-col gap-1 ${
payload.headerSpacing === 'under_header'
? 'border-cyan-glow bg-cyan-glow/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-white">อยู่ใต้ Header Menu (Overlay)</span>
{payload.headerSpacing === 'under_header' && (
<span className="text-[10px] font-bold text-cyan-glow uppercase">Selected</span>
)}
</div>
<p className="text-[10px] text-slate-400">Header เมนูจะโปร่งแสงและลอยทับรูปภาพสไลด์ส่วนบนสุด</p>
</button>
</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2 border-t border-white/5">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={payload.autoplay !== false}
onChange={(e) => handleUpdatePayload('autoplay', e.target.checked)}
className="rounded border-white/10 text-cyan-glow focus:ring-0"
/>
<span className="text-xs text-slate-300 font-medium">Autoplay</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={payload.showArrows !== false}
onChange={(e) => handleUpdatePayload('showArrows', e.target.checked)}
className="rounded border-white/10 text-cyan-glow focus:ring-0"
/>
<span className="text-xs text-slate-300 font-medium">Nav Arrows</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={payload.showDots !== false}
onChange={(e) => handleUpdatePayload('showDots', e.target.checked)}
className="rounded border-white/10 text-cyan-glow focus:ring-0"
/>
<span className="text-xs text-slate-300 font-medium">Dots Indicator</span>
</label>
<div className="space-y-1">
<div className="text-[9px] text-slate-400 uppercase font-bold">Speed (ms)</div>
<input
type="number"
value={payload.interval || 4500}
step={500}
min={1000}
onChange={(e) => handleUpdatePayload('interval', parseInt(e.target.value) || 4500)}
className="admin-input w-full py-1 text-xs"
/>
</div>
</div>
</div>
{/* Slides Section */}
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
<div>
<label className="text-xs uppercase font-bold tracking-widest text-slate-200">
Promo Banner Slides ({slides.length})
</label>
<p className="text-[10px] text-slate-400">Add high-resolution wide banner images and destination links.</p>
</div>
<button
type="button"
onClick={handleAddSlide}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 text-xs font-bold transition-all cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Add Slide</span>
</button>
</div>
{slides.length === 0 ? (
<div className="p-8 text-center rounded-2xl border border-dashed border-white/10 bg-slate-900/30">
<ImageIcon className="w-8 h-8 text-slate-600 mx-auto mb-2" />
<p className="text-xs text-slate-400">No banner slides added yet.</p>
<button
type="button"
onClick={handleAddSlide}
className="mt-3 px-4 py-1.5 bg-cyan-glow text-slate-950 text-xs font-bold rounded-xl"
>
Add First Slide
</button>
</div>
) : (
<div className="space-y-4">
{slides.map((slide: any, idx: number) => (
<div
key={idx}
className="p-4 rounded-2xl bg-slate-900/80 border border-white/10 space-y-4 relative group"
>
<div className="flex items-center justify-between pb-2 border-b border-white/5">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-cyan-glow/10 text-cyan-glow text-xs font-bold flex items-center justify-center">
{idx + 1}
</span>
<span className="text-xs font-bold text-white">Slide #{idx + 1}</span>
</div>
<button
type="button"
onClick={() => handleRemoveSlide(idx)}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition-colors cursor-pointer"
title="Remove slide"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Desktop Image Field & Preview */}
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Desktop Banner Image URL *
</label>
<div className="flex gap-2">
<input
type="text"
required
value={slide.imageUrl || ''}
onChange={(e) => updateSlidePayloadValue(idx, 'imageUrl', e.target.value)}
placeholder="https://... or /images/..."
className="admin-input flex-1 text-xs"
/>
<button
type="button"
onClick={() => {
setUploadTarget({ type: 'slide', index: idx });
setIsMediaModalOpen(true);
}}
className="px-3 py-2 bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl text-xs font-medium flex items-center gap-1.5 cursor-pointer"
>
<UploadCloud className="w-3.5 h-3.5 text-cyan-glow" />
<span>Media</span>
</button>
</div>
</div>
{/* Banner Preview Thumbnail */}
{slide.imageUrl && (
<div className="relative w-full aspect-[21/7] rounded-xl overflow-hidden bg-slate-950 border border-white/10">
<img
src={slide.imageUrl}
alt={slide.alt || 'preview'}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=400&q=80';
}}
/>
<div className="absolute top-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm text-[9px] text-white/80 font-mono">
Preview (21:7)
</div>
</div>
)}
{/* Mobile Image (Optional) & Link URL */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Mobile Image URL (Optional)
</label>
<input
type="text"
value={slide.mobileImageUrl || ''}
onChange={(e) => updateSlidePayloadValue(idx, 'mobileImageUrl', e.target.value)}
placeholder="Leave empty to use desktop image"
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">
Destination Link URL (e.g. /shop, /deals)
</label>
<input
type="text"
value={slide.linkUrl || ''}
onChange={(e) => updateSlidePayloadValue(idx, 'linkUrl', e.target.value)}
placeholder="/shop or /categories/1"
className="admin-input w-full text-xs"
/>
</div>
</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="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Alt Text
</label>
<input
type="text"
value={getLocalizedSlideValue(slide, 'alt')}
onChange={(e) => updateLocalizedSlideValue(idx, 'alt', e.target.value)}
placeholder="e.g. Wet & Wow Promotion"
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">
Overlay Title (Optional)
</label>
<input
type="text"
value={getLocalizedSlideValue(slide, 'title')}
onChange={(e) => updateLocalizedSlideValue(idx, 'title', e.target.value)}
placeholder="e.g. Mega Sale [60% OFF]"
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">
Overlay Tagline (Optional)
</label>
<input
type="text"
value={getLocalizedSlideValue(slide, 'subtitle')}
onChange={(e) => updateLocalizedSlideValue(idx, 'subtitle', e.target.value)}
placeholder="e.g. SPECIAL EDITION"
className="admin-input w-full text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+56 -4
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { import {
Layers, Layers,
Plus, Plus,
@@ -28,6 +29,7 @@ import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm'
import NewsletterForm from '../components/page-builder/NewsletterForm'; import NewsletterForm from '../components/page-builder/NewsletterForm';
import EditorialTileForm from '../components/page-builder/EditorialTileForm'; import EditorialTileForm from '../components/page-builder/EditorialTileForm';
import EditorialCarouselForm from '../components/page-builder/EditorialCarouselForm'; import EditorialCarouselForm from '../components/page-builder/EditorialCarouselForm';
import HeroCarouselForm from '../components/page-builder/HeroCarouselForm';
import CategoriesForm from '../components/page-builder/CategoriesForm'; import CategoriesForm from '../components/page-builder/CategoriesForm';
import ProductsForm from '../components/page-builder/ProductsForm'; import ProductsForm from '../components/page-builder/ProductsForm';
import BannerForm from '../components/page-builder/BannerForm'; import BannerForm from '../components/page-builder/BannerForm';
@@ -82,6 +84,7 @@ interface Brand {
export default function PageBuilderPage() { export default function PageBuilderPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const frontendBase = ( const frontendBase = (
import.meta.env.DEV import.meta.env.DEV
? window.location.origin ? window.location.origin
@@ -671,6 +674,35 @@ export default function PageBuilderPage() {
buttonLink: '/shop' buttonLink: '/shop'
}; };
break; break;
case 'HERO_CAROUSEL':
defaultPayload = {
headerSpacing: 'below_header',
autoplay: true,
interval: 4500,
aspectRatio: '21/7',
mobileAspectRatio: '16/9',
showArrows: true,
showDots: true,
slides: [
{
imageUrl: 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=1920&q=80',
linkUrl: '/shop',
alt: 'Mega Promotion 1',
title: '',
subtitle: '',
buttonText: '',
},
{
imageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=1920&q=80',
linkUrl: '/shop',
alt: 'Mega Promotion 2',
title: '',
subtitle: '',
buttonText: '',
}
]
};
break;
case 'HERO_MINIMAL': case 'HERO_MINIMAL':
defaultPayload = { defaultPayload = {
imageUrl: '/images/hero.png', imageUrl: '/images/hero.png',
@@ -1165,11 +1197,17 @@ export default function PageBuilderPage() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => setIsReorderModalOpen(true)} onClick={() => {
className="flex items-center gap-2 px-5 py-2.5 bg-white/10 hover:bg-white/20 border border-white/5 text-white rounded-xl font-bold text-xs uppercase tracking-wider transition-all" if (page?.id) {
navigate(`/page-builder/${page.id}/reorder`);
} else {
setIsReorderModalOpen(true);
}
}}
className="flex items-center gap-2 px-5 py-2.5 bg-white/10 hover:bg-white/20 border border-white/5 text-white rounded-xl font-bold text-xs uppercase tracking-wider transition-all cursor-pointer shadow-sm hover:scale-[1.02]"
> >
<Layers className="w-4 h-4" /> <Layers className="w-4 h-4 text-cyan-glow" />
{t('pageBuilder.btnReorderLayout', 'Reorder Layout')} {t('pageBuilder.btnReorderLayout', 'Reorder Layout (Full Screen)')}
</button> </button>
<button <button
onClick={openAddModal} onClick={openAddModal}
@@ -1293,6 +1331,7 @@ export default function PageBuilderPage() {
<option value="HERO">HERO (Campaign Intro)</option> <option value="HERO">HERO (Campaign Intro)</option>
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option> <option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
<option value="HERO_VIDEO">HERO VIDEO (Video Full Cover Background)</option> <option value="HERO_VIDEO">HERO VIDEO (Video Full Cover Background)</option>
<option value="HERO_CAROUSEL">HERO CAROUSEL (Wide Promo Slider)</option>
<option value="ANNOUNCEMENT_BAR">ANNOUNCEMENT BAR (Premium Slides)</option> <option value="ANNOUNCEMENT_BAR">ANNOUNCEMENT BAR (Premium Slides)</option>
<option value="NEWSLETTER">NEWSLETTER (Email Subscription)</option> <option value="NEWSLETTER">NEWSLETTER (Email Subscription)</option>
<option value="EDITORIAL_TILE">EDITORIAL TILE (Split 50:50 Story)</option> <option value="EDITORIAL_TILE">EDITORIAL TILE (Split 50:50 Story)</option>
@@ -1439,6 +1478,19 @@ export default function PageBuilderPage() {
/> />
)} )}
{editingSection.type?.toUpperCase() === 'HERO_CAROUSEL' && (
<HeroCarouselForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedSlideValue={getLocalizedSlideValue}
updateLocalizedSlideValue={updateLocalizedSlideValue}
updateSlidePayloadValue={updateSlidePayloadValue}
setUploadTarget={setUploadTarget}
setIsMediaModalOpen={setIsMediaModalOpen}
/>
)}
{editingSection.type?.toUpperCase() === 'USP_BAR' && ( {editingSection.type?.toUpperCase() === 'USP_BAR' && (
<UspBarForm <UspBarForm
editingSection={editingSection} editingSection={editingSection}
+665
View File
@@ -0,0 +1,665 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Layers,
ArrowUp,
ArrowDown,
ChevronsUp,
ChevronsDown,
Eye,
EyeOff,
Save,
Loader2,
CheckCircle2,
GripVertical,
ExternalLink,
RotateCcw,
Edit3
} from 'lucide-react';
import { API_BASE, type Page, type PageSection } from '@homaroma/types';
import { getAdminDisplayString } from '../utils/localization';
export default function PageReorderPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [pages, setPages] = useState<Page[]>([]);
const [currentPage, setCurrentPage] = useState<Page | null>(null);
const [sections, setSections] = useState<PageSection[]>([]);
const [initialSections, setInitialSections] = useState<PageSection[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [saving, setSaving] = useState<boolean>(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [filterType, setFilterType] = useState<'all' | 'active' | 'hidden'>('all');
const showToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 3000);
};
// Fetch all pages for switcher
const fetchPagesList = async () => {
try {
const res = await fetch(`${API_BASE}/pages`);
const data = await res.json();
if (Array.isArray(data)) {
setPages(data);
}
} catch (err) {
console.error('Fetch pages error:', err);
}
};
// Fetch specific page and its sections
const fetchPageDetails = async (pageIdOrSlug: string) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/pages/${pageIdOrSlug}?includeInactive=true`);
const data = await res.json();
if (data && !data.error) {
setCurrentPage(data);
const sortedSections = (data.sections || []).sort((a: PageSection, b: PageSection) => a.order - b.order);
setSections(sortedSections);
setInitialSections(sortedSections);
setHasUnsavedChanges(false);
} else {
alert(t('pageBuilder.errFetchPage', 'Page not found'));
navigate('/page-builder');
}
} catch (err) {
console.error('Fetch page error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPagesList();
}, []);
useEffect(() => {
if (id) {
fetchPageDetails(id);
}
}, [id]);
// Reordering functions
const handleMove = (index: number, direction: 'up' | 'down' | 'top' | 'bottom') => {
const list = [...sections];
if (direction === 'up' && index > 0) {
const temp = list[index];
list[index] = list[index - 1];
list[index - 1] = temp;
} else if (direction === 'down' && index < list.length - 1) {
const temp = list[index];
list[index] = list[index + 1];
list[index + 1] = temp;
} else if (direction === 'top' && index > 0) {
const [item] = list.splice(index, 1);
list.unshift(item);
} else if (direction === 'bottom' && index < list.length - 1) {
const [item] = list.splice(index, 1);
list.push(item);
}
setSections(list);
setHasUnsavedChanges(true);
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const list = [...sections];
const [draggedItem] = list.splice(draggedIndex, 1);
list.splice(index, 0, draggedItem);
setDraggedIndex(index);
setSections(list);
setHasUnsavedChanges(true);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
// Toggle active state
const handleToggleActive = async (section: PageSection) => {
const updatedActive = !section.isActive;
// Update local state immediately
setSections((prev) =>
prev.map((s) => (s.id === section.id ? { ...s, isActive: updatedActive } : s))
);
setHasUnsavedChanges(true);
try {
await fetch(`${API_BASE}/pages/sections/${section.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: updatedActive }),
});
showToast(updatedActive ? 'Section is now visible' : 'Section is now hidden');
} catch (err) {
console.error('Toggle active error:', err);
}
};
// Save new layout order
const handleSaveOrder = async () => {
if (!currentPage) return;
setSaving(true);
try {
const sectionOrders = sections.map((sec, idx) => ({
id: sec.id,
order: idx + 1,
}));
const res = await fetch(`${API_BASE}/pages/${currentPage.id}/sections/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sectionOrders }),
});
if (res.ok) {
setInitialSections([...sections]);
setHasUnsavedChanges(false);
showToast('Layout order saved successfully!');
} else {
alert('Failed to save layout order');
}
} catch (err) {
console.error('Save order error:', err);
alert('Failed to save layout order');
} finally {
setSaving(false);
}
};
// Reset to initial
const handleReset = () => {
setSections([...initialSections]);
setHasUnsavedChanges(false);
showToast('Reset to original order');
};
// Block Category Color Helper
const getBlockCategoryBadge = (type: string) => {
const upper = type.toUpperCase();
if (upper.startsWith('HERO') || upper === 'BANNER' || upper === 'BANNER_MINIMAL') {
return {
bg: 'bg-cyan-500/10 border-cyan-500/30 text-cyan-300',
label: 'Hero / Banner',
glow: 'border-l-cyan-400',
};
}
if (upper === 'PRODUCTS' || upper === 'PRODUCT_CAROUSEL' || upper === 'CATEGORIES' || upper.startsWith('PRODUCT_')) {
return {
bg: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
label: 'Shop & Catalog',
glow: 'border-l-amber-400',
};
}
if (upper.startsWith('EDITORIAL') || upper.startsWith('ARTICLE') || upper === 'RICH_TEXT' || upper === 'TEXT') {
return {
bg: 'bg-purple-500/10 border-purple-500/30 text-purple-300',
label: 'Editorial & Story',
glow: 'border-l-purple-400',
};
}
if (upper.startsWith('ANNOUNCEMENT') || upper.startsWith('USP')) {
return {
bg: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
label: 'Top Bar / Highlights',
glow: 'border-l-emerald-400',
};
}
return {
bg: 'bg-slate-500/10 border-slate-500/30 text-slate-300',
label: 'Content Block',
glow: 'border-l-slate-400',
};
};
// Block Preview Details Helper
const renderBlockPreviewSnippet = (section: PageSection) => {
const payload = section.payload || {};
const type = section.type.toUpperCase();
if (type === 'HERO_CAROUSEL') {
const slides = Array.isArray(payload.slides) ? payload.slides : [];
return (
<div className="flex items-center gap-3 mt-2">
{slides[0]?.imageUrl && (
<img
src={slides[0].imageUrl}
alt="slide"
className="w-20 h-8 object-cover rounded-lg border border-white/10"
/>
)}
<span className="text-[11px] text-slate-400">
{slides.length} Slides · Aspect {payload.aspectRatio || '21:7'} · Autoplay {payload.autoplay !== false ? 'ON' : 'OFF'}
</span>
</div>
);
}
if (type === 'HERO' || type === 'BANNER' || type === 'HERO_VIDEO') {
const img = payload.imageUrl || payload.posterImageUrl;
return (
<div className="flex items-center gap-3 mt-2">
{img && (
<img
src={img}
alt="hero"
className="w-16 h-8 object-cover rounded-lg border border-white/10"
/>
)}
<span className="text-[11px] text-slate-400">
{getAdminDisplayString(payload.description) || payload.buttonLink || 'Single visual cover'}
</span>
</div>
);
}
if (type === 'CATEGORIES' || type === 'PRODUCTS' || type === 'PRODUCT_CAROUSEL') {
return (
<div className="flex items-center gap-2 mt-2 text-[11px] text-slate-400">
<span className="px-2 py-0.5 rounded bg-white/5 border border-white/10 font-mono">
{payload.type || 'Custom Query'}
</span>
<span>{getAdminDisplayString(payload.subtitle) || `Limit: ${payload.limit || 'All'}`}</span>
</div>
);
}
return null;
};
const filteredSections = sections.filter((s) => {
if (filterType === 'active') return s.isActive;
if (filterType === 'hidden') return !s.isActive;
return true;
});
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Loader2 className="w-10 h-10 text-cyan-glow animate-spin" />
<p className="text-sm font-semibold text-slate-400">Loading Page Layout Structure...</p>
</div>
);
}
return (
<div className="space-y-8 animate-fade-in pb-24 text-slate-100 max-w-7xl mx-auto">
{/* Toast Alert Notification */}
{toastMessage && (
<div className="fixed top-6 right-6 z-50 flex items-center gap-2.5 px-5 py-3 rounded-2xl bg-night-900 border border-cyan-glow/40 shadow-2xl text-cyan-glow text-sm font-bold animate-fade-in backdrop-blur-md">
<CheckCircle2 className="w-5 h-5" />
<span>{toastMessage}</span>
</div>
)}
{/* Top Header & Navigation Bar */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b border-white/10 pb-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<Link
to="/page-builder"
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-all"
title="Back to Page Builder"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-2.5">
<h1 className="text-3xl font-bold text-white">Reorder Page Layout</h1>
<span className="px-3 py-1 rounded-full text-xs font-mono font-bold bg-purple-500/10 text-purple-300 border border-purple-500/30">
Full-Screen Canvas
</span>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-400 pl-11 flex-wrap">
<span>Page:</span>
{pages.length > 0 ? (
<select
value={currentPage?.id || ''}
onChange={(e) => {
const target = pages.find((p) => p.id === Number(e.target.value));
if (target) {
navigate(`/page-builder/${target.id}/reorder`);
}
}}
className="bg-slate-900 border border-white/10 text-cyan-glow font-bold rounded-xl px-3 py-1 text-xs focus:outline-none focus:border-cyan-glow cursor-pointer"
>
{pages.map((p) => (
<option key={p.id} value={p.id}>
{p.title} (/{p.slug})
</option>
))}
</select>
) : (
<strong className="text-white">{currentPage?.title} (/{currentPage?.slug})</strong>
)}
<div className="w-px h-3 bg-white/10 mx-1"></div>
<span className="text-xs text-slate-400 font-mono">
Total <strong>{sections.length}</strong> sections
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 pl-11 md:pl-0 flex-wrap">
{hasUnsavedChanges && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset</span>
</button>
)}
{currentPage && (
<a
href={`/${currentPage.slug}`}
target="_blank"
rel="noreferrer"
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Live Page</span>
</a>
)}
<button
type="button"
onClick={handleSaveOrder}
disabled={saving || !hasUnsavedChanges}
className={`px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center gap-2 transition-all shadow-lg cursor-pointer ${
hasUnsavedChanges
? 'bg-cyan-glow text-night-950 hover:bg-cyan-glow/90 shadow-cyan-glow/20 scale-105'
: 'bg-white/10 text-slate-400 border border-white/5 cursor-not-allowed opacity-60'
}`}
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin text-night-950" />
) : (
<Save className="w-4 h-4" />
)}
<span>{saving ? 'Saving Order...' : hasUnsavedChanges ? 'Save Layout Order *' : 'Layout Saved'}</span>
</button>
</div>
</div>
{/* Main Grid: Sortable Canvas + Sticky Wireframe Minimap */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Left / Center: Interactive Drag & Sort Canvas (8 Cols) */}
<div className="lg:col-span-8 space-y-4">
{/* Filter Tabs & Quick Info Bar */}
<div className="flex items-center justify-between p-4 rounded-2xl bg-slate-900/60 border border-white/5">
<div className="flex items-center gap-1 bg-black/40 p-1 rounded-xl border border-white/5">
{(['all', 'active', 'hidden'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setFilterType(mode)}
className={`px-3 py-1 rounded-lg text-xs font-bold uppercase tracking-wider transition-all cursor-pointer ${
filterType === mode
? 'bg-cyan-glow text-night-950 shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
>
{mode === 'all' && `All Blocks (${sections.length})`}
{mode === 'active' && `Active (${sections.filter((s) => s.isActive).length})`}
{mode === 'hidden' && `Hidden (${sections.filter((s) => !s.isActive).length})`}
</button>
))}
</div>
<p className="text-[11px] text-slate-400 hidden sm:block">
💡 Drag cards or use arrow buttons to arrange vertical hierarchy.
</p>
</div>
{/* Sortable List */}
{filteredSections.length === 0 ? (
<div className="p-16 text-center rounded-3xl border border-dashed border-white/10 bg-slate-900/30 space-y-3">
<Layers className="w-10 h-10 text-slate-600 mx-auto" />
<p className="text-sm text-slate-400">No sections found for this filter.</p>
</div>
) : (
<div className="space-y-3">
{filteredSections.map((section, idx) => {
const categoryBadge = getBlockCategoryBadge(section.type);
const titleText = getAdminDisplayString(
section.payload?.title
|| section.payload?.tagline
|| section.payload?.heading
|| section.payload?.quote
|| (section.type?.toUpperCase() === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)
) || `Untitled ${section.type}`;
const isFirst = idx === 0;
const isLast = idx === filteredSections.length - 1;
return (
<div
key={section.id}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
className={`p-4 rounded-2xl bg-night-900 border transition-all duration-200 group flex items-center justify-between gap-4 select-none ${
categoryBadge.glow
} border-l-4 ${
draggedIndex === idx
? 'opacity-40 scale-[0.98] border-cyan-glow bg-cyan-glow/5'
: section.isActive
? 'border-white/10 hover:border-white/20 bg-white/[0.02]'
: 'border-white/5 opacity-60 bg-black/40'
}`}
>
{/* Left: Drag Handle, Numbering & Details */}
<div className="flex items-center gap-4 min-w-0 flex-1">
{/* Drag Handle Icon */}
<div className="cursor-grab active:cursor-grabbing p-1.5 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-white/5 transition-colors shrink-0">
<GripVertical className="w-5 h-5" />
</div>
{/* Step Number Badge */}
<div className="w-9 h-9 rounded-xl bg-slate-800 border border-white/10 text-white font-mono font-bold flex items-center justify-center text-sm shadow-inner shrink-0">
#{idx + 1}
</div>
{/* Block Information */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider border font-mono ${categoryBadge.bg}`}>
{section.type}
</span>
<span className="text-[10px] text-slate-500 font-medium hidden sm:inline-block">
({categoryBadge.label})
</span>
{!section.isActive && (
<span className="px-2 py-0.5 rounded bg-rose-500/10 text-rose-400 border border-rose-500/20 text-[10px] font-bold uppercase tracking-wider">
Hidden
</span>
)}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-cyan-glow transition-colors">
{titleText}
</h3>
{/* Extra Payload Snippet */}
{renderBlockPreviewSnippet(section)}
</div>
</div>
{/* Right: Quick Controls */}
<div className="flex items-center gap-1.5 shrink-0 pl-2">
{/* Quick Move Buttons */}
<div className="flex items-center gap-1 bg-black/40 p-1 rounded-xl border border-white/5">
<button
type="button"
onClick={() => handleMove(idx, 'top')}
disabled={isFirst}
title="Move to Top"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ChevronsUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMove(idx, 'up')}
disabled={isFirst}
title="Move Up"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMove(idx, 'down')}
disabled={isLast}
title="Move Down"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMove(idx, 'bottom')}
disabled={isLast}
title="Move to Bottom"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ChevronsDown className="w-4 h-4" />
</button>
</div>
{/* Visibility Toggle */}
<button
type="button"
onClick={() => handleToggleActive(section)}
className={`p-2 rounded-xl border transition-all cursor-pointer ${
section.isActive
? 'bg-cyan-glow/10 border-cyan-glow/30 text-cyan-glow hover:bg-cyan-glow/20'
: 'bg-white/5 border-white/5 text-slate-500 hover:text-white'
}`}
title={section.isActive ? 'Hide Section' : 'Show Section'}
>
{section.isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
{/* Jump to Edit Block Shortcut */}
<Link
to={`/page-builder`}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/5 transition-all"
title="Edit block in Page Builder"
>
<Edit3 className="w-4 h-4" />
</Link>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right Sidebar: Sticky Page Wireframe Flow & Overview (4 Cols) */}
<div className="lg:col-span-4 sticky top-6 space-y-6">
{/* Overview Statistics Card */}
<div className="p-6 rounded-3xl bg-slate-900/70 border border-white/10 space-y-4 shadow-xl">
<div className="flex items-center gap-2.5 text-xs font-bold text-slate-200 uppercase tracking-wider">
<Layers className="w-4 h-4 text-cyan-glow" />
<span>Page Layout Summary</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-2xl bg-night-950/60 border border-white/5 space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Total Blocks</span>
<p className="text-2xl font-bold font-mono text-white">{sections.length}</p>
</div>
<div className="p-3.5 rounded-2xl bg-night-950/60 border border-white/5 space-y-1">
<span className="text-[10px] uppercase font-bold text-emerald-400 tracking-wider">Active</span>
<p className="text-2xl font-bold font-mono text-emerald-400">
{sections.filter((s) => s.isActive).length}
</p>
</div>
</div>
{hasUnsavedChanges && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs flex items-center gap-2.5">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>You have unsaved order changes. Click "Save Layout Order" above.</span>
</div>
)}
</div>
{/* Visual Wireframe Flow Stack */}
<div className="p-6 rounded-3xl bg-slate-900/70 border border-white/10 space-y-4 shadow-xl">
<div className="flex items-center justify-between text-xs font-bold text-slate-200 uppercase tracking-wider">
<span>Wireframe Sequence</span>
<span className="text-[10px] text-slate-500 font-mono">Top → Bottom</span>
</div>
<div className="space-y-1.5 max-h-[420px] overflow-y-auto pr-1">
{sections.map((sec, idx) => {
const badge = getBlockCategoryBadge(sec.type);
return (
<div
key={sec.id}
className={`px-3 py-2 rounded-xl text-xs flex items-center justify-between border-l-2 transition-all ${
sec.isActive
? `bg-night-950/80 border-y-white/5 border-r-white/5 ${badge.glow} text-slate-200`
: 'bg-black/40 border-dashed border-white/5 text-slate-600'
}`}
>
<div className="flex items-center gap-2 truncate">
<span className="text-[10px] font-mono text-slate-500 font-bold w-4">
#{idx + 1}
</span>
<span className="font-bold text-xs truncate">{sec.type}</span>
</div>
{!sec.isActive && (
<span className="text-[9px] uppercase font-bold text-slate-600 shrink-0">
Hidden
</span>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
+301 -60
View File
@@ -14,7 +14,10 @@ import {
AlertCircle, AlertCircle,
Code2, Code2,
X, X,
Bot Bot,
ArrowUpDown,
Monitor,
Smartphone
} from 'lucide-react' } from 'lucide-react'
const defaultThemeTokens = { const defaultThemeTokens = {
@@ -59,13 +62,16 @@ const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Desi
[กฎและข้อกำหนดทางเทคนิค]: [กฎและข้อกำหนดทางเทคนิค]:
1. ผลลัพธ์ต้องเป็น Valid JSON Object ล้วนๆ เริ่มต้นด้วย { และจบด้วย } เท่านั้น (ห้ามมี Markdown Backticks หรือคำเกริ่นนำ) 1. ผลลัพธ์ต้องเป็น Valid JSON Object ล้วนๆ เริ่มต้นด้วย { และจบด้วย } เท่านั้น (ห้ามมี Markdown Backticks หรือคำเกริ่นนำ)
2. ค่าสีทั้งหมดต้องอยู่ในรูปแบบ HEX Color Code 6 หลัก (เช่น "#A100FF", "#0A0D14") 2. ค่าสีทั้งหมดต้องอยู่ในรูปแบบ HEX Color Code 6 หลัก (เช่น "#A100FF", "#0A0D14")
3. โครงสร้าง JSON ต้องประกอบด้วย: 3. โครงสร้าง JSON ต้องประกอบด้วย 5 ส่วนหลัก:
- "version": "1.0" - "version": "1.0"
- "themeTokens": Object เก็บค่าสีและสไตล์ทั้งหมด - "themeTokens": Object เก็บค่าสีและสไตล์ทั้งหมด
- "scrollRevealEnabled": boolean (true/false) - "scrollRevealEnabled": boolean (true/false)
- "scrollRevealStyle": "fade-up" | "fade-in" | "slide-left" | "zoom-in" | "none" - "scrollRevealStyle": "fade-up" | "fade-in" | "slide-left" | "zoom-in" | "none"
- "scrollRevealDuration": number (เช่น 600, 700, 800) - "scrollRevealDuration": number (เช่น 600, 700, 800)
- "headerStyle": "single_line" | "two_tier" - "headerStyle": "single_line" | "two_tier"
- "headerBehavior": "hide_on_scroll" | "always_sticky"
- "mobileHeaderStyle": "logo_center" | "logo_left" | "search_bar_below"
- "mobileHeaderBehavior": "hide_on_scroll" | "always_sticky"
[รายละเอียดของ themeTokens แต่ละฟิลด์]: [รายละเอียดของ themeTokens แต่ละฟิลด์]:
• กลุ่มสีแบรนด์และไฮไลต์ (Brand & Accents): • กลุ่มสีแบรนด์และไฮไลต์ (Brand & Accents):
@@ -78,13 +84,13 @@ const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Desi
- "brandSoft": สีรองโทนอ่อน / Pastel highlight - "brandSoft": สีรองโทนอ่อน / Pastel highlight
• กลุ่มพื้นผิวและเลเยอร์ (Surfaces & Backgrounds): • กลุ่มพื้นผิวและเลเยอร์ (Surfaces & Backgrounds):
- "surfacePage": สีพื้นหลังหลักของหน้าเว็บ - "surfacePage": สีพื้นหลังหลักของหน้าเว็บ (เช่น "#0A0D14" สำหรับ Dark Mode หรือ "#F8FAFC" สำหรับ Light Mode)
- "surfaceNavRest": สีพื้นหลังของ Header / Navbar - "surfaceNavRest": สีพื้นหลังของ Header / Navbar
- "surfaceCard": สีพื้นหลังของการ์ดสินค้า / Container Block - "surfaceCard": สีพื้นหลังของการ์ดสินค้า / Container Block
- "surfaceMuted": สีพื้นหลังของ Section สลับลาย / Footer - "surfaceMuted": สีพื้นหลังของ Section สลับลาย / Footer
• กลุ่มข้อความและการอ่าน (Typography Colors): • กลุ่มข้อความและการอ่าน (Typography Colors):
- "textMain": สีข้อความหัวข้อและเนื้อหาหลัก - "textMain": สีข้อความหัวข้อและเนื้อหาหลัก (ต้อง Contrast ชัดเจนกับ surfacePage)
- "mutedText": สีข้อความรอง / รายละเอียดปลีกย่อย - "mutedText": สีข้อความรอง / รายละเอียดปลีกย่อย
- "captionText": สีข้อความกำกับ / ตัวเลขสถิติ / วันที่ - "captionText": สีข้อความกำกับ / ตัวเลขสถิติ / วันที่
@@ -94,14 +100,16 @@ const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Desi
- "linePrimaryHover": สีเมื่อ Hover ไอคอน LINE - "linePrimaryHover": สีเมื่อ Hover ไอคอน LINE
• กลุ่มมิติและสเปซ (Elevations & Geometry): • กลุ่มมิติและสเปซ (Elevations & Geometry):
- "fontBody": ฟอนต์เนื้อหา (ปล่อยว่าง "" หรือใส่ Google Fonts) - "fontBody": ฟอนต์เนื้อหา (ปล่อยว่าง "" เพื่อใช้ค่าเริ่มต้นของระบบ หรือใส่ฟอนต์ Google Fonts)
- "fontDisplay": ฟอนต์หัวข้อ (ปล่อยว่าง "" หรือใส่ฟอนต์ Serif/Display) - "fontDisplay": ฟอนต์หัวข้อ (ปล่อยว่าง "" หรือใส่ฟอนต์ Serif/Display เช่น "Playfair Display")
- "shadowCard": เงาของการ์ดปกติ - "shadowCard": เงาของการ์ดปกติ (เช่น "0 2px 8px rgba(0,0,0,0.30)")
- "shadowCardHover": เงาเมื่อ Hover การ์ด - "shadowCardHover": เงาเมื่อ Hover การ์ด (เช่น "0 8px 24px rgba(0,0,0,0.40)")
- "radiusBase": ความโค้งมนของปุ่ม/อินพุต (เช่น "8px") - "radiusBase": ความโค้งมนของปุ่ม/อินพุต (เช่น "8px", "12px", "9999px")
- "radiusCard": ความโค้งมนของการ์ด (เช่น "16px") - "radiusCard": ความโค้งมนของการ์ด (เช่น "16px", "20px", "24px")
[ตัวอย่าง JSON Template]: ---
[ตัวอย่าง JSON Template ที่ถูกต้อง]:
{ {
"version": "1.0", "version": "1.0",
"themeTokens": { "themeTokens": {
@@ -155,6 +163,9 @@ export default function ThemeSettingsPage() {
const [scrollRevealStyle, setScrollRevealStyle] = useState('fade-up') const [scrollRevealStyle, setScrollRevealStyle] = useState('fade-up')
const [scrollRevealDuration, setScrollRevealDuration] = useState('700') const [scrollRevealDuration, setScrollRevealDuration] = useState('700')
const [headerStyle, setHeaderStyle] = useState('single_line') const [headerStyle, setHeaderStyle] = useState('single_line')
const [headerBehavior, setHeaderBehavior] = useState<'always_sticky' | 'hide_on_scroll'>('hide_on_scroll')
const [mobileHeaderStyle, setMobileHeaderStyle] = useState<'logo_center' | 'logo_left' | 'search_bar_below'>('logo_center')
const [mobileHeaderBehavior, setMobileHeaderBehavior] = useState<'always_sticky' | 'hide_on_scroll'>('hide_on_scroll')
// JSON Quick Copy / Paste State // JSON Quick Copy / Paste State
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false) const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
@@ -181,6 +192,10 @@ export default function ThemeSettingsPage() {
setScrollRevealStyle(data.scrollRevealStyle || 'fade-up') setScrollRevealStyle(data.scrollRevealStyle || 'fade-up')
setScrollRevealDuration((data.scrollRevealDuration ?? 700).toString()) setScrollRevealDuration((data.scrollRevealDuration ?? 700).toString())
setHeaderStyle(data.headerStyle || 'single_line') setHeaderStyle(data.headerStyle || 'single_line')
const pcBehavior = (data as any).headerBehavior || (data.themeTokens as any)?.headerBehavior || 'hide_on_scroll'
setHeaderBehavior(pcBehavior)
setMobileHeaderStyle((data as any).mobileHeaderStyle || (data.themeTokens as any)?.mobileHeaderStyle || 'logo_center')
setMobileHeaderBehavior((data as any).mobileHeaderBehavior || (data.themeTokens as any)?.mobileHeaderBehavior || pcBehavior)
}) })
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -196,11 +211,19 @@ export default function ThemeSettingsPage() {
return { return {
version: '1.0', version: '1.0',
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
themeTokens, themeTokens: {
...themeTokens,
headerBehavior,
mobileHeaderBehavior,
mobileHeaderStyle,
},
scrollRevealEnabled, scrollRevealEnabled,
scrollRevealStyle, scrollRevealStyle,
scrollRevealDuration: parseInt(scrollRevealDuration) || 700, scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
headerStyle, headerStyle,
headerBehavior,
mobileHeaderStyle,
mobileHeaderBehavior,
} }
} }
@@ -272,6 +295,29 @@ export default function ThemeSettingsPage() {
} else if (typeof parsed.headerStyle === 'string') { } else if (typeof parsed.headerStyle === 'string') {
setHeaderStyle(parsed.headerStyle) setHeaderStyle(parsed.headerStyle)
} }
// Extract Header Behavior
if (parsed.headerBehavior === 'always_sticky' || parsed.headerBehavior === 'hide_on_scroll') {
setHeaderBehavior(parsed.headerBehavior)
} else if (parsed.headerStickyMode === 'always_sticky' || parsed.headerStickyMode === 'hide_on_scroll') {
setHeaderBehavior(parsed.headerStickyMode)
} else if (extractedTokens?.headerBehavior === 'always_sticky' || extractedTokens?.headerBehavior === 'hide_on_scroll') {
setHeaderBehavior(extractedTokens.headerBehavior)
}
// Extract Mobile Header Style
if (parsed.mobileHeaderStyle === 'logo_center' || parsed.mobileHeaderStyle === 'logo_left' || parsed.mobileHeaderStyle === 'search_bar_below') {
setMobileHeaderStyle(parsed.mobileHeaderStyle)
} else if (extractedTokens?.mobileHeaderStyle === 'logo_center' || extractedTokens?.mobileHeaderStyle === 'logo_left' || extractedTokens?.mobileHeaderStyle === 'search_bar_below') {
setMobileHeaderStyle(extractedTokens.mobileHeaderStyle)
}
// Extract Mobile Header Behavior
if (parsed.mobileHeaderBehavior === 'always_sticky' || parsed.mobileHeaderBehavior === 'hide_on_scroll') {
setMobileHeaderBehavior(parsed.mobileHeaderBehavior)
} else if (extractedTokens?.mobileHeaderBehavior === 'always_sticky' || extractedTokens?.mobileHeaderBehavior === 'hide_on_scroll') {
setMobileHeaderBehavior(extractedTokens.mobileHeaderBehavior)
}
} }
// ── Paste JSON Text Handlers ────────────────────────────────────────────── // ── Paste JSON Text Handlers ──────────────────────────────────────────────
@@ -392,11 +438,19 @@ export default function ThemeSettingsPage() {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
themeTokens, themeTokens: {
...themeTokens,
headerBehavior,
mobileHeaderBehavior,
mobileHeaderStyle,
},
scrollRevealEnabled, scrollRevealEnabled,
scrollRevealStyle, scrollRevealStyle,
scrollRevealDuration: parseInt(scrollRevealDuration) || 700, scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
headerStyle, headerStyle,
headerBehavior,
mobileHeaderStyle,
mobileHeaderBehavior,
}), }),
}) })
if (res.ok) { if (res.ok) {
@@ -831,65 +885,252 @@ export default function ThemeSettingsPage() {
</div> </div>
</div> </div>
{/* ── 4. Header Layout Style ── */} {/* ── 4. Desktop (PC) Header Settings ── */}
<div className="admin-card p-6 md:p-8 space-y-6"> <div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4"> <div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-amber-500/10 rounded-xl text-amber-400"> <div className="p-2 bg-amber-500/10 rounded-xl text-amber-400">
<span className="text-xl">🧭</span> <Monitor className="w-5 h-5" />
</div> </div>
<div> <div>
<h2 className="text-lg font-bold text-white">PC Header Layout Style</h2> <h2 className="text-lg font-bold text-white">Desktop (PC) Header Configuration</h2>
<p className="text-xs text-slate-400 mt-0.5">เลือกรูปแบบการจัดวางเมนูส่วนหัวหลักสำหรับหน้าจอแนวกว้าง (PC / Desktop View)</p> <p className="text-xs text-slate-400 mt-0.5">การตั้งค่าเมนูส่วนหัวหลักสำหรับหน้าจอขนาดใหญ่ (PC / Desktop View ≥ 768px)</p>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> {/* PC Layout Style */}
<button <div className="space-y-3">
type="button" <label className="text-xs uppercase font-bold tracking-widest text-slate-400">PC Header Layout Style</label>
onClick={() => setHeaderStyle('single_line')} <div className="grid gap-4 md:grid-cols-2">
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${ <button
headerStyle === 'single_line' type="button"
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10' onClick={() => setHeaderStyle('single_line')}
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20' className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
}`} headerStyle === 'single_line'
> ? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
<div className="flex items-center justify-between"> : 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
<span className="text-sm font-bold text-white">Single Line (มาตรฐานแถบเดียว)</span> }`}
{headerStyle === 'single_line' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>} >
</div>
<p className="text-xs text-slate-400">โลโก้ซ้าย/กลาง + เมนูกลาง + ไอคอนขวา อยู่ในแถบเดียว ประหยัดพื้นที่แนวตั้ง</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
<div className="font-bold text-white">LOGO</div>
<div className="flex gap-2"><span>Shop</span><span>Category</span><span>About</span></div>
<div className="flex gap-1"><span>🔍</span><span>🛒</span></div>
</div>
</button>
<button
type="button"
onClick={() => setHeaderStyle('two_tier')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
headerStyle === 'two_tier'
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Two-Tier Luxury (หรูหรา 2 ชั้น)</span>
{headerStyle === 'two_tier' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">แบบแบรนด์เนมชั้นนำ (Chanel Style): โลโก้กลาง + ไอคอนขวา (ชั้น 1) / เมนูกลาง (ชั้น 2)</p>
<div className="h-12 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex flex-col justify-between text-[9px] text-slate-400">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="w-4" /> <span className="text-sm font-bold text-white">Single Line (มาตรฐานแถบเดียว)</span>
<div className="font-bold text-white text-[10px]">BRAND LOGO</div> {headerStyle === 'single_line' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">โลโก้ซ้าย/กลาง + เมนูกลาง + ไอคอนขวา อยู่ในแถบเดียว ประหยัดพื้นที่แนวตั้ง</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
<div className="font-bold text-white">LOGO</div>
<div className="flex gap-2"><span>Shop</span><span>Category</span><span>About</span></div>
<div className="flex gap-1"><span>🔍</span><span>🛒</span></div> <div className="flex gap-1"><span>🔍</span><span>🛒</span></div>
</div> </div>
<div className="flex justify-center gap-3 border-t border-white/5 pt-1 text-[8px]"> </button>
<span>HAUTE COUTURE</span><span>FASHION</span><span>PERFUME</span>
<button
type="button"
onClick={() => setHeaderStyle('two_tier')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
headerStyle === 'two_tier'
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Two-Tier Luxury (หรูหรา 2 ชั้น)</span>
{headerStyle === 'two_tier' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
</div> </div>
</div> <p className="text-xs text-slate-400">แบบแบรนด์เนมชั้นนำ (Chanel Style): โลโก้กลาง + ไอคอนขวา (ชั้น 1) / เมนูกลาง (ชั้น 2)</p>
</button> <div className="h-12 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex flex-col justify-between text-[9px] text-slate-400">
<div className="flex items-center justify-between">
<div className="w-4" />
<div className="font-bold text-white text-[10px]">BRAND LOGO</div>
<div className="flex gap-1"><span>🔍</span><span>🛒</span></div>
</div>
<div className="flex justify-center gap-3 border-t border-white/5 pt-1 text-[8px]">
<span>HAUTE COUTURE</span><span>FASHION</span><span>PERFUME</span>
</div>
</div>
</button>
</div>
</div>
{/* PC Scroll Behavior */}
<div className="space-y-3 pt-4 border-t border-white/5">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400 flex items-center gap-1.5">
<ArrowUpDown className="w-3.5 h-3.5 text-amber-400" />
<span>PC Header Scroll Behavior</span>
</label>
<div className="grid gap-4 md:grid-cols-2">
<button
type="button"
onClick={() => setHeaderBehavior('hide_on_scroll')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
headerBehavior === 'hide_on_scroll'
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Hide on Scroll (Smart Header)</span>
{headerBehavior === 'hide_on_scroll' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">เลื่อนลง (Scroll Down) จะซ่อนเมนู / เลื่อนขึ้น (Scroll Up) จะสไลด์กลับมาแสดงทันที</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
<span className="text-rose-400">↓ เลื่อนลง ซ่อน Header</span>
<span className="text-emerald-400">↑ เลื่อนขึ้น แสดง Header</span>
</div>
</button>
<button
type="button"
onClick={() => setHeaderBehavior('always_sticky')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
headerBehavior === 'always_sticky'
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Always Sticky (ตรึงค้างไว้ตลอด)</span>
{headerBehavior === 'always_sticky' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">เมนูจะเกาะติดขอบจอด้านบนตลอดเวลา ไม่ว่าจะเลื่อนขึ้นหรือลง</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
<span className="text-amber-300 font-bold">📌 FIXED TOP</span>
<span className="text-slate-400 text-[9px]">แสดงเมนูค้างไว้ทุกระดับ</span>
</div>
</button>
</div>
</div>
</div>
{/* ── 5. Mobile Header Settings ── */}
<div className="admin-card p-6 md:p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-emerald-500/10 rounded-xl text-emerald-400">
<Smartphone className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Mobile Header Configuration</h2>
<p className="text-xs text-slate-400 mt-0.5">การตั้งค่าเมนูส่วนหัวหลักสำหรับหน้าจอมือถือ (Mobile View &lt; 768px)</p>
</div>
</div>
{/* Mobile Layout Style */}
<div className="space-y-3">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Mobile Header Layout Style</label>
<div className="grid gap-4 md:grid-cols-3">
<button
type="button"
onClick={() => setMobileHeaderStyle('logo_center')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderStyle === 'logo_center'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Centered Logo (สไตล์ Luxury)</span>
{mobileHeaderStyle === 'logo_center' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">ซ้ายเป็นปุ่มเมนู (☰) / โลโก้อยู่กึ่งกลางสมมาตร / ขวาเป็นไอคอนค้นหาและตะกร้า</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
<div className="text-white text-xs">☰</div>
<div className="font-bold text-white tracking-widest text-[9px]">LOGO</div>
<div className="flex gap-1.5 text-[9px]"><span>🔍</span><span>🛍️</span></div>
</div>
</button>
<button
type="button"
onClick={() => setMobileHeaderStyle('logo_left')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderStyle === 'logo_left'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Left Logo (สไตล์ Commercial)</span>
{mobileHeaderStyle === 'logo_left' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">โลโก้อยู่ชิดซ้าย (พื้นที่แนวนอนยาว) / รวมไอคอนค้นหา ตะกร้า และเมนู (☰) ไว้ชิดขวา</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
<div className="font-bold text-white tracking-widest text-[9px]">BRAND LOGO</div>
<div className="flex items-center gap-1.5 text-[9px]"><span>🔍</span><span>🛍️</span><span className="text-white text-xs ml-0.5">☰</span></div>
</div>
</button>
<button
type="button"
onClick={() => setMobileHeaderStyle('search_bar_below')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderStyle === 'search_bar_below'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Two-Row (พร้อมแถบค้นหา)</span>
{mobileHeaderStyle === 'search_bar_below' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">แถวบนโลโก้ + ตะกร้า + เมนู / แถวล่างมีช่อง Search Bar ค้นหาสินค้าได้ทันที</p>
<div className="h-12 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex flex-col justify-between text-[9px] text-slate-400">
<div className="flex items-center justify-between">
<div className="font-bold text-white text-[9px]">LOGO</div>
<div className="flex items-center gap-1.5 text-[9px]"><span>🛍️</span><span className="text-white text-xs ml-0.5">☰</span></div>
</div>
<div className="h-3.5 w-full rounded bg-white/10 flex items-center px-1.5 text-[8px] text-slate-400 gap-1">
<span>🔍</span><span>ค้นหาสินค้า...</span>
</div>
</div>
</button>
</div>
</div>
{/* Mobile Scroll Behavior */}
<div className="space-y-3 pt-4 border-t border-white/5">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400 flex items-center gap-1.5">
<ArrowUpDown className="w-3.5 h-3.5 text-emerald-400" />
<span>Mobile Header Scroll Behavior</span>
</label>
<div className="grid gap-4 md:grid-cols-2">
<button
type="button"
onClick={() => setMobileHeaderBehavior('hide_on_scroll')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderBehavior === 'hide_on_scroll'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Hide on Scroll (Smart Header - แนะนำสำหรับมือถือ)</span>
{mobileHeaderBehavior === 'hide_on_scroll' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">เลื่อนลง (Scroll Down) จะซ่อนเมนูเพื่อประหยัดพื้นที่หน้าจอ / เลื่อนขึ้น (Scroll Up) จะสไลด์กลับมาแสดงทันที</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
<span className="text-rose-400">↓ เลื่อนลง ซ่อน Header</span>
<span className="text-emerald-400">↑ เลื่อนขึ้น แสดง Header</span>
</div>
</button>
<button
type="button"
onClick={() => setMobileHeaderBehavior('always_sticky')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderBehavior === 'always_sticky'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Always Sticky (ตรึงค้างไว้ตลอด)</span>
{mobileHeaderBehavior === 'always_sticky' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">เมนู Header จะเกาะติดขอบจอด้านบนตลอดเวลา ไม่ว่าจะเลื่อนขึ้นหรือลง</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
<span className="text-emerald-300 font-bold">📌 FIXED TOP</span>
<span className="text-slate-400 text-[9px]">แสดงเมนูค้างไว้ทุกระดับ</span>
</div>
</button>
</div>
</div> </div>
</div> </div>
+11 -2
View File
@@ -12,6 +12,7 @@ import {
EditorialTileSectionPayloadSchema, EditorialTileSectionPayloadSchema,
FeaturedTilesSectionPayloadSchema, FeaturedTilesSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema, EditorialCarouselSectionPayloadSchema,
HeroCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema, HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema, RichTextSectionPayloadSchema,
ArticleHeaderSectionPayloadSchema, ArticleHeaderSectionPayloadSchema,
@@ -248,8 +249,13 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const includeInactive = const includeInactive =
String((query as any)?.includeInactive || "").toLowerCase() === "true"; String((query as any)?.includeInactive || "").toLowerCase() === "true";
const lang = (query as any)?.lang || "th"; const lang = (query as any)?.lang || "th";
const page = await prisma.page.findUnique({ const numId = Number(slug);
where: { slug }, const isNumericId = Number.isInteger(numId) && numId > 0;
const page = await prisma.page.findFirst({
where: isNumericId
? { OR: [{ id: numId }, { slug }] }
: { slug },
include: { include: {
sections: { sections: {
where: includeInactive ? undefined : { isActive: true }, where: includeInactive ? undefined : { isActive: true },
@@ -1112,6 +1118,9 @@ export function validateSectionPayload(
case "EDITORIAL_CAROUSEL": case "EDITORIAL_CAROUSEL":
schema = EditorialCarouselSectionPayloadSchema; schema = EditorialCarouselSectionPayloadSchema;
break; break;
case "HERO_CAROUSEL":
schema = HeroCarouselSectionPayloadSchema;
break;
case "HERO_ARTICLE": case "HERO_ARTICLE":
schema = HeroArticleSectionPayloadSchema; schema = HeroArticleSectionPayloadSchema;
break; break;
+36 -3
View File
@@ -17,7 +17,16 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
const localizedDescription = requestedLang const localizedDescription = requestedLang
? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription ? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription
: undefined; : undefined;
return localizedDescription ? { ...settingsData, footerDescription: localizedDescription } : settingsData; const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return {
...settingsData,
headerBehavior,
mobileHeaderBehavior,
mobileHeaderStyle,
footerDescription: localizedDescription ?? settingsData.footerDescription,
};
} catch (err) { } catch (err) {
console.error("[Settings] Error:", err); console.error("[Settings] Error:", err);
set.status = 500; set.status = 500;
@@ -111,7 +120,22 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
}, { body: t.Any() }) }, { body: t.Any() })
.put("/", async ({ body, set }) => { .put("/", async ({ body, set }) => {
try { try {
const data = body as any; const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, ...data } = body as any;
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens
: {};
const updatedTokens = { ...currentTokens };
if (headerBehavior !== undefined) {
updatedTokens.headerBehavior = headerBehavior;
}
if (mobileHeaderBehavior !== undefined) {
updatedTokens.mobileHeaderBehavior = mobileHeaderBehavior;
}
if (mobileHeaderStyle !== undefined) {
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
}
data.themeTokens = updatedTokens;
let settings = await prisma.storeSettings.findFirst(); let settings = await prisma.storeSettings.findFirst();
if (!settings) { if (!settings) {
settings = await prisma.storeSettings.create({ data }); settings = await prisma.storeSettings.create({ data });
@@ -121,7 +145,15 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
data data
}); });
} }
return settings; const returnedBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const returnedMobileBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
const returnedMobileStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return {
...settings,
headerBehavior: returnedBehavior,
mobileHeaderBehavior: returnedMobileBehavior,
mobileHeaderStyle: returnedMobileStyle,
};
} catch (err) { } catch (err) {
console.error("[Settings] Error:", err); console.error("[Settings] Error:", err);
set.status = 500; set.status = 500;
@@ -152,6 +184,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
scrollRevealStyle: t.Optional(t.String()), scrollRevealStyle: t.Optional(t.String()),
scrollRevealDuration: t.Optional(t.Number()), scrollRevealDuration: t.Optional(t.Number()),
headerStyle: t.Optional(t.String()), headerStyle: t.Optional(t.String()),
headerBehavior: t.Optional(t.String()),
}) })
}) })
.patch("/translations", async ({ body, set }) => { .patch("/translations", async ({ body, set }) => {
+33
View File
@@ -8,6 +8,7 @@ import {
const WEBSITE_PAGE_SECTION_TYPE_LIST = [ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
"HERO", "HERO",
"HERO_CAROUSEL",
"ANNOUNCEMENT_BAR", "ANNOUNCEMENT_BAR",
"NEWSLETTER", "NEWSLETTER",
"EDITORIAL_TILE", "EDITORIAL_TILE",
@@ -227,6 +228,38 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
] ]
} }
}, },
HERO_CAROUSEL: {
type: "HERO_CAROUSEL",
requiredFields: ["slides[].imageUrl"],
optionalFields: ["autoplay", "interval", "aspectRatio", "mobileAspectRatio", "showArrows", "showDots", "backgroundColor", "slides[].mobileImageUrl", "slides[].linkUrl", "slides[].alt", "slides[].title", "slides[].subtitle", "slides[].buttonText", "slides[].focalPoint"],
renderedFields: ["autoplay", "interval", "aspectRatio", "mobileAspectRatio", "showArrows", "showDots", "backgroundColor", "slides"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Full-wide edge-to-edge promotional carousel. Default aspect ratio is '21/7' for a sleek Beautrium-style wide ribbon."],
minimalPayload: {
slides: [
{ imageUrl: imageUrlExample, linkUrl: "/shop", alt: localizedObjectExample }
]
},
recommendedPayload: {
autoplay: true,
interval: 4500,
aspectRatio: "21/7",
mobileAspectRatio: "16/9",
showArrows: true,
showDots: true,
slides: [
{
imageUrl: imageUrlExample,
linkUrl: "/shop",
alt: { th: "โปรโมชั่นพิเศษ 1", en: "Special Deal 1" },
title: { th: "โปรแรงลดสูงสุด [70%]", en: "Mega Sale Up To [70% OFF]" },
subtitle: { th: "BEAUTY DEALS", en: "BEAUTY DEALS" },
buttonText: { th: "ช้อปเลย", en: "Shop Now" }
}
]
}
},
EDITORIAL_CAROUSEL: { EDITORIAL_CAROUSEL: {
type: "EDITORIAL_CAROUSEL", type: "EDITORIAL_CAROUSEL",
requiredFields: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"], requiredFields: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"],
@@ -0,0 +1,157 @@
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Pagination, Navigation } from 'swiper/modules';
import { Link } from 'react-router-dom';
import type { HeroCarouselSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
// Import Swiper styles
import 'swiper/css';
import 'swiper/css/pagination';
import 'swiper/css/navigation';
interface DynamicHeroCarouselProps {
payload: HeroCarouselSectionPayload;
}
export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProps) {
const { i18n } = useTranslation();
const lang = i18n.language;
const slides = (payload.slides || []).map((slide) => ({
...slide,
title: getLocalizedField(slide.title, lang),
subtitle: getLocalizedField(slide.subtitle, lang),
buttonText: getLocalizedField(slide.buttonText, lang),
alt: getLocalizedField(slide.alt, lang),
}));
if (slides.length === 0) return null;
const autoplay = payload.autoplay !== false;
const interval = payload.interval || 4500;
const showArrows = payload.showArrows !== false;
const showDots = payload.showDots !== false;
const aspectRatio = payload.aspectRatio || '21/7';
const mobileAspectRatio = payload.mobileAspectRatio || '16/9';
const renderTitle = (text: string) => {
if (!text) return null;
return text.split('[').map((part, i) => {
if (part.includes(']')) {
const [accent, rest] = part.split(']');
return (
<span key={i}>
<span className="text-pop-pink font-medium italic">
{accent}
</span>
{rest}
</span>
);
}
return <span key={i}>{part}</span>;
});
};
const getAspectClass = () => {
switch (aspectRatio) {
case '21/6': return 'md:aspect-[21/6]';
case '21/7': return 'md:aspect-[21/7]';
case '21/9': return 'md:aspect-[21/9]';
case '16/5': return 'md:aspect-[16/5]';
case '16/6': return 'md:aspect-[16/6]';
case '4/1': return 'md:aspect-[4/1]';
case '5/1': return 'md:aspect-[5/1]';
default: return 'md:aspect-[21/7]';
}
};
const getMobileAspectClass = () => {
switch (mobileAspectRatio) {
case '16/9': return 'aspect-[16/9]';
case '4/3': return 'aspect-[4/3]';
case '1/1': return 'aspect-[1/1]';
case '21/9': return 'aspect-[21/9]';
default: return 'aspect-[16/9]';
}
};
return (
<section className="relative w-full overflow-hidden bg-pop-bg">
<Swiper
modules={[Autoplay, Pagination, Navigation]}
slidesPerView={1}
loop={slides.length > 1}
autoplay={autoplay ? { delay: interval, disableOnInteraction: false } : false}
pagination={showDots ? { clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-pink' } : false}
navigation={showArrows}
grabCursor={true}
className="w-full select-none [&_.swiper-button-next]:text-white [&_.swiper-button-prev]:text-white [&_.swiper-button-next]:after:text-sm md:[&_.swiper-button-next]:after:text-base [&_.swiper-button-prev]:after:text-sm md:[&_.swiper-button-prev]:after:text-base [&_.swiper-button-next]:w-8 md:[&_.swiper-button-next]:w-10 [&_.swiper-button-next]:h-8 md:[&_.swiper-button-next]:h-10 [&_.swiper-button-prev]:w-8 md:[&_.swiper-button-prev]:w-10 [&_.swiper-button-prev]:h-8 md:[&_.swiper-button-prev]:h-10 [&_.swiper-button-next]:bg-black/35 [&_.swiper-button-prev]:bg-black/35 [&_.swiper-button-next]:backdrop-blur-sm [&_.swiper-button-prev]:backdrop-blur-sm [&_.swiper-button-next]:rounded-full [&_.swiper-button-prev]:rounded-full [&_.swiper-button-next]:hover:bg-black/60 [&_.swiper-button-prev]:hover:bg-black/60 [&_.swiper-pagination-bullet]:bg-white/60 [&_.swiper-pagination-bullet-active]:!bg-pop-pink [&_.swiper-pagination]:!bottom-2.5 md:[&_.swiper-pagination]:!bottom-4"
>
{slides.map((slide, index) => {
const imageUrl = slide.imageUrl || '';
const mobileImageUrl = slide.mobileImageUrl || imageUrl;
const linkUrl = slide.linkUrl || '';
const altText = slide.alt || slide.title || `Slide ${index + 1}`;
const focalPoint = typeof slide.focalPoint === 'object' && slide.focalPoint ? slide.focalPoint : undefined;
if (!imageUrl) return null;
const slideContent = (
<div className={`relative w-full ${getMobileAspectClass()} ${getAspectClass()} overflow-hidden`}>
{/* Desktop Image */}
<img
src={imageUrl}
alt={altText}
className="hidden md:block absolute inset-0 w-full h-full object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
{/* Mobile Image */}
<img
src={mobileImageUrl}
alt={altText}
className="block md:hidden absolute inset-0 w-full h-full object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
{/* 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>
)}
</div>
</div>
)}
</div>
);
return (
<SwiperSlide key={index}>
{linkUrl ? (
<Link to={linkUrl} className="block w-full h-full group cursor-pointer">
{slideContent}
</Link>
) : (
slideContent
)}
</SwiperSlide>
);
})}
</Swiper>
</section>
);
}
+46 -22
View File
@@ -2,6 +2,7 @@ import type { PageSection, Category, AccordionSectionPayload, ContactInfoSection
import DynamicHero from './DynamicHero'; import DynamicHero from './DynamicHero';
import DynamicHeroV2 from './DynamicHeroV2'; import DynamicHeroV2 from './DynamicHeroV2';
import DynamicHeroVideo from './DynamicHeroVideo'; import DynamicHeroVideo from './DynamicHeroVideo';
import DynamicHeroCarousel from './DynamicHeroCarousel';
import DynamicUspBar from './DynamicUspBar'; import DynamicUspBar from './DynamicUspBar';
import DynamicCategories from './DynamicCategories'; import DynamicCategories from './DynamicCategories';
import DynamicProducts from './DynamicProducts'; import DynamicProducts from './DynamicProducts';
@@ -36,24 +37,30 @@ export default function DynamicPageRenderer({
categories, categories,
categoriesLoading categoriesLoading
}: DynamicPageRendererProps) { }: DynamicPageRendererProps) {
useEffect(() => { const HERO_TYPES = new Set([
const HERO_TYPES = new Set([ 'HERO',
'HERO', 'HERO_V2',
'HERO_V2', 'HERO_VIDEO',
'HERO_VIDEO', 'HERO_CAROUSEL',
'HERO_ARTICLE', 'HERO_ARTICLE',
'HERO_MINIMAL', 'HERO_MINIMAL',
'HERO_SPLIT_MINIMAL', 'HERO_SPLIT_MINIMAL',
'HERO_VIDEO_MINIMAL', 'HERO_VIDEO_MINIMAL',
]); ]);
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR'); const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
if (!firstActiveSection && sections.length > 0 && sections.every(s => !s.isActive || s.type.toUpperCase() === 'ANNOUNCEMENT_BAR')) { const firstPayload = (firstActiveSection?.payload || {}) as Record<string, unknown>;
return; const defaultSpacing = firstActiveSection?.type.toUpperCase() === 'HERO_CAROUSEL' ? 'below_header' : 'under_header';
} const firstHeaderSpacing = typeof firstPayload.headerSpacing === 'string' ? firstPayload.headerSpacing : defaultSpacing;
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()));
const isHeroFirst = Boolean(
firstActiveSection &&
HERO_TYPES.has(firstActiveSection.type.toUpperCase()) &&
firstHeaderSpacing === 'under_header'
);
useEffect(() => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false'; document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false';
} }
return () => { return () => {
@@ -61,23 +68,29 @@ export default function DynamicPageRenderer({
document.documentElement.dataset.heroFirst = 'false'; document.documentElement.dataset.heroFirst = 'false';
} }
}; };
}, [sections]); }, [sections, isHeroFirst]);
return ( return (
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
{sections.map((section) => { {sections.map((section) => {
if (!section.isActive) return null; if (!section.isActive) return null;
switch (section.type.toUpperCase()) { const isFirstContent = section.id === firstActiveSection?.id;
// ไม่ animate — แสดงตั้งแต่ต้น const needsTopSpacing = isFirstContent && !isHeroFirst;
case 'ANNOUNCEMENT_BAR':
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />; const rendered = (() => {
switch (section.type.toUpperCase()) {
// ไม่ animate — แสดงตั้งแต่ต้น
case 'ANNOUNCEMENT_BAR':
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
case 'HERO': case 'HERO':
return <DynamicHero key={section.id} payload={section.payload} />; return <DynamicHero key={section.id} payload={section.payload} />;
case 'HERO_V2': case 'HERO_V2':
return <DynamicHeroV2 key={section.id} payload={section.payload} />; return <DynamicHeroV2 key={section.id} payload={section.payload} />;
case 'HERO_VIDEO': case 'HERO_VIDEO':
return <DynamicHeroVideo key={section.id} payload={section.payload} />; return <DynamicHeroVideo key={section.id} payload={section.payload} />;
case 'HERO_CAROUSEL':
return <DynamicHeroCarousel key={section.id} payload={section.payload} />;
case 'USP_BAR': case 'USP_BAR':
return <DynamicUspBar key={section.id} payload={section.payload} />; return <DynamicUspBar key={section.id} payload={section.payload} />;
@@ -148,7 +161,18 @@ export default function DynamicPageRenderer({
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`); console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
return null; return null;
} }
})} })();
if (needsTopSpacing && rendered) {
return (
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
{rendered}
</div>
);
}
return rendered;
})}
</div> </div>
); );
} }
+324 -129
View File
@@ -39,6 +39,8 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
const { megaMenu, fetchMegaMenu } = useMenuStore() const { megaMenu, fetchMegaMenu } = useMenuStore()
const navRef = useRef<HTMLElement>(null) const navRef = useRef<HTMLElement>(null)
const previousScrollY = useRef(0)
const [scrollingDown, setScrollingDown] = useState(false)
const LANGUAGES = [ const LANGUAGES = [
{ code: 'th', label: 'ไทย', short: 'TH' }, { code: 'th', label: 'ไทย', short: 'TH' },
@@ -66,8 +68,19 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
checkAnnouncement() checkAnnouncement()
const timer = setTimeout(checkAnnouncement, 300) const timer = setTimeout(checkAnnouncement, 300)
previousScrollY.current = window.scrollY
const onScroll = () => { const onScroll = () => {
setScrolled(window.scrollY > 20) const currentScrollY = window.scrollY
const delta = currentScrollY - previousScrollY.current
setScrolled(currentScrollY > 20)
if (currentScrollY <= 20) {
setScrollingDown(false)
} else if (delta > 4) {
setScrollingDown(true)
} else if (delta < -4) {
setScrollingDown(false)
}
previousScrollY.current = currentScrollY
checkAnnouncement() checkAnnouncement()
} }
window.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true })
@@ -163,155 +176,337 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
return () => observer.disconnect(); return () => observer.disconnect();
}, [location.pathname]); }, [location.pathname]);
const shouldBeTransparent = (isTransparent || isHeroFirst) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen const headerBehavior =
(settings?.themeTokens as any)?.headerBehavior ||
settings?.headerBehavior ||
'hide_on_scroll'
const mobileHeaderBehavior =
(settings?.themeTokens as any)?.mobileHeaderBehavior ||
settings?.mobileHeaderBehavior ||
headerBehavior
const mobileHeaderStyle =
(settings?.themeTokens as any)?.mobileHeaderStyle ||
settings?.mobileHeaderStyle ||
'logo_center'
const activeHeaderBehavior = isDesktop ? headerBehavior : mobileHeaderBehavior
const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll'
const isCheckout = location.pathname.startsWith('/checkout')
const hideHeader =
!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen
const shouldBeTransparent = (isHeroFirst || Boolean(isTransparent && isHeroFirst)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const isTwoTier = settings?.headerStyle === 'two_tier'
const renderActionButtons = () => (
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
{/* Search */}
<button
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
aria-label="Search"
>
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
{/* Wishlist */}
<Link
to="/wishlist"
onClick={() => setMobileOpen(false)}
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
aria-label="Wishlist"
>
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
{wishlistCount > 0 && (
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{wishlistCount}
</span>
)}
</Link>
{/* Cart */}
<button
onClick={() => { toggleCart(true); setMobileOpen(false); }}
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
aria-label="Cart"
>
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{cartCount}
</span>
)}
</button>
{/* Language switcher (desktop) */}
{isClientReady && isDesktop && (
<div className="relative group flex items-center">
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
</button>
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
{LANGUAGES.map(lang => {
const isActive = i18n.language.startsWith(lang.code)
return (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
>
<span>{lang.label}</span>
{isActive && <Check className="w-3.5 h-3.5" />}
</button>
)
})}
</div>
</div>
</div>
)}
{/* Auth / Profile (desktop) */}
{isClientReady && isDesktop && (
customer ? (
<div className="relative group flex items-center ml-1">
<Link to="/profile">
<img
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="Profile"
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
/>
</Link>
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
</div>
<Link
to="/profile"
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
>
My Profile
</Link>
<button
onClick={logoutLiff}
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
>
{t('nav.logout', 'Logout')}
</button>
</div>
</div>
</div>
) : (
<button
onClick={loginLiff}
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
>
{t('nav.login', 'LINE Login')}
</button>
)
)}
</div>
)
const renderMobileActionButtons = (includeSearch = true) => (
<div className="flex items-center gap-1.5 shrink-0">
{includeSearch && (
<button
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
aria-label="Search"
>
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
)}
<Link
to="/wishlist"
onClick={() => setMobileOpen(false)}
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
aria-label="Wishlist"
>
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
{wishlistCount > 0 && (
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{wishlistCount}
</span>
)}
</Link>
<button
onClick={() => { toggleCart(true); setMobileOpen(false); }}
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
aria-label="Cart"
>
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{cartCount}
</span>
)}
</button>
</div>
)
const renderMobileHamburger = () => (
<button
onClick={() => setMobileOpen(prev => !prev)}
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
</button>
)
const renderMobileNavbar = () => {
if (mobileHeaderStyle === 'logo_left') {
return (
<div className="flex w-full items-center justify-between gap-3 md:hidden">
<div className="flex shrink-0 items-center">
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0">
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 object-contain" /> : brandName}
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{renderMobileActionButtons(true)}
{renderMobileHamburger()}
</div>
</div>
)
}
if (mobileHeaderStyle === 'search_bar_below') {
return (
<div className="flex w-full flex-col gap-2 md:hidden">
<div className="flex w-full items-center justify-between gap-3">
<div className="flex shrink-0 items-center">
<Link to="/" onClick={() => setMobileOpen(false)} className="text-lg font-bold italic tracking-widest text-pop-black uppercase shrink-0">
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-7 object-contain" /> : brandName}
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{renderMobileActionButtons(false)}
{renderMobileHamburger()}
</div>
</div>
<form onSubmit={handleSearch} className="relative w-full">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหากลิ่น, หมวดหมู่..."
className="w-full rounded-full border border-pop-black/15 bg-pop-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-pop-black placeholder:text-pop-black/40 focus:outline-none focus:ring-1 focus:ring-pop-black"
/>
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-pop-black/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</form>
</div>
)
}
// Default: 'logo_center'
return (
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center">
{renderMobileHamburger()}
</div>
<div className="flex items-center justify-center text-center">
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0">
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 object-contain mx-auto" /> : brandName}
</Link>
</div>
<div className="flex items-center justify-end">
{renderMobileActionButtons(true)}
</div>
</div>
)
}
return ( return (
<> <>
<nav <nav
ref={navRef} ref={navRef}
className={`fixed ${finalTopClassName} z-50 w-full transition-all duration-300 ${ className={`fixed ${finalTopClassName} z-50 w-full transition-all duration-300 ${
hideHeader ? 'pointer-events-none -translate-y-full' : 'translate-y-0'
} ${
scrolled scrolled
? 'border-b border-pop-black/10 shadow-lg shadow-pop-black/10 backdrop-blur-xl bg-pop-bg/90' ? 'border-b border-pop-black/10 shadow-lg shadow-pop-black/10 backdrop-blur-xl bg-pop-bg/90'
: shouldBeTransparent : shouldBeTransparent
? 'border-transparent shadow-none bg-transparent backdrop-blur-none' ? 'border-transparent shadow-none bg-transparent backdrop-blur-none'
: 'border-b border-pop-black/10 shadow-none bg-pop-bg' : 'border-b border-pop-black/10 shadow-none bg-pop-bg'
} px-4 py-3.5 md:px-6 md:py-4`} } ${isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4'}`}
> >
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */} {/* Mobile Navigation Bar (< md) */}
<div className="w-full flex items-center justify-between gap-4"> {renderMobileNavbar()}
{/* Left: Logo */} {/* Desktop Navigation Bar (>= md) */}
<div className="flex items-center shrink-0"> {isTwoTier ? (
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl"> <div className="hidden md:flex w-full flex-col">
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName} {/* Top Tier: Logo Centered + Actions Right (Desktop) */}
</Link> <div className={`flex w-full items-center justify-between transition-all duration-300 md:grid md:grid-cols-3 ${
</div> scrolled ? 'max-h-0 opacity-0 overflow-hidden py-0' : 'max-h-24 py-1.5 opacity-100'
}`}>
<div className="flex items-center" />
{/* Center: Desktop mega menu */} <div className="flex items-center justify-center text-center">
{isClientReady && isDesktop && ( <Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
<div className="flex-1 flex justify-center"> {logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain mx-auto" /> : brandName}
<DesktopMegaMenu items={megaMenu} /> </Link>
</div>
<div className="flex items-center justify-end">
{renderActionButtons()}
</div>
</div> </div>
)}
{/* Right: Actions */} {/* Bottom Tier: Desktop centered mega menu */}
<div className="flex items-center gap-1.5 md:gap-2 shrink-0"> <div className={`hidden md:flex w-full items-center justify-between transition-all duration-300 ${
{/* Search — desktop & mobile */} scrolled ? 'py-0.5' : 'border-t border-pop-black/5 pt-2 mt-1'
<button }`}>
onClick={() => { setSearchOpen(true); setMobileOpen(false); }} <div className={`transition-all duration-300 ${scrolled ? 'w-auto opacity-100' : 'w-0 opacity-0 overflow-hidden'}`}>
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2" <Link to="/" className="text-sm font-bold tracking-widest text-pop-black uppercase">
aria-label="Search" {logoUrl ? <img src={logoUrl} alt={brandName} className="h-6 w-auto object-contain" /> : brandName}
> </Link>
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<Link <div className="flex-1 flex justify-center">
to="/wishlist" {isClientReady && isDesktop && (
onClick={() => setMobileOpen(false)} <DesktopMegaMenu items={megaMenu} />
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
aria-label="Wishlist"
>
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
{wishlistCount > 0 && (
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{wishlistCount}
</span>
)}
</Link>
<button
onClick={() => { toggleCart(true); setMobileOpen(false); }}
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
aria-label="Cart"
>
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{cartCount}
</span>
)}
</button>
{isClientReady && isDesktop && (
<>
<div className="relative group flex items-center">
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
</button>
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
{LANGUAGES.map(lang => {
const isActive = i18n.language.startsWith(lang.code)
return (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
>
<span>{lang.label}</span>
{isActive && <Check className="w-3.5 h-3.5" />}
</button>
)
})}
</div>
</div>
</div>
{customer ? (
<div className="relative group flex items-center ml-1">
<Link to="/profile">
<img
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="Profile"
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
/>
</Link>
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
</div>
<Link
to="/profile"
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
>
My Profile
</Link>
<button
onClick={logoutLiff}
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
>
{t('nav.logout', 'Logout')}
</button>
</div>
</div>
</div>
) : (
<button
onClick={loginLiff}
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
>
{t('nav.login', 'LINE Login')}
</button>
)} )}
</> </div>
<div className={`transition-all duration-300 ${scrolled ? 'w-8' : 'w-0'}`} />
</div>
</div>
) : (
/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */
<div className="hidden md:flex w-full items-center justify-between gap-4">
{/* Left: Logo */}
<div className="flex items-center shrink-0">
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
</Link>
</div>
{/* Center: Desktop mega menu */}
{isClientReady && isDesktop && (
<div className="flex-1 flex justify-center">
<DesktopMegaMenu items={megaMenu} />
</div>
)} )}
{isClientReady && !isDesktop && ( {/* Right: Actions */}
<button {renderActionButtons()}
onClick={() => setMobileOpen(prev => !prev)}
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
</button>
)}
</div> </div>
</div> )}
</nav> </nav>
{isClientReady && !isDesktop && mobileOpen && ( {isClientReady && !isDesktop && mobileOpen && (
+1 -1
View File
@@ -45,7 +45,7 @@ export default function HomePage() {
return ( return (
<div className="min-h-screen bg-pop-bg"> <div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} isTransparent={true} /> <Navbar categories={categories} />
{loading ? ( {loading ? (
<div className="min-h-[60vh] flex items-center justify-center"> <div className="min-h-[60vh] flex items-center justify-center">
+161 -30
View File
@@ -211,12 +211,27 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
}, [pathname]) }, [pathname])
const isHome = getPathWithoutLocale(pathname) === '/' const isHome = getPathWithoutLocale(pathname) === '/'
const isTransparent = (isHome || isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen const isTransparent = (isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const pathWithoutLocale = getPathWithoutLocale(pathname) const pathWithoutLocale = getPathWithoutLocale(pathname)
const isCommercePath = ['/shop', '/products', '/category', '/cart', '/checkout', '/wishlist', '/profile', '/order', '/track'] const headerBehavior =
.some((route) => pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`)) (settings?.themeTokens as any)?.headerBehavior ||
const hideDesktopHeader = settings?.headerBehavior ||
(!isCommercePath && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) || 'hide_on_scroll'
const mobileHeaderBehavior =
(settings?.themeTokens as any)?.mobileHeaderBehavior ||
settings?.mobileHeaderBehavior ||
headerBehavior
const mobileHeaderStyle =
(settings?.themeTokens as any)?.mobileHeaderStyle ||
settings?.mobileHeaderStyle ||
'logo_center'
const isDesktopView = isClientReady && canRenderDesktopMenu
const activeHeaderBehavior = isDesktopView ? headerBehavior : mobileHeaderBehavior
const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll'
const isCheckout = pathWithoutLocale === '/checkout' || pathWithoutLocale.startsWith('/checkout/')
const hideHeader =
(!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) ||
(productStickyVisible && !mobileOpen && !searchOpen && !langSheetOpen) (productStickyVisible && !mobileOpen && !searchOpen && !langSheetOpen)
useEffect(() => { useEffect(() => {
@@ -233,6 +248,138 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
const isTwoTier = settings?.headerStyle === 'two_tier' const isTwoTier = settings?.headerStyle === 'two_tier'
const renderMobileActionButtons = (includeSearch = true) => (
<div className="flex items-center gap-1.5 shrink-0">
{includeSearch && (
<button
onClick={() => {
setSearchOpen(true)
setMobileOpen(false)
}}
className={iconButtonClass}
aria-label="Search"
>
<Search className="h-4 w-4 transition-colors" />
</button>
)}
<Link
href="/wishlist"
onClick={() => setMobileOpen(false)}
className={`relative ${iconButtonClass}`}
aria-label="Wishlist"
>
<Heart className="h-4 w-4 transition-colors" />
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-brand-primary text-[9px] font-bold text-surface-card">
{wishlistCount}
</span>
)}
</Link>
<button
onClick={() => {
toggleCart(true)
setMobileOpen(false)
}}
className={`relative ${iconButtonClass}`}
aria-label="Cart"
>
<ShoppingBag className="h-4 w-4 transition-colors" />
{cartCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-brand-primary text-[9px] font-bold text-surface-card">
{cartCount}
</span>
)}
</button>
</div>
)
const renderMobileHamburger = () => (
<button
onClick={() => setMobileOpen((prev) => !prev)}
className={iconButtonClass}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
)
const renderMobileNavbar = () => {
if (mobileHeaderStyle === 'logo_left') {
return (
<div className="flex w-full items-center justify-between gap-3 md:hidden">
<div className="flex shrink-0 items-center">
<Link href="/" onClick={() => setMobileOpen(false)} className="ui-brand-wordmark">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={150} height={38} unoptimized className="h-8 w-auto object-contain" />
) : (
<span className="text-xl font-bold uppercase tracking-[0.2em] text-text-main">{brandName}</span>
)}
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{renderMobileActionButtons(true)}
{renderMobileHamburger()}
</div>
</div>
)
}
if (mobileHeaderStyle === 'search_bar_below') {
return (
<div className="flex w-full flex-col gap-2 md:hidden">
<div className="flex w-full items-center justify-between gap-3">
<div className="flex shrink-0 items-center">
<Link href="/" onClick={() => setMobileOpen(false)} className="ui-brand-wordmark">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={140} height={35} unoptimized className="h-7 w-auto object-contain" />
) : (
<span className="text-lg font-bold uppercase tracking-[0.2em] text-text-main">{brandName}</span>
)}
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{renderMobileActionButtons(false)}
{renderMobileHamburger()}
</div>
</div>
<form onSubmit={handleSearch} className="relative w-full">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('nav.searchPlaceholder', 'ค้นหาสินค้า กลิ่นหอม หรือหมวดหมู่...')}
className="w-full rounded-full border border-black/10 bg-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-text-main placeholder:text-muted-text/60 focus:outline-none focus:ring-1 focus:ring-brand-primary dark:border-white/10 dark:bg-white/[0.06]"
/>
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-text" />
</form>
</div>
)
}
// Default: 'logo_center'
return (
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center">
{renderMobileHamburger()}
</div>
<div className="flex items-center justify-center text-center">
<Link href="/" onClick={() => setMobileOpen(false)} className="ui-brand-wordmark text-center">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={150} height={38} unoptimized className="h-8 w-auto object-contain" />
) : (
<span className="text-xl font-bold uppercase tracking-[0.2em] text-text-main">{brandName}</span>
)}
</Link>
</div>
<div className="flex items-center justify-end">
{renderMobileActionButtons(true)}
</div>
</div>
)
}
return ( return (
<> <>
<nav <nav
@@ -241,7 +388,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
className={`global-navbar fixed top-0 z-50 w-full border-b transition-all duration-300 ${ className={`global-navbar fixed top-0 z-50 w-full border-b transition-all duration-300 ${
isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4' isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4'
} ${ } ${
hideDesktopHeader ? 'md:pointer-events-none md:-translate-y-full' : 'translate-y-0' hideHeader ? 'pointer-events-none -translate-y-full' : 'translate-y-0'
} ${ } ${
scrolled scrolled
? 'border-black/5 bg-surface-page/95 shadow-lg shadow-black/5 backdrop-blur-xl' ? 'border-black/5 bg-surface-page/95 shadow-lg shadow-black/5 backdrop-blur-xl'
@@ -250,23 +397,17 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
: 'border-black/5 bg-surface-page/80 backdrop-blur-xl md:bg-surface-nav-rest md:backdrop-blur-none' : 'border-black/5 bg-surface-page/80 backdrop-blur-xl md:bg-surface-nav-rest md:backdrop-blur-none'
}`} }`}
> >
{/* Mobile Navigation Bar (< md) */}
{renderMobileNavbar()}
{/* Desktop Navigation Bar (>= md) */}
{isTwoTier ? ( {isTwoTier ? (
<div className="flex w-full flex-col"> <div className="hidden md:flex w-full flex-col">
{/* Top Tier: Logo Centered + Icons Right (Desktop) & Mobile Top Row */} {/* Top Tier: Logo Centered + Icons Right (Desktop) */}
<div className={`flex w-full items-center justify-between transition-all duration-300 md:grid md:grid-cols-3 ${ <div className={`flex w-full items-center justify-between transition-all duration-300 md:grid md:grid-cols-3 ${
scrolled ? 'max-h-0 opacity-0 overflow-hidden py-0' : 'max-h-24 py-1.5 opacity-100' scrolled ? 'max-h-0 opacity-0 overflow-hidden py-0' : 'max-h-24 py-1.5 opacity-100'
}`}> }`}>
<div className="flex items-center"> <div className="flex items-center" />
{isClientReady && !canRenderDesktopMenu && (
<button
onClick={() => setMobileOpen((prev) => !prev)}
className={iconButtonClass}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
)}
</div>
<div className="flex items-center justify-center text-center"> <div className="flex items-center justify-center text-center">
<Link <Link
@@ -422,7 +563,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex w-full items-center justify-between gap-4"> <div className="hidden md:flex w-full items-center justify-between gap-4">
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<Link <Link
href="/" href="/"
@@ -542,16 +683,6 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
</div> </div>
</> </>
)} )}
{isClientReady && !canRenderDesktopMenu && (
<button
onClick={() => setMobileOpen((prev) => !prev)}
className={iconButtonClass}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -3,7 +3,7 @@
import Image from 'next/image' import Image from 'next/image'
import Link from '../LocalizedLink' import Link from '../LocalizedLink'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react' import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
import { useState, useRef } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types' import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types' import { getCategoryImage } from '@homaroma/types'
import ProductCard from '../ProductCard' import ProductCard from '../ProductCard'
@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
import { customRegistry } from '../../config/customRegistry' import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules' import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import ScrollReveal from '../ScrollReveal'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/pagination' import 'swiper/css/pagination'
import 'swiper/css/navigation' import 'swiper/css/navigation'
@@ -155,6 +156,129 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
) )
} }
function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const slides = Array.isArray(payload.slides) ? payload.slides : []
const autoplay = typeof payload.autoplay === 'boolean' ? payload.autoplay : true
const interval = typeof payload.interval === 'number' ? payload.interval : 4500
const showArrows = typeof payload.showArrows === 'boolean' ? payload.showArrows : true
const showDots = typeof payload.showDots === 'boolean' ? payload.showDots : true
const aspectRatio = typeof payload.aspectRatio === 'string' ? payload.aspectRatio : '21/7'
const mobileAspectRatio = typeof payload.mobileAspectRatio === 'string' ? payload.mobileAspectRatio : '16/9'
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
if (!slides.length) return null
const getAspectClass = () => {
switch (aspectRatio) {
case '21/6': return 'md:aspect-[21/6]'
case '21/7': return 'md:aspect-[21/7]'
case '21/9': return 'md:aspect-[21/9]'
case '16/5': return 'md:aspect-[16/5]'
case '16/6': return 'md:aspect-[16/6]'
case '4/1': return 'md:aspect-[4/1]'
case '5/1': return 'md:aspect-[5/1]'
default: return 'md:aspect-[21/7]'
}
}
const getMobileAspectClass = () => {
switch (mobileAspectRatio) {
case '16/9': return 'aspect-[16/9]'
case '4/3': return 'aspect-[4/3]'
case '1/1': return 'aspect-[1/1]'
case '21/9': return 'aspect-[21/9]'
default: return 'aspect-[16/9]'
}
}
return (
<section className="relative w-full overflow-hidden bg-surface-page" style={{ backgroundColor }}>
<Swiper
modules={[Autoplay, Pagination, Navigation]}
slidesPerView={1}
loop={slides.length > 1}
autoplay={autoplay ? { delay: interval, disableOnInteraction: false } : false}
pagination={showDots ? { clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-brand-primary' } : false}
navigation={showArrows}
grabCursor={true}
className="w-full select-none [&_.swiper-button-next]:text-white [&_.swiper-button-prev]:text-white [&_.swiper-button-next]:after:text-sm md:[&_.swiper-button-next]:after:text-base [&_.swiper-button-prev]:after:text-sm md:[&_.swiper-button-prev]:after:text-base [&_.swiper-button-next]:w-8 md:[&_.swiper-button-next]:w-10 [&_.swiper-button-next]:h-8 md:[&_.swiper-button-next]:h-10 [&_.swiper-button-prev]:w-8 md:[&_.swiper-button-prev]:w-10 [&_.swiper-button-prev]:h-8 md:[&_.swiper-button-prev]:h-10 [&_.swiper-button-next]:bg-black/35 [&_.swiper-button-prev]:bg-black/35 [&_.swiper-button-next]:backdrop-blur-sm [&_.swiper-button-prev]:backdrop-blur-sm [&_.swiper-button-next]:rounded-full [&_.swiper-button-prev]:rounded-full [&_.swiper-button-next]:hover:bg-black/60 [&_.swiper-button-prev]:hover:bg-black/60 [&_.swiper-pagination-bullet]:bg-white/60 [&_.swiper-pagination-bullet-active]:!bg-brand-primary [&_.swiper-pagination]:!bottom-2.5 md:[&_.swiper-pagination]:!bottom-4"
>
{slides.map((slide, index) => {
const title = localizedFromRecord(slide, 'title', resolvedLang)
const subtitle = localizedFromRecord(slide, 'subtitle', resolvedLang)
const alt = localizedFromRecord(slide, 'alt', resolvedLang) || title || `Slide ${index + 1}`
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
const mobileImageUrl = typeof slide.mobileImageUrl === 'string' && slide.mobileImageUrl ? slide.mobileImageUrl : imageUrl
const linkUrl = typeof slide.linkUrl === 'string' ? slide.linkUrl : ''
const focalPoint = typeof slide.focalPoint === 'object' && slide.focalPoint ? (slide.focalPoint as { x: string, y: string }) : undefined
if (!imageUrl) return null
const slideContent = (
<div className={`relative w-full ${getMobileAspectClass()} ${getAspectClass()} overflow-hidden`}>
{/* Desktop Image */}
<div className="hidden md:block absolute inset-0 w-full h-full">
<Image
src={imageUrl}
alt={alt}
fill
unoptimized
priority={index === 0}
className="object-cover w-full h-full"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Mobile Image */}
<div className="block md:hidden absolute inset-0 w-full h-full">
<Image
src={mobileImageUrl}
alt={alt}
fill
unoptimized
priority={index === 0}
className="object-cover w-full h-full"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</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>
)}
</div>
</div>
)}
</div>
)
return (
<SwiperSlide key={index}>
{linkUrl ? (
<Link href={linkUrl} className="block w-full h-full group cursor-pointer">
{slideContent}
</Link>
) : (
slideContent
)}
</SwiperSlide>
)
})}
</Swiper>
</section>
)
}
function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang() const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1888,32 +2012,36 @@ function ContactFormSection({ payload, lang }: { payload: Record<string, unknown
) )
} }
import { useEffect } from 'react'
import ScrollReveal from '../ScrollReveal'
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) { export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
const i18nLang = useLang() const i18nLang = useLang()
// Use i18n.language reactively — switches immediately when user changes language // Use i18n.language reactively — switches immediately when user changes language
const lang = langProp ?? i18nLang const lang = langProp ?? i18nLang
useEffect(() => { const HERO_TYPES = useMemo(() => new Set([
const HERO_TYPES = new Set([ 'HERO',
'HERO', 'HERO_V2',
'HERO_V2', 'HERO_VIDEO',
'HERO_VIDEO', 'HERO_ARTICLE',
'HERO_ARTICLE', 'HERO_MINIMAL',
'HERO_MINIMAL', 'HERO_SPLIT_MINIMAL',
'HERO_SPLIT_MINIMAL', 'HERO_VIDEO_MINIMAL',
'HERO_VIDEO_MINIMAL', 'HERO_CAROUSEL',
]) ]), [])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR')
if (!firstActiveSection && sections.length > 0 && sections.every(s => !s.isActive || s.type.toUpperCase() === 'ANNOUNCEMENT_BAR')) {
return
}
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()))
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR_MINIMAL')
const firstPayload = toRecord(firstActiveSection?.payload)
const defaultSpacing = firstActiveSection?.type.toUpperCase() === 'HERO_CAROUSEL' ? 'below_header' : 'under_header'
const firstHeaderSpacing = typeof firstPayload.headerSpacing === 'string' ? firstPayload.headerSpacing : defaultSpacing
const isHeroFirst = Boolean(
firstActiveSection &&
HERO_TYPES.has(firstActiveSection.type.toUpperCase()) &&
firstHeaderSpacing === 'under_header'
)
useEffect(() => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false' document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false'
} }
return () => { return () => {
@@ -1921,7 +2049,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
document.documentElement.dataset.heroFirst = 'false' document.documentElement.dataset.heroFirst = 'false'
} }
} }
}, [sections]) }, [sections, isHeroFirst])
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
@@ -1930,10 +2058,13 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
const payload = toRecord(section.payload) const payload = toRecord(section.payload)
const type = section.type.toUpperCase() const type = section.type.toUpperCase()
const isFirstContent = section.id === firstActiveSection?.id
const needsTopSpacing = isFirstContent && !isHeroFirst
switch (type) { const rendered = (() => {
case 'ANNOUNCEMENT_BAR': switch (type) {
return <AnnouncementBar key={section.id} payload={payload} lang={lang} /> case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
case 'HERO': case 'HERO':
return ( return (
<ScrollReveal key={section.id}> <ScrollReveal key={section.id}>
@@ -1948,6 +2079,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
) )
case 'HERO_VIDEO': case 'HERO_VIDEO':
return <HeroVideoSection key={section.id} payload={payload} lang={lang} /> return <HeroVideoSection key={section.id} payload={payload} lang={lang} />
case 'HERO_CAROUSEL':
return (
<ScrollReveal key={section.id}>
<HeroCarouselSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_MINIMAL': case 'HERO_MINIMAL':
return ( return (
<ScrollReveal key={section.id}> <ScrollReveal key={section.id}>
@@ -2124,7 +2261,18 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
} }
return null return null
} }
})} })()
if (needsTopSpacing && rendered) {
return (
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
{rendered}
</div>
)
}
return rendered
})}
</div> </div>
) )
} }
+30 -1
View File
@@ -196,6 +196,9 @@ export interface StoreSettings {
scrollRevealStyle?: string | null scrollRevealStyle?: string | null
scrollRevealDuration?: number | null scrollRevealDuration?: number | null
headerStyle?: 'single_line' | 'two_tier' | string | null headerStyle?: 'single_line' | 'two_tier' | string | null
headerBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
mobileHeaderStyle?: 'logo_center' | 'logo_left' | 'search_bar_below' | string | null
mobileHeaderBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
} }
export interface FooterBrand { export interface FooterBrand {
@@ -473,6 +476,30 @@ export const EditorialCarouselSectionPayloadSchema = z.object({
}); });
export type EditorialCarouselSectionPayload = z.infer<typeof EditorialCarouselSectionPayloadSchema>; export type EditorialCarouselSectionPayload = z.infer<typeof EditorialCarouselSectionPayloadSchema>;
export const HeroCarouselSlideSchema = z.object({
imageUrl: z.string().min(1),
mobileImageUrl: z.string().optional(),
linkUrl: z.string().optional(),
alt: LocalizedStringSchema.optional(),
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(),
focalPoint: z.object({ x: z.string(), y: z.string() }).optional(),
});
export type HeroCarouselSlide = z.infer<typeof HeroCarouselSlideSchema>;
export const HeroCarouselSectionPayloadSchema = z.object({
slides: z.array(HeroCarouselSlideSchema).min(1),
autoplay: z.boolean().optional().default(true),
interval: z.number().optional().default(4500),
aspectRatio: z.enum(['21/6', '21/7', '21/9', '16/5', '16/6', '4/1', '5/1', 'auto']).optional().default('21/7'),
mobileAspectRatio: z.enum(['16/9', '4/3', '1/1', '21/9', 'auto']).optional().default('16/9'),
showArrows: z.boolean().optional().default(true),
showDots: z.boolean().optional().default(true),
backgroundColor: z.string().optional(),
});
export type HeroCarouselSectionPayload = z.infer<typeof HeroCarouselSectionPayloadSchema>;
export interface CustomerAddress { export interface CustomerAddress {
id: number id: number
customerId: number customerId: number
@@ -869,7 +896,7 @@ export const UspMinimalSectionPayloadSchema = z.object({
export type UspMinimalSectionPayload = z.infer<typeof UspMinimalSectionPayloadSchema>; export type UspMinimalSectionPayload = z.infer<typeof UspMinimalSectionPayloadSchema>;
export const PAGE_SECTION_TYPES = [ export const PAGE_SECTION_TYPES = [
'HERO', 'HERO_V2', 'HERO_VIDEO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'FEATURED_TILES', 'HERO', 'HERO_V2', 'HERO_VIDEO', 'HERO_CAROUSEL', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'FEATURED_TILES',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER', 'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_HEADER', 'ARTICLE_MEDIA', 'ARTICLE_GALLERY', 'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_HEADER', 'ARTICLE_MEDIA', 'ARTICLE_GALLERY',
'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'ARTICLE_TABLE', 'ARTICLE_DOWNLOAD', 'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'ARTICLE_TABLE', 'ARTICLE_DOWNLOAD',
@@ -885,6 +912,7 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
HERO: ['tagline', 'title', 'description', 'buttonText'], HERO: ['tagline', 'title', 'description', 'buttonText'],
HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'], HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'],
HERO_VIDEO: ['tagline', 'title', 'description', 'buttonText', 'secondaryButtonText'], HERO_VIDEO: ['tagline', 'title', 'description', 'buttonText', 'secondaryButtonText'],
HERO_CAROUSEL: ['slides[].title', 'slides[].subtitle', 'slides[].buttonText', 'slides[].alt'],
ANNOUNCEMENT_BAR: ['slides[]'], ANNOUNCEMENT_BAR: ['slides[]'],
NEWSLETTER: ['title', 'description', 'buttonText', 'placeholderText', 'privacyText'], NEWSLETTER: ['title', 'description', 'buttonText', 'placeholderText', 'privacyText'],
EDITORIAL_TILE: ['title', 'description', 'buttonText'], EDITORIAL_TILE: ['title', 'description', 'buttonText'],
@@ -930,6 +958,7 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
HERO: HeroSectionPayloadSchema, HERO: HeroSectionPayloadSchema,
HERO_V2: HeroV2SectionPayloadSchema, HERO_V2: HeroV2SectionPayloadSchema,
HERO_VIDEO: HeroVideoSectionPayloadSchema, HERO_VIDEO: HeroVideoSectionPayloadSchema,
HERO_CAROUSEL: HeroCarouselSectionPayloadSchema,
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema, ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
NEWSLETTER: NewsletterSectionPayloadSchema, NEWSLETTER: NewsletterSectionPayloadSchema,
EDITORIAL_TILE: EditorialTileSectionPayloadSchema, EDITORIAL_TILE: EditorialTileSectionPayloadSchema,