x
This commit is contained in:
1 parent
3ac0cb4f53
commit
5e724fae20
17 files changed
+2404
-278
No files matched your search
@@ -9,6 +9,7 @@ import CategoriesPage from './pages/CategoriesPage'
|
||||
import CategoryEditPage from './pages/CategoryEditPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import PageBuilderPage from './pages/PageBuilderPage'
|
||||
import PageReorderPage from './pages/PageReorderPage'
|
||||
import CmsMockupPage from './pages/CmsMockupPage'
|
||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
@@ -98,6 +99,8 @@ function App() {
|
||||
<Route path="/categories/:id/edit" element={<CategoryEditPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
<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="/orders" element={<OrdersPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
|
||||
@@ -111,6 +111,38 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
</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
|
||||
{
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Layers,
|
||||
Plus,
|
||||
@@ -28,6 +29,7 @@ import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm'
|
||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||
import EditorialTileForm from '../components/page-builder/EditorialTileForm';
|
||||
import EditorialCarouselForm from '../components/page-builder/EditorialCarouselForm';
|
||||
import HeroCarouselForm from '../components/page-builder/HeroCarouselForm';
|
||||
import CategoriesForm from '../components/page-builder/CategoriesForm';
|
||||
import ProductsForm from '../components/page-builder/ProductsForm';
|
||||
import BannerForm from '../components/page-builder/BannerForm';
|
||||
@@ -82,6 +84,7 @@ interface Brand {
|
||||
|
||||
export default function PageBuilderPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const frontendBase = (
|
||||
import.meta.env.DEV
|
||||
? window.location.origin
|
||||
@@ -671,6 +674,35 @@ export default function PageBuilderPage() {
|
||||
buttonLink: '/shop'
|
||||
};
|
||||
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':
|
||||
defaultPayload = {
|
||||
imageUrl: '/images/hero.png',
|
||||
@@ -1165,11 +1197,17 @@ export default function PageBuilderPage() {
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => 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"
|
||||
onClick={() => {
|
||||
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" />
|
||||
{t('pageBuilder.btnReorderLayout', 'Reorder Layout')}
|
||||
<Layers className="w-4 h-4 text-cyan-glow" />
|
||||
{t('pageBuilder.btnReorderLayout', 'Reorder Layout (Full Screen)')}
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
@@ -1293,6 +1331,7 @@ export default function PageBuilderPage() {
|
||||
<option value="HERO">HERO (Campaign Intro)</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_CAROUSEL">HERO CAROUSEL (Wide Promo Slider)</option>
|
||||
<option value="ANNOUNCEMENT_BAR">ANNOUNCEMENT BAR (Premium Slides)</option>
|
||||
<option value="NEWSLETTER">NEWSLETTER (Email Subscription)</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' && (
|
||||
<UspBarForm
|
||||
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,
|
||||
Code2,
|
||||
X,
|
||||
Bot
|
||||
Bot,
|
||||
ArrowUpDown,
|
||||
Monitor,
|
||||
Smartphone
|
||||
} from 'lucide-react'
|
||||
|
||||
const defaultThemeTokens = {
|
||||
@@ -59,13 +62,16 @@ const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Desi
|
||||
[กฎและข้อกำหนดทางเทคนิค]:
|
||||
1. ผลลัพธ์ต้องเป็น Valid JSON Object ล้วนๆ เริ่มต้นด้วย { และจบด้วย } เท่านั้น (ห้ามมี Markdown Backticks หรือคำเกริ่นนำ)
|
||||
2. ค่าสีทั้งหมดต้องอยู่ในรูปแบบ HEX Color Code 6 หลัก (เช่น "#A100FF", "#0A0D14")
|
||||
3. โครงสร้าง JSON ต้องประกอบด้วย:
|
||||
3. โครงสร้าง JSON ต้องประกอบด้วย 5 ส่วนหลัก:
|
||||
- "version": "1.0"
|
||||
- "themeTokens": Object เก็บค่าสีและสไตล์ทั้งหมด
|
||||
- "scrollRevealEnabled": boolean (true/false)
|
||||
- "scrollRevealStyle": "fade-up" | "fade-in" | "slide-left" | "zoom-in" | "none"
|
||||
- "scrollRevealDuration": number (เช่น 600, 700, 800)
|
||||
- "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 แต่ละฟิลด์]:
|
||||
• กลุ่มสีแบรนด์และไฮไลต์ (Brand & Accents):
|
||||
@@ -78,13 +84,13 @@ const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Desi
|
||||
- "brandSoft": สีรองโทนอ่อน / Pastel highlight
|
||||
|
||||
• กลุ่มพื้นผิวและเลเยอร์ (Surfaces & Backgrounds):
|
||||
- "surfacePage": สีพื้นหลังหลักของหน้าเว็บ
|
||||
- "surfacePage": สีพื้นหลังหลักของหน้าเว็บ (เช่น "#0A0D14" สำหรับ Dark Mode หรือ "#F8FAFC" สำหรับ Light Mode)
|
||||
- "surfaceNavRest": สีพื้นหลังของ Header / Navbar
|
||||
- "surfaceCard": สีพื้นหลังของการ์ดสินค้า / Container Block
|
||||
- "surfaceMuted": สีพื้นหลังของ Section สลับลาย / Footer
|
||||
|
||||
• กลุ่มข้อความและการอ่าน (Typography Colors):
|
||||
- "textMain": สีข้อความหัวข้อและเนื้อหาหลัก
|
||||
- "textMain": สีข้อความหัวข้อและเนื้อหาหลัก (ต้อง Contrast ชัดเจนกับ surfacePage)
|
||||
- "mutedText": สีข้อความรอง / รายละเอียดปลีกย่อย
|
||||
- "captionText": สีข้อความกำกับ / ตัวเลขสถิติ / วันที่
|
||||
|
||||
@@ -94,14 +100,16 @@ const LLM_PROMPT_TEXT = `คุณคือ Senior UI/UX Designer และ Desi
|
||||
- "linePrimaryHover": สีเมื่อ Hover ไอคอน LINE
|
||||
|
||||
• กลุ่มมิติและสเปซ (Elevations & Geometry):
|
||||
- "fontBody": ฟอนต์เนื้อหา (ปล่อยว่าง "" หรือใส่ Google Fonts)
|
||||
- "fontDisplay": ฟอนต์หัวข้อ (ปล่อยว่าง "" หรือใส่ฟอนต์ Serif/Display)
|
||||
- "shadowCard": เงาของการ์ดปกติ
|
||||
- "shadowCardHover": เงาเมื่อ Hover การ์ด
|
||||
- "radiusBase": ความโค้งมนของปุ่ม/อินพุต (เช่น "8px")
|
||||
- "radiusCard": ความโค้งมนของการ์ด (เช่น "16px")
|
||||
- "fontBody": ฟอนต์เนื้อหา (ปล่อยว่าง "" เพื่อใช้ค่าเริ่มต้นของระบบ หรือใส่ฟอนต์ Google Fonts)
|
||||
- "fontDisplay": ฟอนต์หัวข้อ (ปล่อยว่าง "" หรือใส่ฟอนต์ Serif/Display เช่น "Playfair Display")
|
||||
- "shadowCard": เงาของการ์ดปกติ (เช่น "0 2px 8px rgba(0,0,0,0.30)")
|
||||
- "shadowCardHover": เงาเมื่อ Hover การ์ด (เช่น "0 8px 24px rgba(0,0,0,0.40)")
|
||||
- "radiusBase": ความโค้งมนของปุ่ม/อินพุต (เช่น "8px", "12px", "9999px")
|
||||
- "radiusCard": ความโค้งมนของการ์ด (เช่น "16px", "20px", "24px")
|
||||
|
||||
[ตัวอย่าง JSON Template]:
|
||||
---
|
||||
|
||||
[ตัวอย่าง JSON Template ที่ถูกต้อง]:
|
||||
{
|
||||
"version": "1.0",
|
||||
"themeTokens": {
|
||||
@@ -155,6 +163,9 @@ export default function ThemeSettingsPage() {
|
||||
const [scrollRevealStyle, setScrollRevealStyle] = useState('fade-up')
|
||||
const [scrollRevealDuration, setScrollRevealDuration] = useState('700')
|
||||
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
|
||||
const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false)
|
||||
@@ -181,6 +192,10 @@ export default function ThemeSettingsPage() {
|
||||
setScrollRevealStyle(data.scrollRevealStyle || 'fade-up')
|
||||
setScrollRevealDuration((data.scrollRevealDuration ?? 700).toString())
|
||||
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)
|
||||
.finally(() => setLoading(false))
|
||||
@@ -196,11 +211,19 @@ export default function ThemeSettingsPage() {
|
||||
return {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
themeTokens,
|
||||
themeTokens: {
|
||||
...themeTokens,
|
||||
headerBehavior,
|
||||
mobileHeaderBehavior,
|
||||
mobileHeaderStyle,
|
||||
},
|
||||
scrollRevealEnabled,
|
||||
scrollRevealStyle,
|
||||
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||
headerStyle,
|
||||
headerBehavior,
|
||||
mobileHeaderStyle,
|
||||
mobileHeaderBehavior,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +295,29 @@ export default function ThemeSettingsPage() {
|
||||
} else if (typeof parsed.headerStyle === 'string') {
|
||||
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 ──────────────────────────────────────────────
|
||||
@@ -392,11 +438,19 @@ export default function ThemeSettingsPage() {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
themeTokens,
|
||||
themeTokens: {
|
||||
...themeTokens,
|
||||
headerBehavior,
|
||||
mobileHeaderBehavior,
|
||||
mobileHeaderStyle,
|
||||
},
|
||||
scrollRevealEnabled,
|
||||
scrollRevealStyle,
|
||||
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||
headerStyle,
|
||||
headerBehavior,
|
||||
mobileHeaderStyle,
|
||||
mobileHeaderBehavior,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
@@ -831,65 +885,252 @@ export default function ThemeSettingsPage() {
|
||||
</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="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">
|
||||
<span className="text-xl">🧭</span>
|
||||
<Monitor className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">PC Header Layout Style</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">เลือกรูปแบบการจัดวางเมนูส่วนหัวหลักสำหรับหน้าจอแนวกว้าง (PC / Desktop View)</p>
|
||||
<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 ≥ 768px)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderStyle('single_line')}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
|
||||
headerStyle === 'single_line'
|
||||
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
|
||||
: '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">Single Line (มาตรฐานแถบเดียว)</span>
|
||||
{headerStyle === 'single_line' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">โลโก้ซ้าย/กลาง + เมนูกลาง + ไอคอนขวา อยู่ในแถบเดียว ประหยัดพื้นที่แนวตั้ง</p>
|
||||
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
|
||||
<div className="font-bold text-white">LOGO</div>
|
||||
<div className="flex gap-2"><span>Shop</span><span>Category</span><span>About</span></div>
|
||||
<div className="flex gap-1"><span>🔍</span><span>🛒</span></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderStyle('two_tier')}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
|
||||
headerStyle === 'two_tier'
|
||||
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-white">Two-Tier Luxury (หรูหรา 2 ชั้น)</span>
|
||||
{headerStyle === 'two_tier' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">แบบแบรนด์เนมชั้นนำ (Chanel Style): โลโก้กลาง + ไอคอนขวา (ชั้น 1) / เมนูกลาง (ชั้น 2)</p>
|
||||
<div className="h-12 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex flex-col justify-between text-[9px] text-slate-400">
|
||||
{/* 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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderStyle('single_line')}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
|
||||
headerStyle === 'single_line'
|
||||
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-4" />
|
||||
<div className="font-bold text-white text-[10px]">BRAND LOGO</div>
|
||||
<span className="text-sm font-bold text-white">Single Line (มาตรฐานแถบเดียว)</span>
|
||||
{headerStyle === 'single_line' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">โลโก้ซ้าย/กลาง + เมนูกลาง + ไอคอนขวา อยู่ในแถบเดียว ประหยัดพื้นที่แนวตั้ง</p>
|
||||
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
|
||||
<div className="font-bold text-white">LOGO</div>
|
||||
<div className="flex gap-2"><span>Shop</span><span>Category</span><span>About</span></div>
|
||||
<div className="flex gap-1"><span>🔍</span><span>🛒</span></div>
|
||||
</div>
|
||||
<div className="flex justify-center gap-3 border-t border-white/5 pt-1 text-[8px]">
|
||||
<span>HAUTE COUTURE</span><span>FASHION</span><span>PERFUME</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderStyle('two_tier')}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
|
||||
headerStyle === 'two_tier'
|
||||
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-white">Two-Tier Luxury (หรูหรา 2 ชั้น)</span>
|
||||
{headerStyle === 'two_tier' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<p className="text-xs text-slate-400">แบบแบรนด์เนมชั้นนำ (Chanel Style): โลโก้กลาง + ไอคอนขวา (ชั้น 1) / เมนูกลาง (ชั้น 2)</p>
|
||||
<div className="h-12 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex flex-col justify-between text-[9px] text-slate-400">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-4" />
|
||||
<div className="font-bold text-white text-[10px]">BRAND LOGO</div>
|
||||
<div className="flex gap-1"><span>🔍</span><span>🛒</span></div>
|
||||
</div>
|
||||
<div className="flex justify-center gap-3 border-t border-white/5 pt-1 text-[8px]">
|
||||
<span>HAUTE COUTURE</span><span>FASHION</span><span>PERFUME</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PC Scroll Behavior */}
|
||||
<div className="space-y-3 pt-4 border-t border-white/5">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400 flex items-center gap-1.5">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>PC Header Scroll Behavior</span>
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderBehavior('hide_on_scroll')}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
|
||||
headerBehavior === 'hide_on_scroll'
|
||||
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-white">Hide on Scroll (Smart Header)</span>
|
||||
{headerBehavior === 'hide_on_scroll' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">เลื่อนลง (Scroll Down) จะซ่อนเมนู / เลื่อนขึ้น (Scroll Up) จะสไลด์กลับมาแสดงทันที</p>
|
||||
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span className="text-rose-400">↓ เลื่อนลง ซ่อน Header</span>
|
||||
<span className="text-emerald-400">↑ เลื่อนขึ้น แสดง Header</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHeaderBehavior('always_sticky')}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
|
||||
headerBehavior === 'always_sticky'
|
||||
? 'border-amber-500 bg-amber-500/10 text-white shadow-lg shadow-amber-500/10'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-white">Always Sticky (ตรึงค้างไว้ตลอด)</span>
|
||||
{headerBehavior === 'always_sticky' && <span className="text-xs font-bold uppercase text-amber-400">Selected</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">เมนูจะเกาะติดขอบจอด้านบนตลอดเวลา ไม่ว่าจะเลื่อนขึ้นหรือลง</p>
|
||||
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span className="text-amber-300 font-bold">📌 FIXED TOP</span>
|
||||
<span className="text-slate-400 text-[9px]">แสดงเมนูค้างไว้ทุกระดับ</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 5. Mobile Header Settings ── */}
|
||||
<div className="admin-card p-6 md:p-8 space-y-6">
|
||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-xl text-emerald-400">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Mobile Header Configuration</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">การตั้งค่าเมนูส่วนหัวหลักสำหรับหน้าจอมือถือ (Mobile View < 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>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EditorialTileSectionPayloadSchema,
|
||||
FeaturedTilesSectionPayloadSchema,
|
||||
EditorialCarouselSectionPayloadSchema,
|
||||
HeroCarouselSectionPayloadSchema,
|
||||
HeroArticleSectionPayloadSchema,
|
||||
RichTextSectionPayloadSchema,
|
||||
ArticleHeaderSectionPayloadSchema,
|
||||
@@ -248,8 +249,13 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
const includeInactive =
|
||||
String((query as any)?.includeInactive || "").toLowerCase() === "true";
|
||||
const lang = (query as any)?.lang || "th";
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { slug },
|
||||
const numId = Number(slug);
|
||||
const isNumericId = Number.isInteger(numId) && numId > 0;
|
||||
|
||||
const page = await prisma.page.findFirst({
|
||||
where: isNumericId
|
||||
? { OR: [{ id: numId }, { slug }] }
|
||||
: { slug },
|
||||
include: {
|
||||
sections: {
|
||||
where: includeInactive ? undefined : { isActive: true },
|
||||
@@ -1112,6 +1118,9 @@ export function validateSectionPayload(
|
||||
case "EDITORIAL_CAROUSEL":
|
||||
schema = EditorialCarouselSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_CAROUSEL":
|
||||
schema = HeroCarouselSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_ARTICLE":
|
||||
schema = HeroArticleSectionPayloadSchema;
|
||||
break;
|
||||
|
||||
@@ -17,7 +17,16 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
const localizedDescription = requestedLang
|
||||
? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription
|
||||
: 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) {
|
||||
console.error("[Settings] Error:", err);
|
||||
set.status = 500;
|
||||
@@ -111,7 +120,22 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
}, { body: t.Any() })
|
||||
.put("/", async ({ body, set }) => {
|
||||
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();
|
||||
if (!settings) {
|
||||
settings = await prisma.storeSettings.create({ data });
|
||||
@@ -121,7 +145,15 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
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) {
|
||||
console.error("[Settings] Error:", err);
|
||||
set.status = 500;
|
||||
@@ -152,6 +184,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
scrollRevealStyle: t.Optional(t.String()),
|
||||
scrollRevealDuration: t.Optional(t.Number()),
|
||||
headerStyle: t.Optional(t.String()),
|
||||
headerBehavior: t.Optional(t.String()),
|
||||
})
|
||||
})
|
||||
.patch("/translations", async ({ body, set }) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
const WEBSITE_PAGE_SECTION_TYPE_LIST = [
|
||||
"HERO",
|
||||
"HERO_CAROUSEL",
|
||||
"ANNOUNCEMENT_BAR",
|
||||
"NEWSLETTER",
|
||||
"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: {
|
||||
type: "EDITORIAL_CAROUSEL",
|
||||
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 DynamicHeroV2 from './DynamicHeroV2';
|
||||
import DynamicHeroVideo from './DynamicHeroVideo';
|
||||
import DynamicHeroCarousel from './DynamicHeroCarousel';
|
||||
import DynamicUspBar from './DynamicUspBar';
|
||||
import DynamicCategories from './DynamicCategories';
|
||||
import DynamicProducts from './DynamicProducts';
|
||||
@@ -36,24 +37,30 @@ export default function DynamicPageRenderer({
|
||||
categories,
|
||||
categoriesLoading
|
||||
}: DynamicPageRendererProps) {
|
||||
useEffect(() => {
|
||||
const HERO_TYPES = new Set([
|
||||
'HERO',
|
||||
'HERO_V2',
|
||||
'HERO_VIDEO',
|
||||
'HERO_ARTICLE',
|
||||
'HERO_MINIMAL',
|
||||
'HERO_SPLIT_MINIMAL',
|
||||
'HERO_VIDEO_MINIMAL',
|
||||
]);
|
||||
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 HERO_TYPES = new Set([
|
||||
'HERO',
|
||||
'HERO_V2',
|
||||
'HERO_VIDEO',
|
||||
'HERO_CAROUSEL',
|
||||
'HERO_ARTICLE',
|
||||
'HERO_MINIMAL',
|
||||
'HERO_SPLIT_MINIMAL',
|
||||
'HERO_VIDEO_MINIMAL',
|
||||
]);
|
||||
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
|
||||
const firstPayload = (firstActiveSection?.payload || {}) as Record<string, unknown>;
|
||||
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') {
|
||||
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false';
|
||||
document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -61,23 +68,29 @@ export default function DynamicPageRenderer({
|
||||
document.documentElement.dataset.heroFirst = 'false';
|
||||
}
|
||||
};
|
||||
}, [sections]);
|
||||
}, [sections, isHeroFirst]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{sections.map((section) => {
|
||||
if (!section.isActive) return null;
|
||||
|
||||
switch (section.type.toUpperCase()) {
|
||||
// ไม่ animate — แสดงตั้งแต่ต้น
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||
const isFirstContent = section.id === firstActiveSection?.id;
|
||||
const needsTopSpacing = isFirstContent && !isHeroFirst;
|
||||
|
||||
const rendered = (() => {
|
||||
switch (section.type.toUpperCase()) {
|
||||
// ไม่ animate — แสดงตั้งแต่ต้น
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||
case 'HERO':
|
||||
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||
case 'HERO_V2':
|
||||
return <DynamicHeroV2 key={section.id} payload={section.payload} />;
|
||||
case 'HERO_VIDEO':
|
||||
return <DynamicHeroVideo key={section.id} payload={section.payload} />;
|
||||
case 'HERO_CAROUSEL':
|
||||
return <DynamicHeroCarousel key={section.id} payload={section.payload} />;
|
||||
case 'USP_BAR':
|
||||
return <DynamicUspBar key={section.id} payload={section.payload} />;
|
||||
|
||||
@@ -148,7 +161,18 @@ export default function DynamicPageRenderer({
|
||||
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
})();
|
||||
|
||||
if (needsTopSpacing && rendered) {
|
||||
return (
|
||||
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+324
-129
@@ -39,6 +39,8 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
|
||||
const { megaMenu, fetchMegaMenu } = useMenuStore()
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const previousScrollY = useRef(0)
|
||||
const [scrollingDown, setScrollingDown] = useState(false)
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'th', label: 'ไทย', short: 'TH' },
|
||||
@@ -66,8 +68,19 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
checkAnnouncement()
|
||||
const timer = setTimeout(checkAnnouncement, 300)
|
||||
|
||||
previousScrollY.current = window.scrollY
|
||||
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()
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
@@ -163,155 +176,337 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
return () => observer.disconnect();
|
||||
}, [location.pathname]);
|
||||
|
||||
const shouldBeTransparent = (isTransparent || isHeroFirst) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
const headerBehavior =
|
||||
(settings?.themeTokens as any)?.headerBehavior ||
|
||||
settings?.headerBehavior ||
|
||||
'hide_on_scroll'
|
||||
const mobileHeaderBehavior =
|
||||
(settings?.themeTokens as any)?.mobileHeaderBehavior ||
|
||||
settings?.mobileHeaderBehavior ||
|
||||
headerBehavior
|
||||
const mobileHeaderStyle =
|
||||
(settings?.themeTokens as any)?.mobileHeaderStyle ||
|
||||
settings?.mobileHeaderStyle ||
|
||||
'logo_center'
|
||||
|
||||
const activeHeaderBehavior = isDesktop ? headerBehavior : mobileHeaderBehavior
|
||||
const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll'
|
||||
const isCheckout = location.pathname.startsWith('/checkout')
|
||||
const hideHeader =
|
||||
!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
|
||||
const shouldBeTransparent = (isHeroFirst || Boolean(isTransparent && isHeroFirst)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
|
||||
const isTwoTier = settings?.headerStyle === 'two_tier'
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{/* Search */}
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Wishlist */}
|
||||
<Link
|
||||
to="/wishlist"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Cart */}
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Language switcher (desktop) */}
|
||||
{isClientReady && isDesktop && (
|
||||
<div className="relative group flex items-center">
|
||||
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
||||
</button>
|
||||
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
||||
{LANGUAGES.map(lang => {
|
||||
const isActive = i18n.language.startsWith(lang.code)
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auth / Profile (desktop) */}
|
||||
{isClientReady && isDesktop && (
|
||||
customer ? (
|
||||
<div className="relative group flex items-center ml-1">
|
||||
<Link to="/profile">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
|
||||
/>
|
||||
</Link>
|
||||
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
|
||||
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
My Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={logoutLiff}
|
||||
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={loginLiff}
|
||||
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{t('nav.login', 'LINE Login')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderMobileActionButtons = (includeSearch = true) => (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{includeSearch && (
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/wishlist"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
|
||||
aria-label="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderMobileHamburger = () => (
|
||||
<button
|
||||
onClick={() => setMobileOpen(prev => !prev)}
|
||||
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
const renderMobileNavbar = () => {
|
||||
if (mobileHeaderStyle === 'logo_left') {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-3 md:hidden">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 object-contain" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{renderMobileActionButtons(true)}
|
||||
{renderMobileHamburger()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mobileHeaderStyle === 'search_bar_below') {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-lg font-bold italic tracking-widest text-pop-black uppercase shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-7 object-contain" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{renderMobileActionButtons(false)}
|
||||
{renderMobileHamburger()}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="ค้นหากลิ่น, หมวดหมู่..."
|
||||
className="w-full rounded-full border border-pop-black/15 bg-pop-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-pop-black placeholder:text-pop-black/40 focus:outline-none focus:ring-1 focus:ring-pop-black"
|
||||
/>
|
||||
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-pop-black/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: 'logo_center'
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between md:hidden">
|
||||
<div className="flex items-center">
|
||||
{renderMobileHamburger()}
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 object-contain mx-auto" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{renderMobileActionButtons(true)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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'
|
||||
} px-4 py-3.5 md:px-6 md:py-4`}
|
||||
} ${isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4'}`}
|
||||
>
|
||||
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */}
|
||||
<div className="w-full flex items-center justify-between gap-4">
|
||||
{/* Mobile Navigation Bar (< md) */}
|
||||
{renderMobileNavbar()}
|
||||
|
||||
{/* 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>
|
||||
{/* 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" />
|
||||
|
||||
{/* Center: Desktop mega menu */}
|
||||
{isClientReady && isDesktop && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<DesktopMegaMenu items={megaMenu} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{/* Search — desktop & mobile */}
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
<Link
|
||||
to="/wishlist"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isClientReady && isDesktop && (
|
||||
<>
|
||||
<div className="relative group flex items-center">
|
||||
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
||||
</button>
|
||||
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
||||
{LANGUAGES.map(lang => {
|
||||
const isActive = i18n.language.startsWith(lang.code)
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customer ? (
|
||||
<div className="relative group flex items-center ml-1">
|
||||
<Link to="/profile">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
|
||||
/>
|
||||
</Link>
|
||||
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
|
||||
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
My Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={logoutLiff}
|
||||
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={loginLiff}
|
||||
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{t('nav.login', 'LINE Login')}
|
||||
</button>
|
||||
<div className="flex-1 flex justify-center">
|
||||
{isClientReady && isDesktop && (
|
||||
<DesktopMegaMenu items={megaMenu} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div className={`transition-all duration-300 ${scrolled ? 'w-8' : 'w-0'}`} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */
|
||||
<div className="hidden md:flex w-full items-center justify-between gap-4">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Desktop mega menu */}
|
||||
{isClientReady && isDesktop && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<DesktopMegaMenu items={megaMenu} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClientReady && !isDesktop && (
|
||||
<button
|
||||
onClick={() => setMobileOpen(prev => !prev)}
|
||||
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Right: Actions */}
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{isClientReady && !isDesktop && mobileOpen && (
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-pop-bg">
|
||||
<Navbar categories={categories} isTransparent={true} />
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{loading ? (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
|
||||
@@ -211,12 +211,27 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
|
||||
}, [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 isCommercePath = ['/shop', '/products', '/category', '/cart', '/checkout', '/wishlist', '/profile', '/order', '/track']
|
||||
.some((route) => pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`))
|
||||
const hideDesktopHeader =
|
||||
(!isCommercePath && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) ||
|
||||
const headerBehavior =
|
||||
(settings?.themeTokens as any)?.headerBehavior ||
|
||||
settings?.headerBehavior ||
|
||||
'hide_on_scroll'
|
||||
const mobileHeaderBehavior =
|
||||
(settings?.themeTokens as any)?.mobileHeaderBehavior ||
|
||||
settings?.mobileHeaderBehavior ||
|
||||
headerBehavior
|
||||
const mobileHeaderStyle =
|
||||
(settings?.themeTokens as any)?.mobileHeaderStyle ||
|
||||
settings?.mobileHeaderStyle ||
|
||||
'logo_center'
|
||||
|
||||
const 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)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -233,6 +248,138 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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 ${
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{/* Mobile Navigation Bar (< md) */}
|
||||
{renderMobileNavbar()}
|
||||
|
||||
{/* Desktop Navigation Bar (>= md) */}
|
||||
{isTwoTier ? (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Top Tier: Logo Centered + Icons Right (Desktop) & Mobile Top Row */}
|
||||
<div className="hidden md:flex w-full flex-col">
|
||||
{/* 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 ${
|
||||
scrolled ? 'max-h-0 opacity-0 overflow-hidden py-0' : 'max-h-24 py-1.5 opacity-100'
|
||||
}`}>
|
||||
<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" />
|
||||
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<Link
|
||||
@@ -422,7 +563,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
|
||||
</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">
|
||||
<Link
|
||||
href="/"
|
||||
@@ -542,16 +683,6 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Image from 'next/image'
|
||||
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 { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { customRegistry } from '../../config/customRegistry'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
|
||||
import ScrollReveal from '../ScrollReveal'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/pagination'
|
||||
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 }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
@@ -1888,32 +2012,36 @@ function ContactFormSection({ payload, lang }: { payload: Record<string, unknown
|
||||
)
|
||||
}
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import ScrollReveal from '../ScrollReveal'
|
||||
|
||||
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
|
||||
const i18nLang = useLang()
|
||||
// Use i18n.language reactively — switches immediately when user changes language
|
||||
const lang = langProp ?? i18nLang
|
||||
|
||||
useEffect(() => {
|
||||
const HERO_TYPES = new Set([
|
||||
'HERO',
|
||||
'HERO_V2',
|
||||
'HERO_VIDEO',
|
||||
'HERO_ARTICLE',
|
||||
'HERO_MINIMAL',
|
||||
'HERO_SPLIT_MINIMAL',
|
||||
'HERO_VIDEO_MINIMAL',
|
||||
])
|
||||
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 HERO_TYPES = useMemo(() => new Set([
|
||||
'HERO',
|
||||
'HERO_V2',
|
||||
'HERO_VIDEO',
|
||||
'HERO_ARTICLE',
|
||||
'HERO_MINIMAL',
|
||||
'HERO_SPLIT_MINIMAL',
|
||||
'HERO_VIDEO_MINIMAL',
|
||||
'HERO_CAROUSEL',
|
||||
]), [])
|
||||
|
||||
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') {
|
||||
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false'
|
||||
document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false'
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -1921,7 +2049,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
document.documentElement.dataset.heroFirst = 'false'
|
||||
}
|
||||
}
|
||||
}, [sections])
|
||||
}, [sections, isHeroFirst])
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@@ -1930,10 +2058,13 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
|
||||
const payload = toRecord(section.payload)
|
||||
const type = section.type.toUpperCase()
|
||||
const isFirstContent = section.id === firstActiveSection?.id
|
||||
const needsTopSpacing = isFirstContent && !isHeroFirst
|
||||
|
||||
switch (type) {
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
|
||||
const rendered = (() => {
|
||||
switch (type) {
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
|
||||
case 'HERO':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
@@ -1948,6 +2079,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
)
|
||||
case 'HERO_VIDEO':
|
||||
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':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
@@ -2124,7 +2261,18 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
}
|
||||
return null
|
||||
}
|
||||
})}
|
||||
})()
|
||||
|
||||
if (needsTopSpacing && rendered) {
|
||||
return (
|
||||
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
|
||||
{rendered}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return rendered
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user