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

No files matched your search

+3
View File
@@ -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>
);
}
+56 -4
View File
@@ -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}
+665
View File
@@ -0,0 +1,665 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Layers,
ArrowUp,
ArrowDown,
ChevronsUp,
ChevronsDown,
Eye,
EyeOff,
Save,
Loader2,
CheckCircle2,
GripVertical,
ExternalLink,
RotateCcw,
Edit3
} from 'lucide-react';
import { API_BASE, type Page, type PageSection } from '@homaroma/types';
import { getAdminDisplayString } from '../utils/localization';
export default function PageReorderPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [pages, setPages] = useState<Page[]>([]);
const [currentPage, setCurrentPage] = useState<Page | null>(null);
const [sections, setSections] = useState<PageSection[]>([]);
const [initialSections, setInitialSections] = useState<PageSection[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [saving, setSaving] = useState<boolean>(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [filterType, setFilterType] = useState<'all' | 'active' | 'hidden'>('all');
const showToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 3000);
};
// Fetch all pages for switcher
const fetchPagesList = async () => {
try {
const res = await fetch(`${API_BASE}/pages`);
const data = await res.json();
if (Array.isArray(data)) {
setPages(data);
}
} catch (err) {
console.error('Fetch pages error:', err);
}
};
// Fetch specific page and its sections
const fetchPageDetails = async (pageIdOrSlug: string) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/pages/${pageIdOrSlug}?includeInactive=true`);
const data = await res.json();
if (data && !data.error) {
setCurrentPage(data);
const sortedSections = (data.sections || []).sort((a: PageSection, b: PageSection) => a.order - b.order);
setSections(sortedSections);
setInitialSections(sortedSections);
setHasUnsavedChanges(false);
} else {
alert(t('pageBuilder.errFetchPage', 'Page not found'));
navigate('/page-builder');
}
} catch (err) {
console.error('Fetch page error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPagesList();
}, []);
useEffect(() => {
if (id) {
fetchPageDetails(id);
}
}, [id]);
// Reordering functions
const handleMove = (index: number, direction: 'up' | 'down' | 'top' | 'bottom') => {
const list = [...sections];
if (direction === 'up' && index > 0) {
const temp = list[index];
list[index] = list[index - 1];
list[index - 1] = temp;
} else if (direction === 'down' && index < list.length - 1) {
const temp = list[index];
list[index] = list[index + 1];
list[index + 1] = temp;
} else if (direction === 'top' && index > 0) {
const [item] = list.splice(index, 1);
list.unshift(item);
} else if (direction === 'bottom' && index < list.length - 1) {
const [item] = list.splice(index, 1);
list.push(item);
}
setSections(list);
setHasUnsavedChanges(true);
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const list = [...sections];
const [draggedItem] = list.splice(draggedIndex, 1);
list.splice(index, 0, draggedItem);
setDraggedIndex(index);
setSections(list);
setHasUnsavedChanges(true);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
// Toggle active state
const handleToggleActive = async (section: PageSection) => {
const updatedActive = !section.isActive;
// Update local state immediately
setSections((prev) =>
prev.map((s) => (s.id === section.id ? { ...s, isActive: updatedActive } : s))
);
setHasUnsavedChanges(true);
try {
await fetch(`${API_BASE}/pages/sections/${section.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: updatedActive }),
});
showToast(updatedActive ? 'Section is now visible' : 'Section is now hidden');
} catch (err) {
console.error('Toggle active error:', err);
}
};
// Save new layout order
const handleSaveOrder = async () => {
if (!currentPage) return;
setSaving(true);
try {
const sectionOrders = sections.map((sec, idx) => ({
id: sec.id,
order: idx + 1,
}));
const res = await fetch(`${API_BASE}/pages/${currentPage.id}/sections/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sectionOrders }),
});
if (res.ok) {
setInitialSections([...sections]);
setHasUnsavedChanges(false);
showToast('Layout order saved successfully!');
} else {
alert('Failed to save layout order');
}
} catch (err) {
console.error('Save order error:', err);
alert('Failed to save layout order');
} finally {
setSaving(false);
}
};
// Reset to initial
const handleReset = () => {
setSections([...initialSections]);
setHasUnsavedChanges(false);
showToast('Reset to original order');
};
// Block Category Color Helper
const getBlockCategoryBadge = (type: string) => {
const upper = type.toUpperCase();
if (upper.startsWith('HERO') || upper === 'BANNER' || upper === 'BANNER_MINIMAL') {
return {
bg: 'bg-cyan-500/10 border-cyan-500/30 text-cyan-300',
label: 'Hero / Banner',
glow: 'border-l-cyan-400',
};
}
if (upper === 'PRODUCTS' || upper === 'PRODUCT_CAROUSEL' || upper === 'CATEGORIES' || upper.startsWith('PRODUCT_')) {
return {
bg: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
label: 'Shop & Catalog',
glow: 'border-l-amber-400',
};
}
if (upper.startsWith('EDITORIAL') || upper.startsWith('ARTICLE') || upper === 'RICH_TEXT' || upper === 'TEXT') {
return {
bg: 'bg-purple-500/10 border-purple-500/30 text-purple-300',
label: 'Editorial & Story',
glow: 'border-l-purple-400',
};
}
if (upper.startsWith('ANNOUNCEMENT') || upper.startsWith('USP')) {
return {
bg: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
label: 'Top Bar / Highlights',
glow: 'border-l-emerald-400',
};
}
return {
bg: 'bg-slate-500/10 border-slate-500/30 text-slate-300',
label: 'Content Block',
glow: 'border-l-slate-400',
};
};
// Block Preview Details Helper
const renderBlockPreviewSnippet = (section: PageSection) => {
const payload = section.payload || {};
const type = section.type.toUpperCase();
if (type === 'HERO_CAROUSEL') {
const slides = Array.isArray(payload.slides) ? payload.slides : [];
return (
<div className="flex items-center gap-3 mt-2">
{slides[0]?.imageUrl && (
<img
src={slides[0].imageUrl}
alt="slide"
className="w-20 h-8 object-cover rounded-lg border border-white/10"
/>
)}
<span className="text-[11px] text-slate-400">
{slides.length} Slides · Aspect {payload.aspectRatio || '21:7'} · Autoplay {payload.autoplay !== false ? 'ON' : 'OFF'}
</span>
</div>
);
}
if (type === 'HERO' || type === 'BANNER' || type === 'HERO_VIDEO') {
const img = payload.imageUrl || payload.posterImageUrl;
return (
<div className="flex items-center gap-3 mt-2">
{img && (
<img
src={img}
alt="hero"
className="w-16 h-8 object-cover rounded-lg border border-white/10"
/>
)}
<span className="text-[11px] text-slate-400">
{getAdminDisplayString(payload.description) || payload.buttonLink || 'Single visual cover'}
</span>
</div>
);
}
if (type === 'CATEGORIES' || type === 'PRODUCTS' || type === 'PRODUCT_CAROUSEL') {
return (
<div className="flex items-center gap-2 mt-2 text-[11px] text-slate-400">
<span className="px-2 py-0.5 rounded bg-white/5 border border-white/10 font-mono">
{payload.type || 'Custom Query'}
</span>
<span>{getAdminDisplayString(payload.subtitle) || `Limit: ${payload.limit || 'All'}`}</span>
</div>
);
}
return null;
};
const filteredSections = sections.filter((s) => {
if (filterType === 'active') return s.isActive;
if (filterType === 'hidden') return !s.isActive;
return true;
});
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Loader2 className="w-10 h-10 text-cyan-glow animate-spin" />
<p className="text-sm font-semibold text-slate-400">Loading Page Layout Structure...</p>
</div>
);
}
return (
<div className="space-y-8 animate-fade-in pb-24 text-slate-100 max-w-7xl mx-auto">
{/* Toast Alert Notification */}
{toastMessage && (
<div className="fixed top-6 right-6 z-50 flex items-center gap-2.5 px-5 py-3 rounded-2xl bg-night-900 border border-cyan-glow/40 shadow-2xl text-cyan-glow text-sm font-bold animate-fade-in backdrop-blur-md">
<CheckCircle2 className="w-5 h-5" />
<span>{toastMessage}</span>
</div>
)}
{/* Top Header & Navigation Bar */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b border-white/10 pb-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<Link
to="/page-builder"
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-all"
title="Back to Page Builder"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-2.5">
<h1 className="text-3xl font-bold text-white">Reorder Page Layout</h1>
<span className="px-3 py-1 rounded-full text-xs font-mono font-bold bg-purple-500/10 text-purple-300 border border-purple-500/30">
Full-Screen Canvas
</span>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-400 pl-11 flex-wrap">
<span>Page:</span>
{pages.length > 0 ? (
<select
value={currentPage?.id || ''}
onChange={(e) => {
const target = pages.find((p) => p.id === Number(e.target.value));
if (target) {
navigate(`/page-builder/${target.id}/reorder`);
}
}}
className="bg-slate-900 border border-white/10 text-cyan-glow font-bold rounded-xl px-3 py-1 text-xs focus:outline-none focus:border-cyan-glow cursor-pointer"
>
{pages.map((p) => (
<option key={p.id} value={p.id}>
{p.title} (/{p.slug})
</option>
))}
</select>
) : (
<strong className="text-white">{currentPage?.title} (/{currentPage?.slug})</strong>
)}
<div className="w-px h-3 bg-white/10 mx-1"></div>
<span className="text-xs text-slate-400 font-mono">
Total <strong>{sections.length}</strong> sections
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 pl-11 md:pl-0 flex-wrap">
{hasUnsavedChanges && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset</span>
</button>
)}
{currentPage && (
<a
href={`/${currentPage.slug}`}
target="_blank"
rel="noreferrer"
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-semibold flex items-center gap-1.5 transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Live Page</span>
</a>
)}
<button
type="button"
onClick={handleSaveOrder}
disabled={saving || !hasUnsavedChanges}
className={`px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center gap-2 transition-all shadow-lg cursor-pointer ${
hasUnsavedChanges
? 'bg-cyan-glow text-night-950 hover:bg-cyan-glow/90 shadow-cyan-glow/20 scale-105'
: 'bg-white/10 text-slate-400 border border-white/5 cursor-not-allowed opacity-60'
}`}
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin text-night-950" />
) : (
<Save className="w-4 h-4" />
)}
<span>{saving ? 'Saving Order...' : hasUnsavedChanges ? 'Save Layout Order *' : 'Layout Saved'}</span>
</button>
</div>
</div>
{/* Main Grid: Sortable Canvas + Sticky Wireframe Minimap */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Left / Center: Interactive Drag & Sort Canvas (8 Cols) */}
<div className="lg:col-span-8 space-y-4">
{/* Filter Tabs & Quick Info Bar */}
<div className="flex items-center justify-between p-4 rounded-2xl bg-slate-900/60 border border-white/5">
<div className="flex items-center gap-1 bg-black/40 p-1 rounded-xl border border-white/5">
{(['all', 'active', 'hidden'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setFilterType(mode)}
className={`px-3 py-1 rounded-lg text-xs font-bold uppercase tracking-wider transition-all cursor-pointer ${
filterType === mode
? 'bg-cyan-glow text-night-950 shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
>
{mode === 'all' && `All Blocks (${sections.length})`}
{mode === 'active' && `Active (${sections.filter((s) => s.isActive).length})`}
{mode === 'hidden' && `Hidden (${sections.filter((s) => !s.isActive).length})`}
</button>
))}
</div>
<p className="text-[11px] text-slate-400 hidden sm:block">
💡 Drag cards or use arrow buttons to arrange vertical hierarchy.
</p>
</div>
{/* Sortable List */}
{filteredSections.length === 0 ? (
<div className="p-16 text-center rounded-3xl border border-dashed border-white/10 bg-slate-900/30 space-y-3">
<Layers className="w-10 h-10 text-slate-600 mx-auto" />
<p className="text-sm text-slate-400">No sections found for this filter.</p>
</div>
) : (
<div className="space-y-3">
{filteredSections.map((section, idx) => {
const categoryBadge = getBlockCategoryBadge(section.type);
const titleText = getAdminDisplayString(
section.payload?.title
|| section.payload?.tagline
|| section.payload?.heading
|| section.payload?.quote
|| (section.type?.toUpperCase() === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)
) || `Untitled ${section.type}`;
const isFirst = idx === 0;
const isLast = idx === filteredSections.length - 1;
return (
<div
key={section.id}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
className={`p-4 rounded-2xl bg-night-900 border transition-all duration-200 group flex items-center justify-between gap-4 select-none ${
categoryBadge.glow
} border-l-4 ${
draggedIndex === idx
? 'opacity-40 scale-[0.98] border-cyan-glow bg-cyan-glow/5'
: section.isActive
? 'border-white/10 hover:border-white/20 bg-white/[0.02]'
: 'border-white/5 opacity-60 bg-black/40'
}`}
>
{/* Left: Drag Handle, Numbering & Details */}
<div className="flex items-center gap-4 min-w-0 flex-1">
{/* Drag Handle Icon */}
<div className="cursor-grab active:cursor-grabbing p-1.5 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-white/5 transition-colors shrink-0">
<GripVertical className="w-5 h-5" />
</div>
{/* Step Number Badge */}
<div className="w-9 h-9 rounded-xl bg-slate-800 border border-white/10 text-white font-mono font-bold flex items-center justify-center text-sm shadow-inner shrink-0">
#{idx + 1}
</div>
{/* Block Information */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider border font-mono ${categoryBadge.bg}`}>
{section.type}
</span>
<span className="text-[10px] text-slate-500 font-medium hidden sm:inline-block">
({categoryBadge.label})
</span>
{!section.isActive && (
<span className="px-2 py-0.5 rounded bg-rose-500/10 text-rose-400 border border-rose-500/20 text-[10px] font-bold uppercase tracking-wider">
Hidden
</span>
)}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-cyan-glow transition-colors">
{titleText}
</h3>
{/* Extra Payload Snippet */}
{renderBlockPreviewSnippet(section)}
</div>
</div>
{/* Right: Quick Controls */}
<div className="flex items-center gap-1.5 shrink-0 pl-2">
{/* Quick Move Buttons */}
<div className="flex items-center gap-1 bg-black/40 p-1 rounded-xl border border-white/5">
<button
type="button"
onClick={() => handleMove(idx, 'top')}
disabled={isFirst}
title="Move to Top"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ChevronsUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMove(idx, 'up')}
disabled={isFirst}
title="Move Up"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMove(idx, 'down')}
disabled={isLast}
title="Move Down"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMove(idx, 'bottom')}
disabled={isLast}
title="Move to Bottom"
className="p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white disabled:opacity-20 disabled:hover:bg-transparent transition-all cursor-pointer"
>
<ChevronsDown className="w-4 h-4" />
</button>
</div>
{/* Visibility Toggle */}
<button
type="button"
onClick={() => handleToggleActive(section)}
className={`p-2 rounded-xl border transition-all cursor-pointer ${
section.isActive
? 'bg-cyan-glow/10 border-cyan-glow/30 text-cyan-glow hover:bg-cyan-glow/20'
: 'bg-white/5 border-white/5 text-slate-500 hover:text-white'
}`}
title={section.isActive ? 'Hide Section' : 'Show Section'}
>
{section.isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
{/* Jump to Edit Block Shortcut */}
<Link
to={`/page-builder`}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/5 transition-all"
title="Edit block in Page Builder"
>
<Edit3 className="w-4 h-4" />
</Link>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right Sidebar: Sticky Page Wireframe Flow & Overview (4 Cols) */}
<div className="lg:col-span-4 sticky top-6 space-y-6">
{/* Overview Statistics Card */}
<div className="p-6 rounded-3xl bg-slate-900/70 border border-white/10 space-y-4 shadow-xl">
<div className="flex items-center gap-2.5 text-xs font-bold text-slate-200 uppercase tracking-wider">
<Layers className="w-4 h-4 text-cyan-glow" />
<span>Page Layout Summary</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-2xl bg-night-950/60 border border-white/5 space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Total Blocks</span>
<p className="text-2xl font-bold font-mono text-white">{sections.length}</p>
</div>
<div className="p-3.5 rounded-2xl bg-night-950/60 border border-white/5 space-y-1">
<span className="text-[10px] uppercase font-bold text-emerald-400 tracking-wider">Active</span>
<p className="text-2xl font-bold font-mono text-emerald-400">
{sections.filter((s) => s.isActive).length}
</p>
</div>
</div>
{hasUnsavedChanges && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs flex items-center gap-2.5">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>You have unsaved order changes. Click "Save Layout Order" above.</span>
</div>
)}
</div>
{/* Visual Wireframe Flow Stack */}
<div className="p-6 rounded-3xl bg-slate-900/70 border border-white/10 space-y-4 shadow-xl">
<div className="flex items-center justify-between text-xs font-bold text-slate-200 uppercase tracking-wider">
<span>Wireframe Sequence</span>
<span className="text-[10px] text-slate-500 font-mono">Top → Bottom</span>
</div>
<div className="space-y-1.5 max-h-[420px] overflow-y-auto pr-1">
{sections.map((sec, idx) => {
const badge = getBlockCategoryBadge(sec.type);
return (
<div
key={sec.id}
className={`px-3 py-2 rounded-xl text-xs flex items-center justify-between border-l-2 transition-all ${
sec.isActive
? `bg-night-950/80 border-y-white/5 border-r-white/5 ${badge.glow} text-slate-200`
: 'bg-black/40 border-dashed border-white/5 text-slate-600'
}`}
>
<div className="flex items-center gap-2 truncate">
<span className="text-[10px] font-mono text-slate-500 font-bold w-4">
#{idx + 1}
</span>
<span className="font-bold text-xs truncate">{sec.type}</span>
</div>
{!sec.isActive && (
<span className="text-[9px] uppercase font-bold text-slate-600 shrink-0">
Hidden
</span>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
+301 -60
View File
@@ -14,7 +14,10 @@ import {
AlertCircle,
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 &lt; 768px)</p>
</div>
</div>
{/* Mobile Layout Style */}
<div className="space-y-3">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Mobile Header Layout Style</label>
<div className="grid gap-4 md:grid-cols-3">
<button
type="button"
onClick={() => setMobileHeaderStyle('logo_center')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderStyle === 'logo_center'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Centered Logo (สไตล์ Luxury)</span>
{mobileHeaderStyle === 'logo_center' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">ซ้ายเป็นปุ่มเมนู (☰) / โลโก้อยู่กึ่งกลางสมมาตร / ขวาเป็นไอคอนค้นหาและตะกร้า</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
<div className="text-white text-xs">☰</div>
<div className="font-bold text-white tracking-widest text-[9px]">LOGO</div>
<div className="flex gap-1.5 text-[9px]"><span>🔍</span><span>🛍️</span></div>
</div>
</button>
<button
type="button"
onClick={() => setMobileHeaderStyle('logo_left')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderStyle === 'logo_left'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Left Logo (สไตล์ Commercial)</span>
{mobileHeaderStyle === 'logo_left' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">โลโก้อยู่ชิดซ้าย (พื้นที่แนวนอนยาว) / รวมไอคอนค้นหา ตะกร้า และเมนู (☰) ไว้ชิดขวา</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[9px] text-slate-400">
<div className="font-bold text-white tracking-widest text-[9px]">BRAND LOGO</div>
<div className="flex items-center gap-1.5 text-[9px]"><span>🔍</span><span>🛍️</span><span className="text-white text-xs ml-0.5">☰</span></div>
</div>
</button>
<button
type="button"
onClick={() => setMobileHeaderStyle('search_bar_below')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderStyle === 'search_bar_below'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Two-Row (พร้อมแถบค้นหา)</span>
{mobileHeaderStyle === 'search_bar_below' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">แถวบนโลโก้ + ตะกร้า + เมนู / แถวล่างมีช่อง Search Bar ค้นหาสินค้าได้ทันที</p>
<div className="h-12 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex flex-col justify-between text-[9px] text-slate-400">
<div className="flex items-center justify-between">
<div className="font-bold text-white text-[9px]">LOGO</div>
<div className="flex items-center gap-1.5 text-[9px]"><span>🛍️</span><span className="text-white text-xs ml-0.5">☰</span></div>
</div>
<div className="h-3.5 w-full rounded bg-white/10 flex items-center px-1.5 text-[8px] text-slate-400 gap-1">
<span>🔍</span><span>ค้นหาสินค้า...</span>
</div>
</div>
</button>
</div>
</div>
{/* Mobile Scroll Behavior */}
<div className="space-y-3 pt-4 border-t border-white/5">
<label className="text-xs uppercase font-bold tracking-widest text-slate-400 flex items-center gap-1.5">
<ArrowUpDown className="w-3.5 h-3.5 text-emerald-400" />
<span>Mobile Header Scroll Behavior</span>
</label>
<div className="grid gap-4 md:grid-cols-2">
<button
type="button"
onClick={() => setMobileHeaderBehavior('hide_on_scroll')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderBehavior === 'hide_on_scroll'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Hide on Scroll (Smart Header - แนะนำสำหรับมือถือ)</span>
{mobileHeaderBehavior === 'hide_on_scroll' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">เลื่อนลง (Scroll Down) จะซ่อนเมนูเพื่อประหยัดพื้นที่หน้าจอ / เลื่อนขึ้น (Scroll Up) จะสไลด์กลับมาแสดงทันที</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
<span className="text-rose-400">↓ เลื่อนลง ซ่อน Header</span>
<span className="text-emerald-400">↑ เลื่อนขึ้น แสดง Header</span>
</div>
</button>
<button
type="button"
onClick={() => setMobileHeaderBehavior('always_sticky')}
className={`flex flex-col gap-3 rounded-2xl border p-5 text-left transition cursor-pointer ${
mobileHeaderBehavior === 'always_sticky'
? 'border-emerald-500 bg-emerald-500/10 text-white shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-white">Always Sticky (ตรึงค้างไว้ตลอด)</span>
{mobileHeaderBehavior === 'always_sticky' && <span className="text-xs font-bold uppercase text-emerald-400">Selected</span>}
</div>
<p className="text-xs text-slate-400">เมนู Header จะเกาะติดขอบจอด้านบนตลอดเวลา ไม่ว่าจะเลื่อนขึ้นหรือลง</p>
<div className="h-10 w-full rounded-lg border border-white/10 bg-slate-900/80 p-2 flex items-center justify-between text-[10px] text-slate-400">
<span className="text-emerald-300 font-bold">📌 FIXED TOP</span>
<span className="text-slate-400 text-[9px]">แสดงเมนูค้างไว้ทุกระดับ</span>
</div>
</button>
</div>
</div>
</div>