From 5e724fae20729b82fb1366f6db46f4c23fcdcc3d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 07:28:27 +0700 Subject: [PATCH] x --- LLM_PROMPT_THEME_GENERATOR.md | 8 +- apps/admin/src/App.tsx | 3 + .../page-builder/AddSectionModal.tsx | 32 + .../page-builder/HeroCarouselForm.tsx | 368 ++++++++++ apps/admin/src/pages/PageBuilderPage.tsx | 60 +- apps/admin/src/pages/PageReorderPage.tsx | 665 ++++++++++++++++++ apps/admin/src/pages/ThemeSettingsPage.tsx | 361 ++++++++-- apps/backend/src/routes/pages.ts | 13 +- apps/backend/src/routes/settings.ts | 39 +- apps/mcp-server/section-toolkit.ts | 33 + .../src/components/DynamicHeroCarousel.tsx | 157 +++++ .../src/components/DynamicPageRenderer.tsx | 68 +- apps/spa/src/components/Navbar.tsx | 453 ++++++++---- apps/spa/src/pages/HomePage.tsx | 2 +- apps/website/src/components/Navbar.tsx | 191 ++++- .../dynamic/DynamicPageRenderer.tsx | 198 +++++- packages/types/index.ts | 31 +- 17 files changed, 2404 insertions(+), 278 deletions(-) create mode 100644 apps/admin/src/components/page-builder/HeroCarouselForm.tsx create mode 100644 apps/admin/src/pages/PageReorderPage.tsx create mode 100644 apps/spa/src/components/DynamicHeroCarousel.tsx diff --git a/LLM_PROMPT_THEME_GENERATOR.md b/LLM_PROMPT_THEME_GENERATOR.md index 6bb9462..e67f482 100644 --- a/LLM_PROMPT_THEME_GENERATOR.md +++ b/LLM_PROMPT_THEME_GENERATOR.md @@ -22,6 +22,9 @@ - "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): @@ -130,7 +133,10 @@ "scrollRevealEnabled": true, "scrollRevealStyle": "fade-up", "scrollRevealDuration": 700, - "headerStyle": "single_line" + "headerStyle": "single_line", + "headerBehavior": "hide_on_scroll", + "mobileHeaderStyle": "logo_center", + "mobileHeaderBehavior": "hide_on_scroll" } ``` diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index f762645..cd3a1b3 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/page-builder/AddSectionModal.tsx b/apps/admin/src/components/page-builder/AddSectionModal.tsx index 154a41a..28e09c7 100644 --- a/apps/admin/src/components/page-builder/AddSectionModal.tsx +++ b/apps/admin/src/components/page-builder/AddSectionModal.tsx @@ -111,6 +111,38 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS ) }, + // 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: () => ( +
+ promo banner slider +
+ +
‹
+ +
+ MEGA PROMO DEALS +
SUMMER SALE UP TO [70% OFF]
+
+ + + +
+
+ +
›
+
+ ) + }, // 3. ANNOUNCEMENT_BAR { type: 'ANNOUNCEMENT_BAR', diff --git a/apps/admin/src/components/page-builder/HeroCarouselForm.tsx b/apps/admin/src/components/page-builder/HeroCarouselForm.tsx new file mode 100644 index 0000000..be1dbe5 --- /dev/null +++ b/apps/admin/src/components/page-builder/HeroCarouselForm.tsx @@ -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; + setEditingSection: React.Dispatch | 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 ( +
+ {/* Global Slider Settings */} +
+
+ + Carousel Dimension & Display Settings +
+ +
+
+ + +

BEAUTRIUM style uses 21:7 or 21:6 for wide banners without eating vertical screen space.

+
+ +
+ + +
+ +
+ +
+ + + +
+
+
+ +
+ + + + + + +
+
Speed (ms)
+ handleUpdatePayload('interval', parseInt(e.target.value) || 4500)} + className="admin-input w-full py-1 text-xs" + /> +
+
+
+ + {/* Slides Section */} +
+
+
+ +

Add high-resolution wide banner images and destination links.

+
+ +
+ + {slides.length === 0 ? ( +
+ +

No banner slides added yet.

+ +
+ ) : ( +
+ {slides.map((slide: any, idx: number) => ( +
+
+
+ + {idx + 1} + + Slide #{idx + 1} +
+ +
+ + {/* Desktop Image Field & Preview */} +
+ +
+ updateSlidePayloadValue(idx, 'imageUrl', e.target.value)} + placeholder="https://... or /images/..." + className="admin-input flex-1 text-xs" + /> + +
+
+ + {/* Banner Preview Thumbnail */} + {slide.imageUrl && ( +
+ {slide.alt { + (e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=400&q=80'; + }} + /> +
+ Preview (21:7) +
+
+ )} + + {/* Mobile Image (Optional) & Link URL */} +
+
+ + updateSlidePayloadValue(idx, 'mobileImageUrl', e.target.value)} + placeholder="Leave empty to use desktop image" + className="admin-input w-full text-xs" + /> +
+ +
+ + updateSlidePayloadValue(idx, 'linkUrl', e.target.value)} + placeholder="/shop or /categories/1" + className="admin-input w-full text-xs" + /> +
+
+ + {/* Accessibility Alt & Optional Overlay Text */} +
+
+ + updateLocalizedSlideValue(idx, 'alt', e.target.value)} + placeholder="e.g. Wet & Wow Promotion" + className="admin-input w-full text-xs" + /> +
+ +
+ + updateLocalizedSlideValue(idx, 'title', e.target.value)} + placeholder="e.g. Mega Sale [60% OFF]" + className="admin-input w-full text-xs" + /> +
+ +
+ + updateLocalizedSlideValue(idx, 'subtitle', e.target.value)} + placeholder="e.g. SPECIAL EDITION" + className="admin-input w-full text-xs" + /> +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx index 049154f..c8ddd47 100644 --- a/apps/admin/src/pages/PageBuilderPage.tsx +++ b/apps/admin/src/pages/PageBuilderPage.tsx @@ -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() {
+ )} + + {currentPage && ( + + + Live Page + + )} + + +
+ + + {/* Main Grid: Sortable Canvas + Sticky Wireframe Minimap */} +
+ + {/* Left / Center: Interactive Drag & Sort Canvas (8 Cols) */} +
+ + {/* Filter Tabs & Quick Info Bar */} +
+
+ {(['all', 'active', 'hidden'] as const).map((mode) => ( + + ))} +
+ +

+ 💡 Drag cards or use arrow buttons to arrange vertical hierarchy. +

+
+ + {/* Sortable List */} + {filteredSections.length === 0 ? ( +
+ +

No sections found for this filter.

+
+ ) : ( +
+ {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 ( +
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 */} +
+ + {/* Drag Handle Icon */} +
+ +
+ + {/* Step Number Badge */} +
+ #{idx + 1} +
+ + {/* Block Information */} +
+
+ + {section.type} + + + + ({categoryBadge.label}) + + + {!section.isActive && ( + + Hidden + + )} +
+ +

+ {titleText} +

+ + {/* Extra Payload Snippet */} + {renderBlockPreviewSnippet(section)} +
+
+ + {/* Right: Quick Controls */} +
+ + {/* Quick Move Buttons */} +
+ + + + +
+ + {/* Visibility Toggle */} + + + {/* Jump to Edit Block Shortcut */} + + + +
+
+ ); + })} +
+ )} +
+ + {/* Right Sidebar: Sticky Page Wireframe Flow & Overview (4 Cols) */} +
+ + {/* Overview Statistics Card */} +
+
+ + Page Layout Summary +
+ +
+
+ Total Blocks +

{sections.length}

+
+
+ Active +

+ {sections.filter((s) => s.isActive).length} +

+
+
+ + {hasUnsavedChanges && ( +
+ + You have unsaved order changes. Click "Save Layout Order" above. +
+ )} +
+ + {/* Visual Wireframe Flow Stack */} +
+
+ Wireframe Sequence + Top → Bottom +
+ +
+ {sections.map((sec, idx) => { + const badge = getBlockCategoryBadge(sec.type); + return ( +
+
+ + #{idx + 1} + + {sec.type} +
+ + {!sec.isActive && ( + + Hidden + + )} +
+ ); + })} +
+
+
+ +
+ + ); +} diff --git a/apps/admin/src/pages/ThemeSettingsPage.tsx b/apps/admin/src/pages/ThemeSettingsPage.tsx index 16c1b28..b001e3f 100644 --- a/apps/admin/src/pages/ThemeSettingsPage.tsx +++ b/apps/admin/src/pages/ThemeSettingsPage.tsx @@ -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() { - {/* ── 4. Header Layout Style ── */} + {/* ── 4. Desktop (PC) Header Settings ── */}
- 🧭 +
-

PC Header Layout Style

-

เลือกรูปแบบการจัดวางเมนูส่วนหัวหลักสำหรับหน้าจอแนวกว้าง (PC / Desktop View)

+

Desktop (PC) Header Configuration

+

การตั้งค่าเมนูส่วนหัวหลักสำหรับหน้าจอขนาดใหญ่ (PC / Desktop View ≥ 768px)

-
- - - + +
- +

แบบแบรนด์เนมชั้นนำ (Chanel Style): โลโก้กลาง + ไอคอนขวา (ชั้น 1) / เมนูกลาง (ชั้น 2)

+
+
+
+
BRAND LOGO
+
🔍🛒
+
+
+ HAUTE COUTUREFASHIONPERFUME +
+
+ +
+
+ + {/* PC Scroll Behavior */} +
+ +
+ + + +
+
+ + + {/* ── 5. Mobile Header Settings ── */} +
+
+
+ +
+
+

Mobile Header Configuration

+

การตั้งค่าเมนูส่วนหัวหลักสำหรับหน้าจอมือถือ (Mobile View < 768px)

+
+
+ + {/* Mobile Layout Style */} +
+ +
+ + + + + +
+
+ + {/* Mobile Scroll Behavior */} +
+ +
+ + + +
diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 3751442..e793764 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -12,6 +12,7 @@ import { EditorialTileSectionPayloadSchema, FeaturedTilesSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, + HeroCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ArticleHeaderSectionPayloadSchema, @@ -248,8 +249,13 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) const includeInactive = String((query as any)?.includeInactive || "").toLowerCase() === "true"; const lang = (query as any)?.lang || "th"; - const page = await prisma.page.findUnique({ - where: { slug }, + const numId = Number(slug); + const isNumericId = Number.isInteger(numId) && numId > 0; + + const page = await prisma.page.findFirst({ + where: isNumericId + ? { OR: [{ id: numId }, { slug }] } + : { slug }, include: { sections: { where: includeInactive ? undefined : { isActive: true }, @@ -1112,6 +1118,9 @@ export function validateSectionPayload( case "EDITORIAL_CAROUSEL": schema = EditorialCarouselSectionPayloadSchema; break; + case "HERO_CAROUSEL": + schema = HeroCarouselSectionPayloadSchema; + break; case "HERO_ARTICLE": schema = HeroArticleSectionPayloadSchema; break; diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 0d3c5c0..9dc6414 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -17,7 +17,16 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) const localizedDescription = requestedLang ? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription : undefined; - return localizedDescription ? { ...settingsData, footerDescription: localizedDescription } : settingsData; + const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll'; + const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior; + const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center'; + return { + ...settingsData, + headerBehavior, + mobileHeaderBehavior, + mobileHeaderStyle, + footerDescription: localizedDescription ?? settingsData.footerDescription, + }; } catch (err) { console.error("[Settings] Error:", err); set.status = 500; @@ -111,7 +120,22 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) }, { body: t.Any() }) .put("/", async ({ body, set }) => { try { - const data = body as any; + const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, ...data } = body as any; + const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null) + ? data.themeTokens + : {}; + const updatedTokens = { ...currentTokens }; + if (headerBehavior !== undefined) { + updatedTokens.headerBehavior = headerBehavior; + } + if (mobileHeaderBehavior !== undefined) { + updatedTokens.mobileHeaderBehavior = mobileHeaderBehavior; + } + if (mobileHeaderStyle !== undefined) { + updatedTokens.mobileHeaderStyle = mobileHeaderStyle; + } + data.themeTokens = updatedTokens; + let settings = await prisma.storeSettings.findFirst(); if (!settings) { settings = await prisma.storeSettings.create({ data }); @@ -121,7 +145,15 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) data }); } - return settings; + const returnedBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll'; + const returnedMobileBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior; + const returnedMobileStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center'; + return { + ...settings, + headerBehavior: returnedBehavior, + mobileHeaderBehavior: returnedMobileBehavior, + mobileHeaderStyle: returnedMobileStyle, + }; } catch (err) { console.error("[Settings] Error:", err); set.status = 500; @@ -152,6 +184,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) scrollRevealStyle: t.Optional(t.String()), scrollRevealDuration: t.Optional(t.Number()), headerStyle: t.Optional(t.String()), + headerBehavior: t.Optional(t.String()), }) }) .patch("/translations", async ({ body, set }) => { diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index 691e51c..5005f12 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -8,6 +8,7 @@ import { const WEBSITE_PAGE_SECTION_TYPE_LIST = [ "HERO", + "HERO_CAROUSEL", "ANNOUNCEMENT_BAR", "NEWSLETTER", "EDITORIAL_TILE", @@ -227,6 +228,38 @@ const PAGE_BLOCK_METADATA: Record = { ] } }, + HERO_CAROUSEL: { + type: "HERO_CAROUSEL", + requiredFields: ["slides[].imageUrl"], + optionalFields: ["autoplay", "interval", "aspectRatio", "mobileAspectRatio", "showArrows", "showDots", "backgroundColor", "slides[].mobileImageUrl", "slides[].linkUrl", "slides[].alt", "slides[].title", "slides[].subtitle", "slides[].buttonText", "slides[].focalPoint"], + renderedFields: ["autoplay", "interval", "aspectRatio", "mobileAspectRatio", "showArrows", "showDots", "backgroundColor", "slides"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Full-wide edge-to-edge promotional carousel. Default aspect ratio is '21/7' for a sleek Beautrium-style wide ribbon."], + minimalPayload: { + slides: [ + { imageUrl: imageUrlExample, linkUrl: "/shop", alt: localizedObjectExample } + ] + }, + recommendedPayload: { + autoplay: true, + interval: 4500, + aspectRatio: "21/7", + mobileAspectRatio: "16/9", + showArrows: true, + showDots: true, + slides: [ + { + imageUrl: imageUrlExample, + linkUrl: "/shop", + alt: { th: "โปรโมชั่นพิเศษ 1", en: "Special Deal 1" }, + title: { th: "โปรแรงลดสูงสุด [70%]", en: "Mega Sale Up To [70% OFF]" }, + subtitle: { th: "BEAUTY DEALS", en: "BEAUTY DEALS" }, + buttonText: { th: "ช้อปเลย", en: "Shop Now" } + } + ] + } + }, EDITORIAL_CAROUSEL: { type: "EDITORIAL_CAROUSEL", requiredFields: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"], diff --git a/apps/spa/src/components/DynamicHeroCarousel.tsx b/apps/spa/src/components/DynamicHeroCarousel.tsx new file mode 100644 index 0000000..9cfd69a --- /dev/null +++ b/apps/spa/src/components/DynamicHeroCarousel.tsx @@ -0,0 +1,157 @@ +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Autoplay, Pagination, Navigation } from 'swiper/modules'; +import { Link } from 'react-router-dom'; +import type { HeroCarouselSectionPayload } from '@homaroma/types'; +import { useTranslation } from 'react-i18next'; +import { getLocalizedField } from '../utils/localization'; + +// Import Swiper styles +import 'swiper/css'; +import 'swiper/css/pagination'; +import 'swiper/css/navigation'; + +interface DynamicHeroCarouselProps { + payload: HeroCarouselSectionPayload; +} + +export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProps) { + const { i18n } = useTranslation(); + const lang = i18n.language; + const slides = (payload.slides || []).map((slide) => ({ + ...slide, + title: getLocalizedField(slide.title, lang), + subtitle: getLocalizedField(slide.subtitle, lang), + buttonText: getLocalizedField(slide.buttonText, lang), + alt: getLocalizedField(slide.alt, lang), + })); + + if (slides.length === 0) return null; + + const autoplay = payload.autoplay !== false; + const interval = payload.interval || 4500; + const showArrows = payload.showArrows !== false; + const showDots = payload.showDots !== false; + const aspectRatio = payload.aspectRatio || '21/7'; + const mobileAspectRatio = payload.mobileAspectRatio || '16/9'; + + const renderTitle = (text: string) => { + if (!text) return null; + return text.split('[').map((part, i) => { + if (part.includes(']')) { + const [accent, rest] = part.split(']'); + return ( + + + {accent} + + {rest} + + ); + } + return {part}; + }); + }; + + const getAspectClass = () => { + switch (aspectRatio) { + case '21/6': return 'md:aspect-[21/6]'; + case '21/7': return 'md:aspect-[21/7]'; + case '21/9': return 'md:aspect-[21/9]'; + case '16/5': return 'md:aspect-[16/5]'; + case '16/6': return 'md:aspect-[16/6]'; + case '4/1': return 'md:aspect-[4/1]'; + case '5/1': return 'md:aspect-[5/1]'; + default: return 'md:aspect-[21/7]'; + } + }; + + const getMobileAspectClass = () => { + switch (mobileAspectRatio) { + case '16/9': return 'aspect-[16/9]'; + case '4/3': return 'aspect-[4/3]'; + case '1/1': return 'aspect-[1/1]'; + case '21/9': return 'aspect-[21/9]'; + default: return 'aspect-[16/9]'; + } + }; + + return ( +
+ 1} + autoplay={autoplay ? { delay: interval, disableOnInteraction: false } : false} + pagination={showDots ? { clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-pink' } : false} + navigation={showArrows} + grabCursor={true} + className="w-full select-none [&_.swiper-button-next]:text-white [&_.swiper-button-prev]:text-white [&_.swiper-button-next]:after:text-sm md:[&_.swiper-button-next]:after:text-base [&_.swiper-button-prev]:after:text-sm md:[&_.swiper-button-prev]:after:text-base [&_.swiper-button-next]:w-8 md:[&_.swiper-button-next]:w-10 [&_.swiper-button-next]:h-8 md:[&_.swiper-button-next]:h-10 [&_.swiper-button-prev]:w-8 md:[&_.swiper-button-prev]:w-10 [&_.swiper-button-prev]:h-8 md:[&_.swiper-button-prev]:h-10 [&_.swiper-button-next]:bg-black/35 [&_.swiper-button-prev]:bg-black/35 [&_.swiper-button-next]:backdrop-blur-sm [&_.swiper-button-prev]:backdrop-blur-sm [&_.swiper-button-next]:rounded-full [&_.swiper-button-prev]:rounded-full [&_.swiper-button-next]:hover:bg-black/60 [&_.swiper-button-prev]:hover:bg-black/60 [&_.swiper-pagination-bullet]:bg-white/60 [&_.swiper-pagination-bullet-active]:!bg-pop-pink [&_.swiper-pagination]:!bottom-2.5 md:[&_.swiper-pagination]:!bottom-4" + > + {slides.map((slide, index) => { + const imageUrl = slide.imageUrl || ''; + const mobileImageUrl = slide.mobileImageUrl || imageUrl; + const linkUrl = slide.linkUrl || ''; + const altText = slide.alt || slide.title || `Slide ${index + 1}`; + const focalPoint = typeof slide.focalPoint === 'object' && slide.focalPoint ? slide.focalPoint : undefined; + + if (!imageUrl) return null; + + const slideContent = ( +
+ {/* Desktop Image */} + {altText} + + {/* Mobile Image */} + {altText} + + {/* Text Overlay */} + {(slide.title || slide.subtitle || slide.buttonText) && ( +
+
+ {slide.subtitle && ( +

+ {slide.subtitle} +

+ )} + {slide.title && ( +

+ {renderTitle(slide.title)} +

+ )} + {slide.buttonText && ( + + {slide.buttonText} + + )} +
+
+ )} +
+ ); + + return ( + + {linkUrl ? ( + + {slideContent} + + ) : ( + slideContent + )} + + ); + })} +
+
+ ); +} diff --git a/apps/spa/src/components/DynamicPageRenderer.tsx b/apps/spa/src/components/DynamicPageRenderer.tsx index 47efb3a..cda8607 100644 --- a/apps/spa/src/components/DynamicPageRenderer.tsx +++ b/apps/spa/src/components/DynamicPageRenderer.tsx @@ -2,6 +2,7 @@ import type { PageSection, Category, AccordionSectionPayload, ContactInfoSection import DynamicHero from './DynamicHero'; import DynamicHeroV2 from './DynamicHeroV2'; import DynamicHeroVideo from './DynamicHeroVideo'; +import DynamicHeroCarousel from './DynamicHeroCarousel'; import DynamicUspBar from './DynamicUspBar'; import DynamicCategories from './DynamicCategories'; import DynamicProducts from './DynamicProducts'; @@ -36,24 +37,30 @@ export default function DynamicPageRenderer({ categories, categoriesLoading }: DynamicPageRendererProps) { - useEffect(() => { - const HERO_TYPES = new Set([ - 'HERO', - 'HERO_V2', - 'HERO_VIDEO', - 'HERO_ARTICLE', - 'HERO_MINIMAL', - 'HERO_SPLIT_MINIMAL', - 'HERO_VIDEO_MINIMAL', - ]); - const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR'); - if (!firstActiveSection && sections.length > 0 && sections.every(s => !s.isActive || s.type.toUpperCase() === 'ANNOUNCEMENT_BAR')) { - return; - } - const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase())); + const HERO_TYPES = new Set([ + 'HERO', + 'HERO_V2', + 'HERO_VIDEO', + 'HERO_CAROUSEL', + 'HERO_ARTICLE', + 'HERO_MINIMAL', + 'HERO_SPLIT_MINIMAL', + 'HERO_VIDEO_MINIMAL', + ]); + const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR'); + const firstPayload = (firstActiveSection?.payload || {}) as Record; + const defaultSpacing = firstActiveSection?.type.toUpperCase() === 'HERO_CAROUSEL' ? 'below_header' : 'under_header'; + const firstHeaderSpacing = typeof firstPayload.headerSpacing === 'string' ? firstPayload.headerSpacing : defaultSpacing; + const isHeroFirst = Boolean( + firstActiveSection && + HERO_TYPES.has(firstActiveSection.type.toUpperCase()) && + firstHeaderSpacing === 'under_header' + ); + + useEffect(() => { if (typeof document !== 'undefined') { - document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false'; + document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false'; } return () => { @@ -61,23 +68,29 @@ export default function DynamicPageRenderer({ document.documentElement.dataset.heroFirst = 'false'; } }; - }, [sections]); + }, [sections, isHeroFirst]); return (
{sections.map((section) => { if (!section.isActive) return null; - switch (section.type.toUpperCase()) { - // ไม่ animate — แสดงตั้งแต่ต้น - case 'ANNOUNCEMENT_BAR': - return ; + const isFirstContent = section.id === firstActiveSection?.id; + const needsTopSpacing = isFirstContent && !isHeroFirst; + + const rendered = (() => { + switch (section.type.toUpperCase()) { + // ไม่ animate — แสดงตั้งแต่ต้น + case 'ANNOUNCEMENT_BAR': + return ; case 'HERO': return ; case 'HERO_V2': return ; case 'HERO_VIDEO': return ; + case 'HERO_CAROUSEL': + return ; case 'USP_BAR': return ; @@ -148,7 +161,18 @@ export default function DynamicPageRenderer({ console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`); return null; } - })} + })(); + + if (needsTopSpacing && rendered) { + return ( +
+ {rendered} +
+ ); + } + + return rendered; + })}
); } diff --git a/apps/spa/src/components/Navbar.tsx b/apps/spa/src/components/Navbar.tsx index 16d1b94..cc8dbfd 100644 --- a/apps/spa/src/components/Navbar.tsx +++ b/apps/spa/src/components/Navbar.tsx @@ -39,6 +39,8 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f const { megaMenu, fetchMegaMenu } = useMenuStore() const navRef = useRef(null) + const previousScrollY = useRef(0) + const [scrollingDown, setScrollingDown] = useState(false) const LANGUAGES = [ { code: 'th', label: 'ไทย', short: 'TH' }, @@ -66,8 +68,19 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f checkAnnouncement() const timer = setTimeout(checkAnnouncement, 300) + previousScrollY.current = window.scrollY const onScroll = () => { - setScrolled(window.scrollY > 20) + const currentScrollY = window.scrollY + const delta = currentScrollY - previousScrollY.current + setScrolled(currentScrollY > 20) + if (currentScrollY <= 20) { + setScrollingDown(false) + } else if (delta > 4) { + setScrollingDown(true) + } else if (delta < -4) { + setScrollingDown(false) + } + previousScrollY.current = currentScrollY checkAnnouncement() } window.addEventListener('scroll', onScroll, { passive: true }) @@ -163,155 +176,337 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f return () => observer.disconnect(); }, [location.pathname]); - const shouldBeTransparent = (isTransparent || isHeroFirst) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen + const headerBehavior = + (settings?.themeTokens as any)?.headerBehavior || + settings?.headerBehavior || + 'hide_on_scroll' + const mobileHeaderBehavior = + (settings?.themeTokens as any)?.mobileHeaderBehavior || + settings?.mobileHeaderBehavior || + headerBehavior + const mobileHeaderStyle = + (settings?.themeTokens as any)?.mobileHeaderStyle || + settings?.mobileHeaderStyle || + 'logo_center' + + const activeHeaderBehavior = isDesktop ? headerBehavior : mobileHeaderBehavior + const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll' + const isCheckout = location.pathname.startsWith('/checkout') + const hideHeader = + !isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen + + const shouldBeTransparent = (isHeroFirst || Boolean(isTransparent && isHeroFirst)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen + + const isTwoTier = settings?.headerStyle === 'two_tier' + + const renderActionButtons = () => ( +
+ {/* Search */} + + + {/* Wishlist */} + setMobileOpen(false)} + className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2" + aria-label="Wishlist" + > + + {wishlistCount > 0 && ( + + {wishlistCount} + + )} + + + {/* Cart */} + + + {/* Language switcher (desktop) */} + {isClientReady && isDesktop && ( +
+ +
+
+ {LANGUAGES.map(lang => { + const isActive = i18n.language.startsWith(lang.code) + return ( + + ) + })} +
+
+
+ )} + + {/* Auth / Profile (desktop) */} + {isClientReady && isDesktop && ( + customer ? ( +
+ + Profile + +
+
+
+

{t('nav.signedInAs', 'Signed in as')}

+

{customer.displayName}

+
+ + My Profile + + +
+
+
+ ) : ( + + ) + )} +
+ ) + + const renderMobileActionButtons = (includeSearch = true) => ( +
+ {includeSearch && ( + + )} + + setMobileOpen(false)} + className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5" + aria-label="Wishlist" + > + + {wishlistCount > 0 && ( + + {wishlistCount} + + )} + + + +
+ ) + + const renderMobileHamburger = () => ( + + ) + + const renderMobileNavbar = () => { + if (mobileHeaderStyle === 'logo_left') { + return ( +
+
+ setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0"> + {logoUrl ? {brandName} : brandName} + +
+
+ {renderMobileActionButtons(true)} + {renderMobileHamburger()} +
+
+ ) + } + + if (mobileHeaderStyle === 'search_bar_below') { + return ( +
+
+
+ setMobileOpen(false)} className="text-lg font-bold italic tracking-widest text-pop-black uppercase shrink-0"> + {logoUrl ? {brandName} : brandName} + +
+
+ {renderMobileActionButtons(false)} + {renderMobileHamburger()} +
+
+
+ setSearchQuery(e.target.value)} + placeholder="ค้นหากลิ่น, หมวดหมู่..." + className="w-full rounded-full border border-pop-black/15 bg-pop-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-pop-black placeholder:text-pop-black/40 focus:outline-none focus:ring-1 focus:ring-pop-black" + /> + + + +
+
+ ) + } + + // Default: 'logo_center' + return ( +
+
+ {renderMobileHamburger()} +
+
+ setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0"> + {logoUrl ? {brandName} : brandName} + +
+
+ {renderMobileActionButtons(true)} +
+
+ ) + } return ( <> {isClientReady && !isDesktop && mobileOpen && ( diff --git a/apps/spa/src/pages/HomePage.tsx b/apps/spa/src/pages/HomePage.tsx index e0c4cc9..cda7e5e 100644 --- a/apps/spa/src/pages/HomePage.tsx +++ b/apps/spa/src/pages/HomePage.tsx @@ -45,7 +45,7 @@ export default function HomePage() { return (
- + {loading ? (
diff --git a/apps/website/src/components/Navbar.tsx b/apps/website/src/components/Navbar.tsx index db0fc28..6644c2d 100644 --- a/apps/website/src/components/Navbar.tsx +++ b/apps/website/src/components/Navbar.tsx @@ -211,12 +211,27 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n }, [pathname]) const isHome = getPathWithoutLocale(pathname) === '/' - const isTransparent = (isHome || isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen + const isTransparent = (isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen const pathWithoutLocale = getPathWithoutLocale(pathname) - const isCommercePath = ['/shop', '/products', '/category', '/cart', '/checkout', '/wishlist', '/profile', '/order', '/track'] - .some((route) => pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`)) - const hideDesktopHeader = - (!isCommercePath && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) || + const headerBehavior = + (settings?.themeTokens as any)?.headerBehavior || + settings?.headerBehavior || + 'hide_on_scroll' + const mobileHeaderBehavior = + (settings?.themeTokens as any)?.mobileHeaderBehavior || + settings?.mobileHeaderBehavior || + headerBehavior + const mobileHeaderStyle = + (settings?.themeTokens as any)?.mobileHeaderStyle || + settings?.mobileHeaderStyle || + 'logo_center' + + const isDesktopView = isClientReady && canRenderDesktopMenu + const activeHeaderBehavior = isDesktopView ? headerBehavior : mobileHeaderBehavior + const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll' + const isCheckout = pathWithoutLocale === '/checkout' || pathWithoutLocale.startsWith('/checkout/') + const hideHeader = + (!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) || (productStickyVisible && !mobileOpen && !searchOpen && !langSheetOpen) useEffect(() => { @@ -233,6 +248,138 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n const isTwoTier = settings?.headerStyle === 'two_tier' + const renderMobileActionButtons = (includeSearch = true) => ( +
+ {includeSearch && ( + + )} + + setMobileOpen(false)} + className={`relative ${iconButtonClass}`} + aria-label="Wishlist" + > + + {wishlistCount > 0 && ( + + {wishlistCount} + + )} + + + +
+ ) + + const renderMobileHamburger = () => ( + + ) + + const renderMobileNavbar = () => { + if (mobileHeaderStyle === 'logo_left') { + return ( +
+
+ setMobileOpen(false)} className="ui-brand-wordmark"> + {settings?.logoUrl ? ( + {brandName} + ) : ( + {brandName} + )} + +
+
+ {renderMobileActionButtons(true)} + {renderMobileHamburger()} +
+
+ ) + } + + if (mobileHeaderStyle === 'search_bar_below') { + return ( +
+
+
+ setMobileOpen(false)} className="ui-brand-wordmark"> + {settings?.logoUrl ? ( + {brandName} + ) : ( + {brandName} + )} + +
+
+ {renderMobileActionButtons(false)} + {renderMobileHamburger()} +
+
+
+ setSearchQuery(e.target.value)} + placeholder={t('nav.searchPlaceholder', 'ค้นหาสินค้า กลิ่นหอม หรือหมวดหมู่...')} + className="w-full rounded-full border border-black/10 bg-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-text-main placeholder:text-muted-text/60 focus:outline-none focus:ring-1 focus:ring-brand-primary dark:border-white/10 dark:bg-white/[0.06]" + /> + + +
+ ) + } + + // Default: 'logo_center' + return ( +
+
+ {renderMobileHamburger()} +
+
+ setMobileOpen(false)} className="ui-brand-wordmark text-center"> + {settings?.logoUrl ? ( + {brandName} + ) : ( + {brandName} + )} + +
+
+ {renderMobileActionButtons(true)} +
+
+ ) + } + return ( <>