From 138078a3986b2afff5c1b147d6a58a1bac47d76c Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 05:03:46 +0700 Subject: [PATCH] x --- apps/admin/src/pages/CategoriesPage.tsx | 758 ++++++++++++++++++------ 1 file changed, 586 insertions(+), 172 deletions(-) diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx index 0cea99e..562b2a6 100644 --- a/apps/admin/src/pages/CategoriesPage.tsx +++ b/apps/admin/src/pages/CategoriesPage.tsx @@ -1,19 +1,52 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { API_BASE, getCategoryImage, type Category } from '@homaroma/types' -import { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react' +import { + Edit3, + Package, + Trash2, + Plus, + X, + Image as ImageIcon, + UploadCloud, + Loader2, + ClipboardPaste, + LayoutDashboard, + LayoutGrid, + List, + Search, + Globe, + ChevronDown, + ChevronUp, + Layers, + Copy +} from 'lucide-react' import MediaLibraryModal from '../components/MediaLibraryModal' -import ContentLanguageTabs from '../components/ContentLanguageTabs' + +const ADDITIONAL_LANGS = [ + { code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' }, + { code: 'zh', label: 'Chinese (中文)', placeholder: '例如: 香薰蜡烛' }, + { code: 'vi', label: 'Vietnamese (Tiếng Việt)', placeholder: 'Ví dụ: Nến thơm' }, + { code: 'ko', label: 'Korean (한국어)', placeholder: '예: 아로마 캔들' }, + { code: 'lo', label: 'Lao (ພາສາລາວ)', placeholder: 'ຕົວຢ່າງ: ທຽນຫອມ' }, + { code: 'it', label: 'Italian (Italiano)', placeholder: 'Es: Candela profumata' }, +] export default function CategoriesPage() { - const { t, i18n } = useTranslation() + const { t } = useTranslation() const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) + const [viewMode, setViewMode] = useState<'list' | 'grid'>('list') + const [searchQuery, setSearchQuery] = useState('') + + // Modal State const [isModalOpen, setIsModalOpen] = useState(false) const [isUploading, setIsUploading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [showMoreLangs, setShowMoreLangs] = useState(false) const [editingCategory, setEditingCategory] = useState | null>(null) - const [activeLang, setActiveLang] = useState('th') + // Media Modal State const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) @@ -22,9 +55,11 @@ export default function CategoriesPage() { try { const r = await fetch(`${API_BASE}/categories?lang=all`) const data = await r.json() - setCategories(data) + if (Array.isArray(data)) { + setCategories(data) + } } catch (err) { - console.error(err) + console.error('Failed to fetch categories:', err) } finally { setLoading(false) } @@ -34,38 +69,66 @@ export default function CategoriesPage() { fetchData() }, []) + // Filtered categories + const filteredCategories = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return categories + + return categories.filter((cat) => { + const idMatch = String(cat.id).includes(q) + if (typeof cat.name === 'string') { + return idMatch || cat.name.toLowerCase().includes(q) + } + if (cat.name && typeof cat.name === 'object') { + const anyLangMatch = Object.values(cat.name).some( + (val) => typeof val === 'string' && val.toLowerCase().includes(q) + ) + return idMatch || anyLangMatch + } + return idMatch + }) + }, [categories, searchQuery]) + + // Summary stats + const totalProducts = useMemo(() => { + return categories.reduce((sum, c) => sum + (c._count?.products || 0), 0) + }, [categories]) + const handleSave = async (e: React.FormEvent) => { e.preventDefault() if (!editingCategory) return + setIsSaving(true) try { const url = editingCategory.id ? `${API_BASE}/categories/${editingCategory.id}` : `${API_BASE}/categories` const method = editingCategory.id ? 'PUT' : 'POST' + const masterName = typeof editingCategory.name === 'string' + ? editingCategory.name + : (editingCategory.name?.th || editingCategory.name?.en || 'Untitled') + const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: typeof editingCategory.name === 'string' - ? editingCategory.name - : (editingCategory.name?.th || editingCategory.name?.en || 'Untitled'), + name: masterName, imageUrl: editingCategory.imageUrl || null }) }) if (res.ok) { - const savedCat = await res.json(); - const catId = editingCategory.id || savedCat.id; + const savedCat = await res.json() + const catId = editingCategory.id || savedCat.id // Save translations - if (typeof editingCategory.name === 'object') { + if (typeof editingCategory.name === 'object' && catId) { await fetch(`${API_BASE}/categories/${catId}/translations`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: editingCategory.name }) - }); + }) } setIsModalOpen(false) @@ -76,12 +139,14 @@ export default function CategoriesPage() { } } catch (err) { console.error('Save error:', err) - alert('An error occurred while saving.') + alert('An error occurred while saving category.') + } finally { + setIsSaving(false) } } - const handleDelete = async (id: number) => { - if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this category?'))) return + const handleDelete = async (id: number, catName: string) => { + if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${catName}"?`))) return try { const res = await fetch(`${API_BASE}/categories/${id}`, { method: 'DELETE' @@ -139,215 +204,564 @@ export default function CategoriesPage() { const openAddModal = () => { setEditingCategory({ - name: { th: '', en: '', ja: '', zh: '' } as any, + name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' } as any, imageUrl: '' }) - setActiveLang('th') + setShowMoreLangs(false) setIsModalOpen(true) } const openEditModal = (cat: Category) => { + const nameObj = typeof cat.name === 'string' + ? { th: cat.name, en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' } + : { th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '', ...cat.name } + setEditingCategory({ ...cat, - name: typeof cat.name === 'string' ? { th: cat.name } as any : { ...cat.name } + name: nameObj as any }) - setActiveLang('th') + + // Auto-open more langs if any secondary lang has content + const hasExtraLangs = ADDITIONAL_LANGS.some(l => Boolean((nameObj as any)[l.code])) + setShowMoreLangs(hasExtraLangs) setIsModalOpen(true) } - const handleCopyName = (fromLang: string) => { - if (!editingCategory?.name || typeof editingCategory.name !== 'object') return - const sourceText = (editingCategory.name as any)[fromLang] || '' - if (!sourceText) return - setEditingCategory({ - ...editingCategory, - name: { - ...(editingCategory.name as any), - [activeLang]: sourceText, - } as any, + const updateCategoryNameField = (langCode: string, value: string) => { + setEditingCategory(prev => { + if (!prev) return prev + const currentName = typeof prev.name === 'object' ? { ...prev.name } : { th: prev.name || '' } + return { + ...prev, + name: { + ...currentName, + [langCode]: value + } as any + } }) } - const getLocalizedCategoryName = (cat: Category) => { + const copyNameFrom = (fromLang: string, targetLang: string) => { + if (!editingCategory?.name || typeof editingCategory.name !== 'object') return + const source = (editingCategory.name as any)[fromLang] || '' + if (!source) return + updateCategoryNameField(targetLang, source) + } + + const getThaiName = (cat: Category) => { if (typeof cat.name === 'string') return cat.name - const lang = i18n.language - return cat.name?.[lang] || cat.name?.th || cat.name?.en || 'Unnamed' + return cat.name?.th || '-' + } + + const getEnglishName = (cat: Category) => { + if (typeof cat.name === 'string') return '-' + return cat.name?.en || '-' + } + + const getAvailableLangsCount = (cat: Category) => { + if (typeof cat.name === 'string') return 1 + if (!cat.name || typeof cat.name !== 'object') return 0 + return Object.values(cat.name).filter(v => Boolean(v && typeof v === 'string' && v.trim())).length } return ( -
- {!isModalOpen ? ( - <> -
-
-

{t('categories.title', 'Category Management')}

-

{t('categories.subtitle', 'Organize products into multilingual categories')}

-
- +
-
- {loading ? ( - Array.from({ length: 5 }).map((_, i) => ( -
- )) - ) : ( - categories.map((cat) => ( -
-
-
- {typeof -
-
- - -
-
- -
-

- {getLocalizedCategoryName(cat)} -

-
- + {/* Add Category Button */} + +
+
+ + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-11 pr-4 py-3 bg-night-900/50 border border-white/5 focus:border-cyan-glow/30 rounded-xl outline-none text-sm placeholder:text-slate-600 transition-all shadow-inner text-white" + /> + {searchQuery && ( + + )} +
+
+ + {/* Content Rendering: List View vs Grid View */} + {viewMode === 'list' ? ( + /* ─── LIST / TABLE VIEW ─── */ +
+
+ + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + + + )) + ) : filteredCategories.length === 0 ? ( + + + + ) : ( + filteredCategories.map((cat) => { + const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '') + const imageUrl = cat.imageUrl || getCategoryImage(fallbackName) + const langsCount = getAvailableLangsCount(cat) + const thaiName = getThaiName(cat) + + return ( + + {/* Thumbnail */} + + + {/* ID */} + + + {/* Thai Name */} + + + {/* English Name */} + + + {/* Languages Badge */} + + + {/* Product Count */} + + + {/* Actions */} + + + ) + }) + )} + +
ImageIDThai Name (ชื่อหมวดหมู่)English NameLanguagesProductsActions
+
+
+ +

{t('categories.noFound', 'No categories found')}

+ {searchQuery && ( + + )} +
+
+ {thaiName} +
+
+ #{cat.id} + +
+ {thaiName} +
+
+
+ {getEnglishName(cat)} +
+
+ + + {langsCount} {langsCount === 1 ? 'lang' : 'langs'} + + + + + {cat._count?.products || 0} + + +
+ + +
+
+
+
+ ) : ( + /* ─── GRID / CARDS VIEW ─── */ +
+ {loading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+ )) + ) : filteredCategories.length === 0 ? ( +
+ +

{t('categories.noFound', 'No categories found')}

+
+ ) : ( + filteredCategories.map((cat) => { + const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '') + const imageUrl = cat.imageUrl || getCategoryImage(fallbackName) + const thaiName = getThaiName(cat) + const englishName = getEnglishName(cat) + + return ( +
+
+
+ {thaiName} +
+ + {/* Actions on Card (Always visible with subtle hover highlights) */} +
+ + +
+
+ +
+
ID #{cat.id}
+

+ {thaiName} +

+ {englishName !== '-' && ( +

{englishName}

+ )} + +
+
+ {cat._count?.products || 0} {t('categories.itemCount', 'Products')}
-
+ + {getAvailableLangsCount(cat)} langs + +
+
-
+
- )) - )} -
- - ) : ( - editingCategory && ( -
-
-

- - {editingCategory.id ? t('categories.editCategory', 'Edit Category') : t('categories.addNew', 'Create Category')} -

+ ) + }) + )} +
+ )} + + {/* ─── SIMPLIFIED EDIT / CREATE MODAL ─── */} + {isModalOpen && editingCategory && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

+ {editingCategory.id ? t('categories.editCategory', 'Edit Category') : t('categories.addNew', 'Create Category')} +

+

+ {editingCategory.id ? `Editing category ID #${editingCategory.id}` : 'Fill in the details below to add a new category'} +

+
+
-
-
- - {/* Image Upload Area */} -
-
- - -
-
{ - setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url }))) - setIsMediaModalOpen(true) - }} - > - {editingCategory.imageUrl ? ( - <> - Category -
- -
- - ) : ( -
- -

Select Image

-
- )} - - {isUploading && ( -
- -
- )} -
+ {/* Modal Body */} + + + {/* Category Image Section */} +
+
+ +
- {/* Multilingual Name Field */} -
- +
+
{ + setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url }))) + setIsMediaModalOpen(true) + }} + > + {editingCategory.imageUrl ? ( + <> + Category +
+ +
+ + ) : ( +
+ +

Select Image

+
+ )} + + {isUploading && ( +
+ +
+ )} +
- - +
+

Click thumbnail to select from Media Library

+

Recommended ratio: Square 1:1 (e.g. 600x600 px) in JPG, PNG, or WebP format.

+ {editingCategory.imageUrl && ( + + )} +
+
+
+ + {/* Primary Names (Thai & English) */} +
+
+ Primary Names +
+
+ + {/* Thai Name */} +
+ setEditingCategory({ - ...editingCategory, - name: { ...(editingCategory.name as any), [activeLang]: e.target.value } as any - })} - className="admin-input w-full mt-2" - placeholder={`Category name in ${activeLang.toUpperCase()} (${activeLang === 'th' ? 'เช่น เทียนหอม' : activeLang === 'en' ? 'e.g. Scented Candle' : activeLang === 'ja' ? '例: アロマキャンドル' : '例如: 香薰蜡烛'})`} + required + value={(editingCategory.name as any)?.th || ''} + onChange={(e) => updateCategoryNameField('th', e.target.value)} + className="admin-input w-full" + placeholder="เช่น เทียนหอม, น้ำมันหอมระเหย, ก้านไม้หอม" + /> +
+ + {/* English Name */} +
+
+ + +
+ updateCategoryNameField('en', e.target.value)} + className="admin-input w-full" + placeholder="e.g. Scented Candles, Essential Oils, Reed Diffusers" />
-
- + + {showMoreLangs && ( +
+ {ADDITIONAL_LANGS.map(({ code, label, placeholder }) => ( +
+
+ + +
+ updateCategoryNameField(code, e.target.value)} + className="admin-input w-full text-xs py-2" + placeholder={placeholder} + /> +
+ ))} +
+ )} +
+ + {/* Modal Actions */} +
+
- ) +
)} + {/* Media Library Modal */} setIsMediaModalOpen(false)}