x
This commit is contained in:
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user