This commit is contained in:
Your Name committed 2026-10-18 08:01:36 +07:00
1 parent dd79519bf7
commit 765cdd1fa9
11 files changed
+972 -36

No files matched your search

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react'; import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
if (!isOpen) return null; if (!isOpen) return null;
return ( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative"> <div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
{/* Header */} {/* Header */}
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
)} )}
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { createPortal } from 'react-dom'
import { import {
Sparkles, Sparkles,
Copy, Copy,
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
### 1. Root Items (เมนูแถวบนสุด - Level 1) ### 1. Root Items (เมนูแถวบนสุด - Level 1)
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\` - **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\` - **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800) - **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2) - **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู - **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
} }
} }
return ( return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in"> <div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in"> <div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
{/* Header */} {/* Header */}
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
document.body
) )
} }
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { createPortal } from 'react-dom'
import { import {
Sparkles, Sparkles,
Copy, Copy,
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
} }
} }
return ( return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in"> <div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in"> <div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
</div> </div>
</div> </div>
</div> </div>,
document.body
) )
} }
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
X, X,
@@ -852,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
return matchesCategory && matchesSearch; return matchesCategory && matchesSearch;
}); });
return ( return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200"> <div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
{/* Modal Header */} {/* Modal Header */}
@@ -978,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
)} )}
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Loader2 } from 'lucide-react'; import { X, Search, Loader2 } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { getAdminDisplayString } from '../../utils/localization'; import { getAdminDisplayString } from '../../utils/localization';
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
onClose(); onClose();
}; };
return ( return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div> <div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]"> <div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
+4 -4
View File
@@ -116,18 +116,18 @@
/* Animations */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: none; }
} }
@keyframes zoomIn { @keyframes zoomIn {
from { opacity: 0; transform: scale(0.95); } from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: none; }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out;
} }
.animate-zoom-in { .animate-zoom-in {
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { API_BASE, type Menu } from '@homaroma/types' import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react' import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
import { ICON_REGISTRY } from '../../utils/iconRegistry' import { ICON_REGISTRY } from '../../utils/iconRegistry'
import FocalPointPicker from '../../components/page-builder/FocalPointPicker' import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal' import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
import MediaLibraryModal from '../../components/MediaLibraryModal'
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => { const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
return ( return (
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
description: string; description: string;
} }
const FeaturedCardEditorFields = ({ featured, onChange }: any) => { const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content'); const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
const [activeLang, setActiveLang] = useState('th'); const [activeLang, setActiveLang] = useState('th');
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
['ctaLabel', 'CTA label', 'View details'], ['ctaLabel', 'CTA label', 'View details'],
]; ];
const isEnabled = featured.enabled !== false;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div>
<div className="text-xs font-bold text-white">Enable Featured Card</div>
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
</div>
<button
type="button"
onClick={() => updateFeatured('enabled', !isEnabled)}
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
isEnabled
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
</button>
</div>
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1"> <div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
{[ {[
['content', 'Content'], ['content', 'Content'],
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
</div> </div>
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4"> <div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center"> <div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{featured.imageUrl ? ( {featured.imageUrl ? (
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" /> <>
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
<ImageIcon className="w-6 h-6 text-cyan-glow" />
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
</div>
</>
) : ( ) : (
<span className="px-4 text-center text-xs text-slate-600">No featured image</span> <div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
<ImageIcon className="w-8 h-8" />
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
</div>
)} )}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label> <label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
{onOpenMediaPicker && (
<button
type="button"
onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input <input
className="admin-input w-full text-xs py-1 px-2" className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''} value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp" placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)} onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/> />
{featured.imageUrl && (
<button
type="button"
onClick={() => updateFeatured('imageUrl', null)}
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
</div> </div>
{featured.imageUrl && ( {featured.imageUrl && (
<div className="pt-2"> <div className="pt-2">
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
); );
} }
const FeaturedCardSidePanel = ({ node, onChange }: any) => { const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
if (!node) { if (!node) {
return ( return (
<div className="admin-card p-6 space-y-3"> <div className="admin-card p-6 space-y-3">
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
<FeaturedCardEditorFields <FeaturedCardEditorFields
featured={featured} featured={featured}
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })} onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
onOpenMediaPicker={onOpenMediaPicker}
/> />
</div> </div>
); );
} }
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => { const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
const childrenKey = type === 'root' ? 'columns' : 'links'; const childrenKey = type === 'root' ? 'columns' : 'links';
const labelKey = type === 'column' ? 'title' : 'label'; const labelKey = type === 'column' ? 'title' : 'label';
const hasUrl = type !== 'column'; const hasUrl = type !== 'column';
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
{/* Image URL field */} {/* Image URL field */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Preview thumbnail */} {/* Preview thumbnail */}
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center"> <div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{imageValue ? ( {imageValue ? (
<>
<img src={imageValue} alt="preview" className="w-full h-full object-cover" /> <img src={imageValue} alt="preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
</div>
</>
) : ( ) : (
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span> <div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
<ImageIcon className="w-4 h-4 mb-0.5" />
<span className="text-[8px] text-center leading-tight">Library</span>
</div>
)} )}
</div> </div>
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label> <label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
{onOpenMediaPicker && (
<button
type="button"
onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input <input
className="admin-input w-full text-xs py-1 px-2" className="admin-input w-full text-xs py-1 px-2"
value={imageValue} value={imageValue}
placeholder="https://cdn.example.com/image.webp" placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })} onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/> />
{imageValue && (
<button
type="button"
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
{type !== 'root' && ( {type !== 'root' && (
<IconSelect <IconSelect
value={node.icon} value={node.icon}
@@ -325,6 +420,167 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div> </div>
{type === 'root' && ( {type === 'root' && (
<div className="space-y-3">
{/* Layout Mode Selector */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
Sub-Menu Layout Preset
</label>
<span className="text-[10px] text-cyan-glow font-medium">
{node.layout === 'classic'
? 'Classic Dropdown'
: node.layout === 'tabbed'
? 'Tabbed Hierarchy'
: node.layout === 'visual-cards'
? 'Visual Cards'
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<button
type="button"
onClick={() => onChange({ ...node, layout: 'columns' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
(!node.layout || node.layout === 'columns')
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'tabbed' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'tabbed'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'visual-cards'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'classic' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'classic'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
</div>
</button>
</div>
{/* Submenu Layout Modifier / Description */}
{(!node.layout || node.layout === 'columns') && (
<div className="pt-3 border-t border-white/5 space-y-3">
{/* Column Count */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
{[2, 3, 4].map((count) => {
const currentCols = node.layoutConfig?.columnCount || 2;
return (
<button
key={count}
type="button"
onClick={() => onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
})}
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
currentCols === count
? 'bg-cyan-glow text-night-900'
: 'text-slate-400 hover:text-white'
}`}
>
{count} Cols
</button>
);
})}
</div>
</div>
{/* Toggle Featured Card */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
<button
type="button"
onClick={() => {
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
const nextState = !isCurrentlyShown;
onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
featured: { ...featured, enabled: nextState }
});
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
</button>
</div>
</div>
)}
{node.layout === 'tabbed' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
</div>
)}
{node.layout === 'visual-cards' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
</div>
)}
{node.layout === 'classic' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
</div>
)}
</div>
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4"> <div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@@ -334,7 +590,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3> <h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500"> <p className="truncate text-[11px] text-slate-500">
{featured.enabled === false ? 'Disabled' : featuredTitle} {featuredTitle}
</p> </p>
</div> </div>
</div> </div>
@@ -352,6 +608,8 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div> </div>
)} )}
</div> </div>
)}
</div>
{hasUrl && ( {hasUrl && (
<div className="w-48 space-y-1"> <div className="w-48 space-y-1">
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
depth={depth + 1} depth={depth + 1}
onChange={(newChild: any) => handleUpdateChild(i, newChild)} onChange={(newChild: any) => handleUpdateChild(i, newChild)}
onRemove={() => handleRemoveChild(i)} onRemove={() => handleRemoveChild(i)}
onOpenMediaPicker={onOpenMediaPicker}
/> />
))} ))}
</div> </div>
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
const [pastedJsonText, setPastedJsonText] = useState('') const [pastedJsonText, setPastedJsonText] = useState('')
const [pasteError, setPasteError] = useState<string | null>(null) const [pasteError, setPasteError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
setMediaSelectCallback(() => onSelectUrl)
setIsMediaModalOpen(true)
}
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
}} }}
onEditFeatured={() => setSelectedFeaturedIndex(idx)} onEditFeatured={() => setSelectedFeaturedIndex(idx)}
isFeaturedSelected={safeFeaturedIndex === idx} isFeaturedSelected={safeFeaturedIndex === idx}
onOpenMediaPicker={handleOpenMediaPicker}
/> />
)) ))
)} )}
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
<FeaturedCardSidePanel <FeaturedCardSidePanel
node={selectedFeaturedNode} node={selectedFeaturedNode}
onChange={handleUpdateFeaturedNode} onChange={handleUpdateFeaturedNode}
onOpenMediaPicker={handleOpenMediaPicker}
/> />
) : ( ) : (
<div className="admin-card p-6 space-y-4"> <div className="admin-card p-6 space-y-4">
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
)} )}
{/* Paste JSON Modal */} {/* Paste JSON Modal */}
{showPasteModal && ( {showPasteModal && createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in"> <div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in"> <div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
<div className="flex items-center justify-between border-b border-white/10 pb-3"> <div className="flex items-center justify-between border-b border-white/10 pb-3">
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
</button> </button>
</div> </div>
</div> </div>
</div> </div>,
document.body
)} )}
{/* LLM Mega Menu Guide Modal */} {/* LLM Mega Menu Guide Modal */}
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
onOpenPasteJson={() => setShowPasteModal(true)} onOpenPasteJson={() => setShowPasteModal(true)}
onOpenImportFile={() => fileInputRef.current?.click()} onOpenImportFile={() => fileInputRef.current?.click()}
/> />
{/* Media Library Modal */}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => {
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
onSelect={(url) => {
if (mediaSelectCallback) {
mediaSelectCallback(url)
}
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
mediaType="image"
/>
</div> </div>
) )
} }
+314 -5
View File
@@ -60,6 +60,7 @@ function MenuImage({ src, alt, placeholder, className, style }: { src?: string |
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) { export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null); const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null);
const [activeTabByMenu, setActiveTabByMenu] = useState<Record<string, number>>({});
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const location = useLocation(); const location = useLocation();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
@@ -110,10 +111,17 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const isOpen = openId === uniqueId; const isOpen = openId === uniqueId;
const hasColumns = item.columns && item.columns.length > 0; const hasColumns = item.columns && item.columns.length > 0;
const visibleColumns = item.columns?.slice(0, 2) ?? []; const layout = item.layout || 'columns';
const isClassic = layout === 'classic';
const isTabbed = layout === 'tabbed';
const isVisualCards = layout === 'visual-cards';
const configuredCols = item.layoutConfig?.columnCount || (item.columns?.length ? Math.min(Math.max(item.columns.length, 2), 4) : 2);
const visibleColumns = item.columns?.slice(0, configuredCols) ?? [];
const featuredLink = getFirstImageLink(item.columns); const featuredLink = getFirstImageLink(item.columns);
const featuredConfig = item.featured; const featuredConfig = item.featured;
const showFeatured = featuredConfig?.enabled !== false; const showFeatured = (item.layoutConfig?.showFeatured !== false) && (featuredConfig?.enabled !== false) && !isClassic && !isVisualCards && !isTabbed && (
!!(featuredConfig?.imageUrl || featuredConfig?.title || featuredConfig?.sectionTitle || featuredLink?.imageUrl)
);
const configuredImage = featuredConfig?.imageUrl || null; const configuredImage = featuredConfig?.imageUrl || null;
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl; const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl;
const focalPoint = featuredConfig?.focalPoint; const focalPoint = featuredConfig?.focalPoint;
@@ -129,6 +137,28 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'; const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#';
const featuredCta = getLocalizedText(featuredConfig?.ctaLabel, lang) || 'View details'; const featuredCta = getLocalizedText(featuredConfig?.ctaLabel, lang) || 'View details';
const getMegaContainerClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'w-[min(100vw-2rem,1260px)]';
if (cols === 3) return 'w-[min(100vw-2rem,1120px)]';
return 'w-[min(100vw-2rem,980px)]';
}
if (cols >= 4) return 'w-[min(100vw-2rem,1180px)]';
if (cols === 3) return 'w-[min(100vw-2rem,960px)]';
return 'w-[min(100vw-2rem,720px)]';
};
const getMegaGridColsClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'grid-cols-[1fr_1fr_1fr_1fr_260px]';
if (cols === 3) return 'grid-cols-[1fr_1fr_1fr_270px]';
return 'grid-cols-[1fr_1fr_270px]';
}
if (cols >= 4) return 'grid-cols-4';
if (cols === 3) return 'grid-cols-3';
return 'grid-cols-2';
};
return ( return (
<div <div
key={uniqueId} key={uniqueId}
@@ -159,9 +189,288 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
)} )}
</Link> </Link>
{hasColumns && ( {hasColumns && isClassic && (
<div <div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${ className={`absolute left-0 top-full z-50 pt-2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="min-w-[230px] max-w-xs cursor-default overflow-hidden rounded-xl border border-pop-black/10 bg-pop-bg/95 p-2 shadow-2xl backdrop-blur-xl">
{item.columns?.map((col, colIdx) => (
<div key={colIdx} className={colIdx > 0 ? 'mt-2 border-t border-pop-black/10 pt-2' : ''}>
{getLocalizedText(col.title, lang) && item.columns && item.columns.length > 1 && (
<h5 className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-pop-black/50">
{getLocalizedText(col.title, lang)}
</h5>
)}
<ul className="space-y-0.5">
{col.links.map((link, linkIdx) => (
<li key={linkIdx}>
<Link
to={link.url || '#'}
onClick={handleLinkClick}
className="group flex items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold normal-case tracking-normal text-pop-black transition-colors hover:bg-pop-card hover:text-pop-black"
>
<span className="flex items-center gap-2.5">
{link.icon && (
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-4 w-4 shrink-0 text-pop-black/70 transition-colors group-hover:text-pop-black"
strokeWidth={1.5}
/>
)}
<span>{getLocalizedText(link.label, lang)}</span>
</span>
{link.links?.length ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-pop-black/30 transition-colors group-hover:text-pop-black" />
) : null}
</Link>
{link.links && link.links.length > 0 && (
<ul className="ml-4 space-y-0.5 border-l border-pop-black/10 pl-2 my-1">
{link.links.map((child, childIdx) => (
<li key={childIdx}>
<Link
to={child.url || '#'}
onClick={handleLinkClick}
className="block rounded-md px-2 py-1 text-[11px] font-normal normal-case tracking-normal text-pop-black/60 transition-colors hover:bg-pop-card hover:text-pop-black"
>
{getLocalizedText(child.label, lang)}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{hasColumns && isTabbed && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-xl border border-pop-black/10 bg-pop-bg/95 shadow-2xl backdrop-blur-xl">
<div className="flex min-h-[380px] max-h-[500px]">
{/* Left Category Tabs Sidebar */}
<div className="w-64 shrink-0 border-r border-pop-black/10 bg-pop-card/30 p-3 space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-pop-black/40">
{lang === 'th' ? 'หมวดหมู่หลัก' : 'Categories'}
</div>
{item.columns?.map((column, colIdx) => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0;
const isSelected = activeIdx === colIdx;
return (
<button
key={colIdx}
type="button"
onMouseEnter={() => {
setActiveTabByMenu(prev => ({ ...prev, [uniqueId]: colIdx }));
setHoveredLinkImage(null);
}}
className={`flex w-full items-center justify-between rounded-lg px-3.5 py-2.5 text-left transition-all ${
isSelected
? 'bg-pop-primary/10 text-pop-primary font-bold shadow-sm'
: 'text-pop-black/80 hover:bg-pop-card hover:text-pop-black font-medium'
}`}
>
<span className="flex items-center gap-2.5 min-w-0">
<DynamicIcon
iconName={column.icon}
defaultIcon={Sparkles}
className={`h-4 w-4 shrink-0 ${isSelected ? 'text-pop-primary' : 'text-pop-black/50'}`}
strokeWidth={1.5}
/>
<span className="truncate text-xs normal-case">{getLocalizedText(column.title, lang)}</span>
</span>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSelected ? 'text-pop-primary translate-x-0.5' : 'text-pop-black/20'}`} />
</button>
);
})}
</div>
{/* Right Content Panel for Selected Tab */}
{(() => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0;
const activeCol = item.columns?.[activeIdx] || item.columns?.[0];
if (!activeCol) return null;
return (
<div className="flex-1 p-6 flex flex-col justify-between overflow-y-auto">
<div>
<div className="flex items-center justify-between border-b border-pop-black/10 pb-3 mb-4">
<div className="flex items-center gap-2.5">
<DynamicIcon
iconName={activeCol.icon}
defaultIcon={Sparkles}
className="h-5 w-5 text-pop-primary"
strokeWidth={1.5}
/>
<h3 className="text-sm font-bold normal-case tracking-normal text-pop-black">
{getLocalizedText(activeCol.title, lang)}
</h3>
</div>
<Link
to={activeCol.links[0]?.url || item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 text-xs font-semibold normal-case text-pop-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูรายการทั้งหมด' : 'View all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{activeCol.links.map((link, lIdx) => (
<div
key={lIdx}
className="rounded-lg p-2.5 hover:bg-pop-card transition-colors group"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
to={link.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-2.5"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-pop-black/10 bg-pop-bg text-pop-primary group-hover:border-pop-primary/40">
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-3.5 w-3.5"
strokeWidth={1.35}
/>
</span>
<span className="truncate text-xs font-semibold normal-case text-pop-black group-hover:text-pop-primary transition-colors">
{getLocalizedText(link.label, lang)}
</span>
</Link>
{link.links && link.links.length > 0 && (
<div className="ml-9 mt-1.5 flex flex-wrap gap-1.5">
{link.links.slice(0, 3).map((child, cIdx) => (
<Link
key={cIdx}
to={child.url || '#'}
onClick={handleLinkClick}
className="text-[10px] text-pop-black/60 hover:text-pop-primary transition-colors bg-pop-card px-1.5 py-0.5 rounded border border-pop-black/5"
>
{getLocalizedText(child.label, lang)}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
<div className="border-t border-pop-black/10 pt-4 mt-6 flex items-center justify-between text-xs text-pop-black/50">
<span>{item.columns?.length || 0} {lang === 'th' ? 'หมวดหมู่ย่อย' : 'categories'}</span>
<Link
to={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 font-semibold text-pop-black hover:text-pop-primary transition-colors"
>
<span>{getLocalizedText(item.label, lang)}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
);
})()}
</div>
</div>
</div>
)}
{hasColumns && isVisualCards && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-2xl border border-pop-black/10 bg-pop-bg/95 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-pop-black/40">
{getLocalizedText(item.label, lang)}
</h4>
<Link
to={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1 text-xs font-semibold text-pop-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูทั้งหมด' : 'Explore all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{item.columns?.slice(0, 4).map((column, colIdx) => {
const cardImg = column.iconUrl || column.links?.[0]?.imageUrl || configuredImage || item.rootImageUrl;
const cardHref = column.links?.[0]?.url || item.url || '#';
const cardTitle = getLocalizedText(column.title, lang);
const firstSubLink = column.links?.[0];
const subText = firstSubLink ? getLocalizedText(firstSubLink.label, lang) : null;
return (
<Link
key={colIdx}
to={cardHref}
onClick={handleLinkClick}
className="group relative block overflow-hidden rounded-xl border border-pop-black/10 bg-pop-card transition-all hover:border-pop-primary/50 hover:shadow-lg"
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-pop-card/50">
<MenuImage
src={cardImg}
alt={cardTitle}
placeholder={cardTitle}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4 space-y-1">
<span className="block text-sm font-bold text-white normal-case leading-snug drop-shadow-sm">
{cardTitle}
</span>
{subText && (
<span className="flex items-center justify-between text-[11px] font-normal text-white/80 normal-case">
<span className="truncate">{subText}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-white/80 transition-transform group-hover:translate-x-1" />
</span>
)}
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
)}
{hasColumns && !isClassic && !isTabbed && !isVisualCards && (
<div
className={`fixed left-1/2 z-50 ${getMegaContainerClass(visibleColumns.length, showFeatured)} -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1' isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`} }`}
style={{ top: 'var(--navbar-bottom, 56px)' }} style={{ top: 'var(--navbar-bottom, 56px)' }}
@@ -172,7 +481,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" /> <div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div> </div>
<div className="w-full overflow-hidden rounded-lg border border-pop-black/10 bg-pop-bg/95 shadow-2xl cursor-default backdrop-blur-xl"> <div className="w-full overflow-hidden rounded-lg border border-pop-black/10 bg-pop-bg/95 shadow-2xl cursor-default backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}> <div className={`grid gap-0 px-8 py-8 ${getMegaGridColsClass(visibleColumns.length, showFeatured)}`}>
{visibleColumns.map((col, idx) => ( {visibleColumns.map((col, idx) => (
<div <div
key={idx} key={idx}
+10
View File
@@ -29,11 +29,21 @@ export interface MenuFeaturedCard {
ctaLabel?: Record<string, string> | string; ctaLabel?: Record<string, string> | string;
} }
export type SubmenuLayoutType = 'columns' | 'classic' | 'tabbed' | 'visual-cards';
export interface SubmenuLayoutConfig {
columnCount?: 2 | 3 | 4;
showFeatured?: boolean;
itemStyle?: 'icon' | 'thumbnail' | 'text-only';
}
export interface MegaMenuPayload { export interface MegaMenuPayload {
id?: string; id?: string;
nodeId?: string; nodeId?: string;
label: Record<string, string> | string; label: Record<string, string> | string;
url?: string; url?: string;
layout?: SubmenuLayoutType;
layoutConfig?: SubmenuLayoutConfig;
columns?: MenuColumn[]; columns?: MenuColumn[];
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1) rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard; featured?: MenuFeaturedCard;
@@ -84,6 +84,7 @@ function MenuImage({
export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) { export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null) const [openId, setOpenId] = useState<string | null>(null)
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null) const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null)
const [activeTabByMenu, setActiveTabByMenu] = useState<Record<string, number>>({})
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Automatically close mega menu when route changes // Automatically close mega menu when route changes
@@ -128,10 +129,17 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const hasColumns = !!item.columns?.length const hasColumns = !!item.columns?.length
const isActive = item.url ? currentPath === item.url : false const isActive = item.url ? currentPath === item.url : false
const visibleColumns = item.columns?.slice(0, 2) ?? [] const layout = item.layout || 'columns'
const isClassic = layout === 'classic'
const isTabbed = layout === 'tabbed'
const isVisualCards = layout === 'visual-cards'
const configuredCols = item.layoutConfig?.columnCount || (item.columns?.length ? Math.min(Math.max(item.columns.length, 2), 4) : 2)
const visibleColumns = item.columns?.slice(0, configuredCols) ?? []
const featuredLink = getFirstImageLink(item.columns) const featuredLink = getFirstImageLink(item.columns)
const featuredConfig = item.featured const featuredConfig = item.featured
const showFeatured = featuredConfig?.enabled !== false const showFeatured = (item.layoutConfig?.showFeatured !== false) && (featuredConfig?.enabled !== false) && !isClassic && !isVisualCards && !isTabbed && (
!!(featuredConfig?.imageUrl || featuredConfig?.title || featuredConfig?.sectionTitle || featuredLink?.imageUrl)
)
const configuredImage = featuredConfig?.imageUrl || null const configuredImage = featuredConfig?.imageUrl || null
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl
const focalPoint = featuredConfig?.focalPoint const focalPoint = featuredConfig?.focalPoint
@@ -147,6 +155,28 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#' const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'
const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details' const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details'
const getMegaContainerClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'w-[min(100vw-2rem,1260px)]'
if (cols === 3) return 'w-[min(100vw-2rem,1120px)]'
return 'w-[min(100vw-2rem,980px)]'
}
if (cols >= 4) return 'w-[min(100vw-2rem,1180px)]'
if (cols === 3) return 'w-[min(100vw-2rem,960px)]'
return 'w-[min(100vw-2rem,720px)]'
}
const getMegaGridColsClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'grid-cols-[1fr_1fr_1fr_1fr_260px]'
if (cols === 3) return 'grid-cols-[1fr_1fr_1fr_270px]'
return 'grid-cols-[1fr_1fr_270px]'
}
if (cols >= 4) return 'grid-cols-4'
if (cols === 3) return 'grid-cols-3'
return 'grid-cols-2'
}
return ( return (
<div <div
key={uniqueId} key={uniqueId}
@@ -176,9 +206,288 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
)} )}
</Link> </Link>
{hasColumns && ( {hasColumns && isClassic && (
<div <div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${ className={`absolute left-0 top-full z-50 pt-2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="min-w-[230px] max-w-xs cursor-default overflow-hidden rounded-xl border border-line-primary/15 bg-surface-card/95 p-2 shadow-card backdrop-blur-xl">
{item.columns?.map((column, columnIndex) => (
<div key={columnIndex} className={columnIndex > 0 ? 'mt-2 border-t border-line-primary/10 pt-2' : ''}>
{getLocalizedMenuText(column.title, lang) && item.columns && item.columns.length > 1 && (
<h5 className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-text">
{getLocalizedMenuText(column.title, lang)}
</h5>
)}
<ul className="space-y-0.5">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<Link
href={link.url || '#'}
onClick={handleLinkClick}
className="group flex items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold normal-case tracking-normal text-text-main transition-colors hover:bg-surface-muted hover:text-brand-primary"
>
<span className="flex items-center gap-2.5">
{link.icon && (
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-4 w-4 shrink-0 text-brand-primary/80 transition-colors group-hover:text-brand-primary"
strokeWidth={1.5}
/>
)}
<span>{getLocalizedMenuText(link.label, lang)}</span>
</span>
{link.links?.length ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-main/30 transition-colors group-hover:text-brand-primary" />
) : null}
</Link>
{link.links && link.links.length > 0 && (
<ul className="ml-4 space-y-0.5 border-l border-line-primary/15 pl-2 my-1">
{link.links.map((child, childIdx) => (
<li key={childIdx}>
<Link
href={child.url || '#'}
onClick={handleLinkClick}
className="block rounded-md px-2 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-text transition-colors hover:bg-surface-muted hover:text-brand-primary"
>
{getLocalizedMenuText(child.label, lang)}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{hasColumns && isTabbed && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-xl border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className="flex min-h-[380px] max-h-[500px]">
{/* Left Category Tabs Sidebar */}
<div className="w-64 shrink-0 border-r border-line-primary/15 bg-surface-page/50 p-3 space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-muted-text">
{lang === 'th' ? 'หมวดหมู่หลัก' : 'Categories'}
</div>
{item.columns?.map((column, colIdx) => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0
const isSelected = activeIdx === colIdx
return (
<button
key={colIdx}
type="button"
onMouseEnter={() => {
setActiveTabByMenu(prev => ({ ...prev, [uniqueId]: colIdx }))
setHoveredLinkImage(null)
}}
className={`flex w-full items-center justify-between rounded-lg px-3.5 py-2.5 text-left transition-all ${
isSelected
? 'bg-brand-primary/10 text-brand-primary font-bold shadow-sm'
: 'text-text-main/80 hover:bg-surface-muted hover:text-text-main font-medium'
}`}
>
<span className="flex items-center gap-2.5 min-w-0">
<DynamicIcon
iconName={column.icon}
defaultIcon={Sparkles}
className={`h-4 w-4 shrink-0 ${isSelected ? 'text-brand-primary' : 'text-brand-primary/60'}`}
strokeWidth={1.5}
/>
<span className="truncate text-xs normal-case">{getLocalizedMenuText(column.title, lang)}</span>
</span>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSelected ? 'text-brand-primary translate-x-0.5' : 'text-text-main/20'}`} />
</button>
)
})}
</div>
{/* Right Content Panel for Selected Tab */}
{(() => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0
const activeCol = item.columns?.[activeIdx] || item.columns?.[0]
if (!activeCol) return null
return (
<div className="flex-1 p-6 flex flex-col justify-between overflow-y-auto">
<div>
<div className="flex items-center justify-between border-b border-line-primary/10 pb-3 mb-4">
<div className="flex items-center gap-2.5">
<DynamicIcon
iconName={activeCol.icon}
defaultIcon={Sparkles}
className="h-5 w-5 text-brand-primary"
strokeWidth={1.5}
/>
<h3 className="text-sm font-bold normal-case tracking-normal text-text-main">
{getLocalizedMenuText(activeCol.title, lang)}
</h3>
</div>
<Link
href={activeCol.links[0]?.url || item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 text-xs font-semibold normal-case text-brand-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูรายการทั้งหมด' : 'View all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{activeCol.links.map((link, lIdx) => (
<div
key={lIdx}
className="rounded-lg p-2.5 hover:bg-surface-muted transition-colors group"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
href={link.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-2.5"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary group-hover:border-brand-primary/40">
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-3.5 w-3.5"
strokeWidth={1.35}
/>
</span>
<span className="truncate text-xs font-semibold normal-case text-text-main group-hover:text-brand-primary transition-colors">
{getLocalizedMenuText(link.label, lang)}
</span>
</Link>
{link.links && link.links.length > 0 && (
<div className="ml-9 mt-1.5 flex flex-wrap gap-1.5">
{link.links.slice(0, 3).map((child, cIdx) => (
<Link
key={cIdx}
href={child.url || '#'}
onClick={handleLinkClick}
className="text-[10px] text-muted-text hover:text-brand-primary transition-colors bg-surface-page/60 px-1.5 py-0.5 rounded border border-line-primary/10"
>
{getLocalizedMenuText(child.label, lang)}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
<div className="border-t border-line-primary/10 pt-4 mt-6 flex items-center justify-between text-xs text-muted-text">
<span>{item.columns?.length || 0} {lang === 'th' ? 'หมวดหมู่ย่อย' : 'categories'}</span>
<Link
href={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 font-semibold text-text-main hover:text-brand-primary transition-colors"
>
<span>{getLocalizedMenuText(item.label, lang)}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
)
})()}
</div>
</div>
</div>
)}
{hasColumns && isVisualCards && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-2xl border border-line-primary/15 bg-surface-card/95 p-6 shadow-card backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-text">
{getLocalizedMenuText(item.label, lang)}
</h4>
<Link
href={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1 text-xs font-semibold text-brand-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูทั้งหมด' : 'Explore all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{item.columns?.slice(0, 4).map((column, colIdx) => {
const cardImg = column.iconUrl || column.links?.[0]?.imageUrl || configuredImage || item.rootImageUrl
const cardHref = column.links?.[0]?.url || item.url || '#'
const cardTitle = getLocalizedMenuText(column.title, lang)
const firstSubLink = column.links?.[0]
const subText = firstSubLink ? getLocalizedMenuText(firstSubLink.label, lang) : null
return (
<Link
key={colIdx}
href={cardHref}
onClick={handleLinkClick}
className="group relative block overflow-hidden rounded-xl border border-line-primary/20 bg-surface-page transition-all hover:border-brand-primary/50 hover:shadow-lg"
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-surface-muted">
<MenuImage
src={cardImg}
alt={cardTitle}
placeholder={cardTitle}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4 space-y-1">
<span className="block text-sm font-bold text-white normal-case leading-snug drop-shadow-sm">
{cardTitle}
</span>
{subText && (
<span className="flex items-center justify-between text-[11px] font-normal text-white/80 normal-case">
<span className="truncate">{subText}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-white/80 transition-transform group-hover:translate-x-1" />
</span>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
</div>
)}
{hasColumns && !isClassic && !isTabbed && !isVisualCards && (
<div
className={`fixed left-1/2 z-50 ${getMegaContainerClass(visibleColumns.length, showFeatured)} -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0' isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`} }`}
style={{ top: 'var(--navbar-bottom, 64px)' }} style={{ top: 'var(--navbar-bottom, 64px)' }}
@@ -189,7 +498,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" /> <div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div> </div>
<div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl"> <div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}> <div className={`grid gap-0 px-8 py-8 ${getMegaGridColsClass(visibleColumns.length, showFeatured)}`}>
{visibleColumns.map((column, columnIndex) => ( {visibleColumns.map((column, columnIndex) => (
<div <div
key={columnIndex} key={columnIndex}
@@ -26,11 +26,21 @@ export interface MenuFeaturedCard {
ctaLabel?: Record<string, string> | string ctaLabel?: Record<string, string> | string
} }
export type SubmenuLayoutType = 'columns' | 'classic' | 'tabbed' | 'visual-cards'
export interface SubmenuLayoutConfig {
columnCount?: 2 | 3 | 4
showFeatured?: boolean
itemStyle?: 'icon' | 'thumbnail' | 'text-only'
}
export interface MegaMenuPayload { export interface MegaMenuPayload {
id?: string id?: string
nodeId?: string nodeId?: string
label: Record<string, string> | string label: Record<string, string> | string
url?: string url?: string
layout?: SubmenuLayoutType
layoutConfig?: SubmenuLayoutConfig
columns?: MenuColumn[] columns?: MenuColumn[]
rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1) rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard featured?: MenuFeaturedCard