From ac387185e006e5451edc413c30eaaf68e2e6fe5a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 23:59:16 -0400 Subject: [PATCH] x --- apps/admin/src/App.tsx | 2 + apps/admin/src/components/Sidebar.tsx | 2 + .../admin/src/pages/ProductAttributesPage.tsx | 425 +++++++++++++++++ apps/admin/src/pages/ProductEditPage.tsx | 287 +++++++++++- .../migration.sql | 79 ++++ .../migration.sql | 20 + apps/backend/prisma/schema.prisma | 69 +++ apps/backend/src/index.ts | 3 +- apps/backend/src/lib/productAttributes.ts | 171 +++++++ apps/backend/src/routes/product-attributes.ts | 439 ++++++++++++++++++ apps/backend/src/routes/products.ts | 64 ++- apps/mcp-server/index.ts | 190 ++++++++ apps/spa/src/pages/ProductDetailPage.tsx | 37 +- .../src/app/[lang]/products/[id]/page.tsx | 33 ++ packages/types/index.ts | 46 ++ 15 files changed, 1855 insertions(+), 12 deletions(-) create mode 100644 apps/admin/src/pages/ProductAttributesPage.tsx create mode 100644 apps/backend/prisma/migrations/20260628033659_add_product_attributes/migration.sql create mode 100644 apps/backend/prisma/migrations/20260628035508_add_product_attribute_translations/migration.sql create mode 100644 apps/backend/src/lib/productAttributes.ts create mode 100644 apps/backend/src/routes/product-attributes.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4661134..9a93f3a 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -33,6 +33,7 @@ import LazadaOverviewPage from './pages/LazadaOverviewPage' import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage' import MediaLibraryPage from './pages/MediaLibraryPage' import ContactMessagesPage from './pages/ContactMessagesPage' +import ProductAttributesPage from './pages/ProductAttributesPage' function App() { return ( @@ -46,6 +47,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index e3f3e86..f549d7e 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { LayoutDashboard, Package, PackageOpen, + Ruler, Settings, LogOut, ChevronRight, @@ -24,6 +25,7 @@ const MENU_ITEMS = [ { icon: LayoutDashboard, label: 'Dashboard', to: '/' }, { icon: Tags, label: 'Categories', to: '/categories' }, { icon: Package, label: 'Products', to: '/products' }, + { icon: Ruler, label: 'Product Attributes', to: '/product-attributes' }, { icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' }, { icon: Users, label: 'LINE Customers', to: '/customers' }, { icon: ShoppingCart, label: 'Orders', to: '/orders' }, diff --git a/apps/admin/src/pages/ProductAttributesPage.tsx b/apps/admin/src/pages/ProductAttributesPage.tsx new file mode 100644 index 0000000..d3cee9d --- /dev/null +++ b/apps/admin/src/pages/ProductAttributesPage.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useState } from 'react' +import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types' +import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react' + +const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] as const +const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const + +type EditableDefinition = { + id?: number + code: string + type: typeof ATTRIBUTE_TYPES[number] + unit: string + isRequired: boolean + isFilterable: boolean + sortOrder: number + categoryId: number | null + translations: Record + options: Record[] +} + +function emptyDefinition(): EditableDefinition { + return { + code: '', + type: 'TEXT', + unit: '', + isRequired: false, + isFilterable: false, + sortOrder: 0, + categoryId: null, + translations: Object.fromEntries(LANGUAGES.map((lang) => [lang, { label: '', description: '' }])), + options: [], + } +} + +function parseDefinition(definition: AttributeDefinition): EditableDefinition { + const translations = Object.fromEntries( + LANGUAGES.map((lang) => [lang, { + label: typeof definition.label === 'string' ? (lang === 'th' ? definition.label : '') : (definition.label?.[lang] || ''), + description: typeof definition.description === 'string' ? (lang === 'th' ? definition.description : '') : (definition.description?.[lang] || ''), + }]) + ) + + return { + id: definition.id, + code: definition.code, + type: definition.type, + unit: definition.unit || '', + isRequired: definition.isRequired, + isFilterable: definition.isFilterable, + sortOrder: definition.sortOrder, + categoryId: definition.categoryId ?? null, + translations, + options: Array.isArray(definition.options) + ? definition.options.map((option) => typeof option === 'string' ? { th: option } : option) + : [], + } +} + +export default function ProductAttributesPage() { + const [definitions, setDefinitions] = useState([]) + const [categories, setCategories] = useState([]) + const [editing, setEditing] = useState(null) + const [activeLang, setActiveLang] = useState('th') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const fetchData = async () => { + setLoading(true) + try { + const [definitionRes, categoryRes] = await Promise.all([ + fetch(`${API_BASE}/product-attributes/definitions?lang=all`), + fetch(`${API_BASE}/categories?lang=all`), + ]) + if (!definitionRes.ok) throw new Error('Failed to load attribute definitions') + const [definitionData, categoryData] = await Promise.all([definitionRes.json(), categoryRes.json()]) + setDefinitions(definitionData) + setCategories(categoryData) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + if (!editing) return + setSaving(true) + setError(null) + + try { + const baseLabel = editing.translations.th.label || editing.translations.en.label + if (!baseLabel) throw new Error('Thai or English label is required') + + const payload = { + code: editing.code.trim(), + label: baseLabel, + description: editing.translations.th.description || null, + type: editing.type, + unit: editing.unit || null, + isRequired: editing.isRequired, + isFilterable: editing.isFilterable, + sortOrder: Number(editing.sortOrder) || 0, + categoryId: editing.categoryId || null, + options: editing.type === 'SELECT' ? editing.options : null, + } + + const url = editing.id + ? `${API_BASE}/product-attributes/definitions/${editing.id}` + : `${API_BASE}/product-attributes/definitions` + const method = editing.id ? 'PUT' : 'POST' + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...payload, + translations: Object.fromEntries( + Object.entries(editing.translations).filter(([, value]) => value.label.trim()) + ), + }), + }) + + if (!res.ok) { + const err = await res.json().catch(() => null) + throw new Error(err?.message || 'Failed to save attribute definition') + } + + const saved = await res.json() + const definitionId = editing.id || saved.definition?.id + + const translationRes = await fetch(`${API_BASE}/product-attributes/definitions/${definitionId}/translations`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + translations: Object.fromEntries( + Object.entries(editing.translations).filter(([, value]) => value.label.trim()) + ), + }), + }) + + if (!translationRes.ok) { + const err = await translationRes.json().catch(() => null) + throw new Error(err?.message || 'Failed to save attribute definition translations') + } + + setEditing(null) + await fetchData() + } catch (err: any) { + setError(err.message) + } finally { + setSaving(false) + } + } + + const openCreate = () => { + setEditing(emptyDefinition()) + setActiveLang('th') + } + + const categoryNameMap = useMemo( + () => new Map(categories.map((category) => [category.id, typeof category.name === 'string' ? category.name : category.name.th || category.name.en || 'Untitled'])), + [categories] + ) + + return ( +
+
+
+

Product Attribute Definitions

+

Create reusable product specs and maintain translations across languages.

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ +

Definitions

+
+ +
+ {loading && Array.from({ length: 4 }).map((_, idx) => ( +
+ ))} + + {!loading && definitions.map((definition) => { + const label = typeof definition.label === 'string' ? definition.label : definition.label.th || definition.label.en || definition.code + const description = typeof definition.description === 'string' ? definition.description : definition.description?.th || definition.description?.en || '' + return ( +
+
+
+

{label}

+ + {definition.type} + +
+

{definition.code}

+

{description || 'No description yet'}

+

+ {definition.categoryId ? `Category: ${categoryNameMap.get(definition.categoryId) || definition.categoryId}` : 'Global attribute'} +

+
+ +
+ ) + })} +
+
+ +
+
+
+

{editing?.id ? 'Edit Definition' : 'Create Definition'}

+

Type-aware definitions with localized labels and SELECT options.

+
+ {editing && ( + + )} +
+ + {!editing ? ( +
+ Select a definition to edit or create a new one. +
+ ) : ( +
+
+ {LANGUAGES.map((lang) => ( + + ))} +
+ +
+
+ + setEditing({ ...editing, code: e.target.value })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" + /> +
+
+ + +
+
+ + setEditing({ ...editing, unit: e.target.value })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" + /> +
+
+ + setEditing({ ...editing, sortOrder: Number(e.target.value) || 0 })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" + /> +
+
+ + +
+
+ +
+ + +
+ +
+ + setEditing({ + ...editing, + translations: { + ...editing.translations, + [activeLang]: { ...editing.translations[activeLang], label: e.target.value }, + }, + })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" + /> +
+ +
+ +