From 3e742ac468edc4bec9fc70efce89fe13d992431d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 05:16:28 +0700 Subject: [PATCH] x --- apps/admin/src/App.tsx | 4 + apps/admin/src/pages/CategoriesPage.tsx | 447 +----------- apps/admin/src/pages/CategoryEditPage.tsx | 807 ++++++++++++++++++++++ apps/backend/src/routes/categories.ts | 26 +- apps/backend/src/routes/pages.ts | 4 +- 5 files changed, 871 insertions(+), 417 deletions(-) create mode 100644 apps/admin/src/pages/CategoryEditPage.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 91ccdf2..f762645 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -6,6 +6,7 @@ import ProductEditPage from './pages/ProductEditPage' import ProductBomPage from './pages/ProductBomPage' import ProductCmsPage from './pages/ProductCmsPage' import CategoriesPage from './pages/CategoriesPage' +import CategoryEditPage from './pages/CategoryEditPage' import CustomersPage from './pages/CustomersPage' import PageBuilderPage from './pages/PageBuilderPage' import CmsMockupPage from './pages/CmsMockupPage' @@ -92,6 +93,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx index 562b2a6..1725e9c 100644 --- a/apps/admin/src/pages/CategoriesPage.tsx +++ b/apps/admin/src/pages/CategoriesPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo } from 'react' +import { useNavigate, Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { API_BASE, getCategoryImage, type Category } from '@homaroma/types' import { @@ -7,48 +8,20 @@ import { Trash2, Plus, X, - Image as ImageIcon, - UploadCloud, - Loader2, - ClipboardPaste, - LayoutDashboard, LayoutGrid, List, Search, Globe, - ChevronDown, - ChevronUp, - Layers, - Copy + Layers } from 'lucide-react' -import MediaLibraryModal from '../components/MediaLibraryModal' - -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 } = useTranslation() + const navigate = useNavigate() 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) - - // Media Modal State - const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) - const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) const fetchData = async () => { setLoading(true) @@ -94,57 +67,6 @@ export default function CategoriesPage() { 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: masterName, - imageUrl: editingCategory.imageUrl || null - }) - }) - - if (res.ok) { - const savedCat = await res.json() - const catId = editingCategory.id || savedCat.id - - // Save translations - 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) - fetchData() - } else { - const errorData = await res.json() - alert(errorData.message || 'Failed to save category') - } - } catch (err) { - console.error('Save error:', err) - alert('An error occurred while saving category.') - } finally { - setIsSaving(false) - } - } - const handleDelete = async (id: number, catName: string) => { if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${catName}"?`))) return try { @@ -159,95 +81,10 @@ export default function CategoriesPage() { } } catch (err) { console.error('Delete error:', err) + alert('Failed to delete category') } } - const handleUpload = async (file: File) => { - setIsUploading(true) - const formData = new FormData() - formData.append('file', file) - - try { - const res = await fetch(`${API_BASE}/upload`, { - method: 'POST', - body: formData - }) - const data = await res.json() - if (data.url) { - setEditingCategory(prev => ({ ...prev, imageUrl: data.url })) - } - } catch (err) { - console.error('Upload error:', err) - } finally { - setIsUploading(false) - } - } - - const handlePasteFromClipboard = async () => { - try { - const items = await navigator.clipboard.read() - for (const item of items) { - const imageTypes = item.types.filter(t => t.startsWith('image/')) - for (const type of imageTypes) { - const blob = await item.getType(type) - const file = new File([blob], 'pasted-category-image.png', { type }) - handleUpload(file) - return - } - } - alert('No image found in clipboard') - } catch (err) { - console.error('Clipboard error:', err) - alert('Please allow clipboard access or copy an image first.') - } - } - - const openAddModal = () => { - setEditingCategory({ - name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' } as any, - imageUrl: '' - }) - 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: nameObj as any - }) - - // 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 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 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 return cat.name?.th || '-' @@ -270,7 +107,7 @@ export default function CategoriesPage() {
-

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

+

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

{categories.length} {t('categories.categoriesUnit', 'Categories')} @@ -310,13 +147,13 @@ export default function CategoriesPage() {
{/* Add Category Button */} - + {t('categories.addNew', 'Create Category')} +
@@ -393,7 +230,8 @@ export default function CategoriesPage() { return ( navigate(`/categories/${cat.id}`)} > {/* Thumbnail */} @@ -413,9 +251,13 @@ export default function CategoriesPage() { {/* Thai Name */} -
+ e.stopPropagation()} + className="font-bold text-white text-sm hover:text-cyan-glow transition-colors" + > {thaiName} -
+ {/* English Name */} @@ -443,14 +285,14 @@ export default function CategoriesPage() { {/* Actions */} -
- + + -
- - {/* Modal Body */} -
- - {/* Category Image Section */} -
-
- - -
- -
-
{ - 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 */} -
- - 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" - /> -
-
- - {/* Additional Languages Accordion (Optional) */} -
- - - {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)} - onSelect={(url) => { - if (onSelectMedia) onSelectMedia(url) - }} - /> ) } diff --git a/apps/admin/src/pages/CategoryEditPage.tsx b/apps/admin/src/pages/CategoryEditPage.tsx new file mode 100644 index 0000000..ad2a92a --- /dev/null +++ b/apps/admin/src/pages/CategoryEditPage.tsx @@ -0,0 +1,807 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { API_BASE, type Category } from '@homaroma/types' +import { + ArrowLeft, + Save, + Trash2, + Image as ImageIcon, + UploadCloud, + Loader2, + ClipboardPaste, + Globe, + Package, + ExternalLink, + Copy, + ChevronDown, + ChevronUp, + LayoutDashboard, + CheckCircle2, + AlertCircle, + FileJson, + Check, + Code2, + Sparkles +} from 'lucide-react' +import MediaLibraryModal from '../components/MediaLibraryModal' + +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' }, +] + +const SAMPLE_JSON_TEMPLATE = JSON.stringify({ + th: "เทียนหอมอโรมา", + en: "Aromatherapy Scented Candles", + ja: "アロマキャンドル", + zh: "芳香香薰蜡烛", + vi: "Nến thơm thư giãn", + ko: "아로마 캔들", + lo: "ທຽນຫອມອາໂຣມາ", + it: "Candele aromatiche profumate" +}, null, 2) + +export default function CategoryEditPage() { + const { t } = useTranslation() + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + + const [loading, setLoading] = useState(!isNew) + const [saving, setSaving] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [showMoreLangs, setShowMoreLangs] = useState(false) + const [successToast, setSuccessToast] = useState(null) + + // JSON Quick Import State + const [isJsonBoxOpen, setIsJsonBoxOpen] = useState(false) + const [jsonInput, setJsonInput] = useState('') + const [jsonError, setJsonError] = useState(null) + const [jsonSuccess, setJsonSuccess] = useState(null) + + // Category State + const [name, setName] = useState>({ + th: '', + en: '', + ja: '', + zh: '', + vi: '', + ko: '', + lo: '', + it: '', + }) + const [imageUrl, setImageUrl] = useState('') + const [categoryMeta, setCategoryMeta] = useState(null) + + // Media Library Modal + const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) + + useEffect(() => { + if (isNew) return + + setLoading(true) + fetch(`${API_BASE}/categories/${id}?lang=all`) + .then(async (res) => { + if (!res.ok) throw new Error('Category not found') + const data = await res.json() + setCategoryMeta(data) + setImageUrl(data.imageUrl || '') + + if (data.name && typeof data.name === 'object') { + setName(prev => ({ ...prev, ...data.name })) + // If any additional lang exists, expand the accordion + const hasExtra = ADDITIONAL_LANGS.some(l => Boolean(data.name[l.code])) + if (hasExtra) setShowMoreLangs(true) + } else if (typeof data.name === 'string') { + setName(prev => ({ ...prev, th: data.name })) + } + }) + .catch((err) => { + console.error('Failed to load category:', err) + alert('Could not find category. Returning to categories list.') + navigate('/categories') + }) + .finally(() => setLoading(false)) + }, [id, isNew, navigate]) + + const updateLangName = (langCode: string, value: string) => { + setName(prev => ({ + ...prev, + [langCode]: value + })) + } + + const copyNameFrom = (fromLang: string, targetLang: string) => { + const source = name[fromLang] || '' + if (!source) return + updateLangName(targetLang, source) + } + + // ── JSON Import Handlers ────────────────────────────────────────────────── + const handleApplyJson = () => { + setJsonError(null) + setJsonSuccess(null) + + if (!jsonInput.trim()) { + setJsonError('Please paste valid JSON text') + return + } + + try { + let parsed = JSON.parse(jsonInput) + + // Support nested shapes e.g. { name: { th, en } } or { translations: { th: { name: "..." } } } + if (parsed.name && typeof parsed.name === 'object') { + parsed = parsed.name + } else if (parsed.translations && typeof parsed.translations === 'object') { + const extracted: Record = {} + for (const [key, val] of Object.entries(parsed.translations)) { + if (typeof val === 'string') { + extracted[key] = val + } else if (val && typeof val === 'object' && typeof (val as any).name === 'string') { + extracted[key] = (val as any).name + } + } + parsed = extracted + } + + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + throw new Error('JSON must be an object with language keys (e.g. { "th": "...", "en": "..." })') + } + + const updatedName = { ...name } + let appliedCount = 0 + + // Match all possible language keys + const allKnownCodes = ['th', 'en', ...ADDITIONAL_LANGS.map(l => l.code)] + for (const code of allKnownCodes) { + if (typeof parsed[code] === 'string' && parsed[code].trim()) { + updatedName[code] = parsed[code].trim() + appliedCount++ + } + } + + if (appliedCount === 0) { + throw new Error('No recognized language keys (th, en, ja, zh, vi, ko, lo, it) found in the JSON.') + } + + setName(updatedName) + + // Auto-expand accordion if additional languages were filled + const hasExtra = ADDITIONAL_LANGS.some(l => Boolean(updatedName[l.code])) + if (hasExtra) setShowMoreLangs(true) + + setJsonSuccess(`Successfully applied ${appliedCount} language names!`) + setTimeout(() => { + setIsJsonBoxOpen(false) + setJsonSuccess(null) + }, 1200) + } catch (err: any) { + setJsonError(err.message || 'Invalid JSON format') + } + } + + const handlePasteJsonFromClipboard = async () => { + try { + const text = await navigator.clipboard.readText() + if (text) { + setJsonInput(text) + setJsonError(null) + } else { + alert('Clipboard is empty or does not contain text.') + } + } catch (err) { + console.error('Clipboard error:', err) + alert('Could not access clipboard. Please paste manually.') + } + } + + const handleFillSampleJson = () => { + setJsonInput(SAMPLE_JSON_TEMPLATE) + setJsonError(null) + } + + const handleExportCurrentJson = () => { + const cleanObject: Record = {} + for (const [k, v] of Object.entries(name)) { + if (v && v.trim()) cleanObject[k] = v.trim() + } + setJsonInput(JSON.stringify(cleanObject, null, 2)) + setJsonError(null) + setJsonSuccess('Exported current names to JSON!') + setTimeout(() => setJsonSuccess(null), 1500) + } + + // ── Image Upload Handlers ───────────────────────────────────────────────── + const handleUpload = async (file: File) => { + setIsUploading(true) + const formData = new FormData() + formData.append('file', file) + + try { + const res = await fetch(`${API_BASE}/upload`, { + method: 'POST', + body: formData + }) + const data = await res.json() + if (data.url) { + setImageUrl(data.url) + } + } catch (err) { + console.error('Upload error:', err) + alert('Failed to upload image') + } finally { + setIsUploading(false) + } + } + + const handlePasteFromClipboard = async () => { + try { + const items = await navigator.clipboard.read() + for (const item of items) { + const imageTypes = item.types.filter(t => t.startsWith('image/')) + for (const type of imageTypes) { + const blob = await item.getType(type) + const file = new File([blob], 'pasted-category-image.png', { type }) + handleUpload(file) + return + } + } + alert('No image found in clipboard') + } catch (err) { + console.error('Clipboard error:', err) + alert('Please allow clipboard access or copy an image first.') + } + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.th?.trim()) { + alert('Please enter Thai category name') + return + } + + setSaving(true) + try { + const masterName = name.th.trim() || name.en?.trim() || 'Untitled' + const url = isNew ? `${API_BASE}/categories` : `${API_BASE}/categories/${id}` + const method = isNew ? 'POST' : 'PUT' + + // Step 1: Save base category + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: masterName, + imageUrl: imageUrl || null + }) + }) + + if (!res.ok) { + const errorData = await res.json() + throw new Error(errorData.message || 'Failed to save category') + } + + const savedCat = await res.json() + const targetId = isNew ? savedCat.id : id + + // Step 2: Save translations + if (targetId) { + await fetch(`${API_BASE}/categories/${targetId}/translations`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }) + } + + setSuccessToast(isNew ? 'Category created successfully!' : 'Category updated successfully!') + setTimeout(() => { + navigate('/categories') + }, 800) + } catch (err: any) { + console.error('Save error:', err) + alert(err.message || 'An error occurred while saving.') + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (isNew) return + const categoryTitle = name.th || name.en || `ID #${id}` + if (!confirm(t('categories.confirmDelete', `Are you sure you want to delete category "${categoryTitle}"?`))) return + + try { + const res = await fetch(`${API_BASE}/categories/${id}`, { + method: 'DELETE' + }) + if (res.ok) { + navigate('/categories') + } else { + const errorData = await res.json() + alert(errorData.message || 'Failed to delete category') + } + } catch (err) { + console.error('Delete error:', err) + alert('Failed to delete category') + } + } + + if (loading) { + return ( +
+ +

Loading category details...

+
+ ) + } + + const displayName = name.th || name.en || (isNew ? 'New Category' : `Category #${id}`) + + return ( +
+ {/* Top Header & Sticky Action Bar */} +
+
+ + + +
+
+

+ {isNew ? 'Create New Category' : `Edit Category`} +

+ {!isNew && ( + + ID #{id} + + )} +
+

+ {isNew ? 'Define category names in multiple languages and set a cover image' : displayName} +

+
+
+ +
+ {!isNew && ( + + )} + + + Cancel + + + +
+
+ + {/* Success Notification */} + {successToast && ( +
+ + {successToast} +
+ )} + + {/* Main Edit Layout */} +
+ + {/* Left Column (2 Cols): Names & Translations */} +
+ + {/* Card 1: Primary Names + JSON Quick Paste Button */} +
+
+
+
+ +
+
+

Category Names

+

Primary names for store navigation and product classification

+
+
+ + {/* JSON Paste Action Button */} + +
+ + {/* Expandable JSON Input Box */} + {isJsonBoxOpen && ( +
+
+
+ + Paste Multilingual Names JSON +
+ +
+ + + +
+
+ +