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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user