x
This commit is contained in:
1 parent
3ac0cb4f53
commit
5e724fae20
17 files changed
+2240
-114
No files matched your search
@@ -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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,18 +885,21 @@ 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>
|
||||||
|
|
||||||
|
{/* PC Layout Style */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">PC Header Layout Style</label>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -893,6 +950,190 @@ export default function ThemeSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 < 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>
|
||||||
|
|
||||||
{/* ── 5. Scroll Reveal Animation ── */}
|
{/* ── 5. Scroll Reveal Animation ── */}
|
||||||
<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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,13 +68,17 @@ 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;
|
||||||
|
|
||||||
|
const isFirstContent = section.id === firstActiveSection?.id;
|
||||||
|
const needsTopSpacing = isFirstContent && !isHeroFirst;
|
||||||
|
|
||||||
|
const rendered = (() => {
|
||||||
switch (section.type.toUpperCase()) {
|
switch (section.type.toUpperCase()) {
|
||||||
// ไม่ animate — แสดงตั้งแต่ต้น
|
// ไม่ animate — แสดงตั้งแต่ต้น
|
||||||
case 'ANNOUNCEMENT_BAR':
|
case 'ANNOUNCEMENT_BAR':
|
||||||
@@ -78,6 +89,8 @@ export default function DynamicPageRenderer({
|
|||||||
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,6 +161,17 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,40 +176,32 @@ 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'
|
||||||
|
|
||||||
return (
|
const activeHeaderBehavior = isDesktop ? headerBehavior : mobileHeaderBehavior
|
||||||
<>
|
const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll'
|
||||||
<nav
|
const isCheckout = location.pathname.startsWith('/checkout')
|
||||||
ref={navRef}
|
const hideHeader =
|
||||||
className={`fixed ${finalTopClassName} z-50 w-full transition-all duration-300 ${
|
!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen
|
||||||
scrolled
|
|
||||||
? 'border-b border-pop-black/10 shadow-lg shadow-pop-black/10 backdrop-blur-xl bg-pop-bg/90'
|
|
||||||
: shouldBeTransparent
|
|
||||||
? 'border-transparent shadow-none bg-transparent backdrop-blur-none'
|
|
||||||
: 'border-b border-pop-black/10 shadow-none bg-pop-bg'
|
|
||||||
} px-4 py-3.5 md:px-6 md:py-4`}
|
|
||||||
>
|
|
||||||
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */}
|
|
||||||
<div className="w-full flex items-center justify-between gap-4">
|
|
||||||
|
|
||||||
{/* Left: Logo */}
|
const shouldBeTransparent = (isHeroFirst || Boolean(isTransparent && isHeroFirst)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
|
||||||
<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 */}
|
const isTwoTier = settings?.headerStyle === 'two_tier'
|
||||||
{isClientReady && isDesktop && (
|
|
||||||
<div className="flex-1 flex justify-center">
|
|
||||||
<DesktopMegaMenu items={megaMenu} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right: Actions */}
|
const renderActionButtons = () => (
|
||||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||||
{/* Search — desktop & mobile */}
|
{/* Search */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
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"
|
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||||
@@ -207,6 +212,7 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Wishlist */}
|
||||||
<Link
|
<Link
|
||||||
to="/wishlist"
|
to="/wishlist"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
@@ -221,6 +227,7 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Cart */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
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"
|
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||||
@@ -234,8 +241,8 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Language switcher (desktop) */}
|
||||||
{isClientReady && isDesktop && (
|
{isClientReady && isDesktop && (
|
||||||
<>
|
|
||||||
<div className="relative group flex items-center">
|
<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">
|
<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} />
|
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
@@ -259,8 +266,11 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{customer ? (
|
{/* Auth / Profile (desktop) */}
|
||||||
|
{isClientReady && isDesktop && (
|
||||||
|
customer ? (
|
||||||
<div className="relative group flex items-center ml-1">
|
<div className="relative group flex items-center ml-1">
|
||||||
<Link to="/profile">
|
<Link to="/profile">
|
||||||
<img
|
<img
|
||||||
@@ -297,11 +307,55 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
|||||||
>
|
>
|
||||||
{t('nav.login', 'LINE Login')}
|
{t('nav.login', 'LINE Login')}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isClientReady && !isDesktop && (
|
<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
|
<button
|
||||||
onClick={() => setMobileOpen(prev => !prev)}
|
onClick={() => setMobileOpen(prev => !prev)}
|
||||||
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
||||||
@@ -309,9 +363,150 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
|||||||
>
|
>
|
||||||
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
||||||
</button>
|
</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 (
|
||||||
|
<>
|
||||||
|
<nav
|
||||||
|
ref={navRef}
|
||||||
|
className={`fixed ${finalTopClassName} z-50 w-full transition-all duration-300 ${
|
||||||
|
hideHeader ? 'pointer-events-none -translate-y-full' : 'translate-y-0'
|
||||||
|
} ${
|
||||||
|
scrolled
|
||||||
|
? 'border-b border-pop-black/10 shadow-lg shadow-pop-black/10 backdrop-blur-xl bg-pop-bg/90'
|
||||||
|
: shouldBeTransparent
|
||||||
|
? 'border-transparent shadow-none bg-transparent backdrop-blur-none'
|
||||||
|
: 'border-b border-pop-black/10 shadow-none bg-pop-bg'
|
||||||
|
} ${isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4'}`}
|
||||||
|
>
|
||||||
|
{/* Mobile Navigation Bar (< md) */}
|
||||||
|
{renderMobileNavbar()}
|
||||||
|
|
||||||
|
{/* Desktop Navigation Bar (>= md) */}
|
||||||
|
{isTwoTier ? (
|
||||||
|
<div className="hidden md:flex w-full flex-col">
|
||||||
|
{/* Top Tier: Logo Centered + Actions Right (Desktop) */}
|
||||||
|
<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'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center" />
|
||||||
|
|
||||||
|
<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 md:text-2xl">
|
||||||
|
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain mx-auto" /> : brandName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
{renderActionButtons()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Tier: Desktop centered mega menu */}
|
||||||
|
<div className={`hidden md:flex w-full items-center justify-between transition-all duration-300 ${
|
||||||
|
scrolled ? 'py-0.5' : 'border-t border-pop-black/5 pt-2 mt-1'
|
||||||
|
}`}>
|
||||||
|
<div className={`transition-all duration-300 ${scrolled ? 'w-auto opacity-100' : 'w-0 opacity-0 overflow-hidden'}`}>
|
||||||
|
<Link to="/" className="text-sm font-bold tracking-widest text-pop-black uppercase">
|
||||||
|
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-6 w-auto object-contain" /> : brandName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex justify-center">
|
||||||
|
{isClientReady && isDesktop && (
|
||||||
|
<DesktopMegaMenu items={megaMenu} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`transition-all duration-300 ${scrolled ? 'w-8' : 'w-0'}`} />
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
{renderActionButtons()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{isClientReady && !isDesktop && mobileOpen && (
|
{isClientReady && !isDesktop && mobileOpen && (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,16 +2012,12 @@ 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',
|
||||||
@@ -1905,15 +2025,23 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
|||||||
'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,7 +2058,10 @@ 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
|
||||||
|
|
||||||
|
const rendered = (() => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'ANNOUNCEMENT_BAR':
|
case 'ANNOUNCEMENT_BAR':
|
||||||
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
|
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
|
||||||
@@ -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,6 +2261,17 @@ 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
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user