From b7004b63a37b77ffa2372e8e34649726168d6491 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 18:47:24 +0700 Subject: [PATCH] x --- apps/admin/package.json | 2 + apps/admin/src/components/AdminLayout.tsx | 28 +- .../src/components/ContentLanguageTabs.tsx | 96 ++ .../admin/src/components/LanguageSelector.tsx | 117 ++ apps/admin/src/components/Sidebar.tsx | 430 +++--- .../analytics/OmnichannelDistrictModal.tsx | 45 +- .../analytics/ProductDistrictModal.tsx | 45 +- .../tiktok-map/DistrictMapModal.tsx | 84 +- .../components/tiktok-map/ThailandMapSvg.tsx | 103 +- apps/admin/src/i18n/index.ts | 37 + apps/admin/src/locales/en/translation.json | 1207 +++++++++++++++++ apps/admin/src/locales/ja/translation.json | 1206 ++++++++++++++++ apps/admin/src/locales/th/translation.json | 1206 ++++++++++++++++ apps/admin/src/locales/zh/translation.json | 1207 +++++++++++++++++ apps/admin/src/main.tsx | 1 + apps/admin/src/pages/ArticleEditPage.tsx | 8 +- apps/admin/src/pages/CategoriesPage.tsx | 97 +- apps/admin/src/pages/DashboardPage.tsx | 104 +- apps/admin/src/pages/GlobalAnalyticsPage.tsx | 101 +- apps/admin/src/pages/LazadaAnalyticsPage.tsx | 149 +- apps/admin/src/pages/LazadaOrderPage.tsx | 70 +- apps/admin/src/pages/LazadaOverviewPage.tsx | 125 +- apps/admin/src/pages/LoginPage.tsx | 26 +- apps/admin/src/pages/OrderDetailPage.tsx | 67 +- apps/admin/src/pages/OrdersPage.tsx | 94 +- .../admin/src/pages/ProductAttributesPage.tsx | 388 +++--- apps/admin/src/pages/ProductEditPage.tsx | 35 +- apps/admin/src/pages/ProductsPage.tsx | 220 +-- apps/admin/src/pages/ShopeeAnalyticsPage.tsx | 190 ++- apps/admin/src/pages/ShopeeOverviewPage.tsx | 127 +- apps/admin/src/pages/ShopeeSalesMapPage.tsx | 144 +- apps/admin/src/pages/ShopeeShopCasePage.tsx | 127 +- apps/admin/src/pages/TiktokAnalyticsPage.tsx | 153 +-- .../admin/src/pages/TiktokOrderDetailPage.tsx | 148 +- apps/admin/src/pages/TiktokOrderPage.tsx | 288 ++-- apps/admin/src/pages/TiktokOverviewPage.tsx | 101 +- .../src/pages/TiktokPriceSimulatorPage.tsx | 308 ++--- apps/admin/src/pages/TiktokSalesMapPage.tsx | 136 +- apps/admin/src/pages/TiktokShopCasePage.tsx | 145 +- .../analytics/OmnichannelSalesMapPage.tsx | 152 ++- .../pages/analytics/ProductSalesMapPage.tsx | 172 +-- .../pages/tools/MarketplaceProductsPage.tsx | 248 ++-- apps/admin/src/utils/thaiGeoUtils.ts | 222 +++ bun.lock | 2 + 44 files changed, 7975 insertions(+), 1986 deletions(-) create mode 100644 apps/admin/src/components/ContentLanguageTabs.tsx create mode 100644 apps/admin/src/components/LanguageSelector.tsx create mode 100644 apps/admin/src/i18n/index.ts create mode 100644 apps/admin/src/locales/en/translation.json create mode 100644 apps/admin/src/locales/ja/translation.json create mode 100644 apps/admin/src/locales/th/translation.json create mode 100644 apps/admin/src/locales/zh/translation.json diff --git a/apps/admin/package.json b/apps/admin/package.json index 9a14f7c..9f52aab 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,10 +15,12 @@ "@types/papaparse": "^5.5.2", "@uiw/react-md-editor": "^4.1.1", "d3-geo": "^3.1.1", + "i18next": "^26.0.4", "lucide-react": "^1.7.0", "papaparse": "^5.5.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.2", "react-quill-new": "^3.8.3", "react-router-dom": "^7.14.0", "recharts": "^3.8.1", diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx index 036eef3..12c926a 100644 --- a/apps/admin/src/components/AdminLayout.tsx +++ b/apps/admin/src/components/AdminLayout.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Outlet } from 'react-router-dom' import Sidebar from './Sidebar' +import LanguageSelector from './LanguageSelector' export default function AdminLayout() { const [isCollapsed, setIsCollapsed] = useState(false) @@ -8,11 +9,28 @@ export default function AdminLayout() { return (
setIsCollapsed(!isCollapsed)} /> -
-
- -
-
+
+ {/* Top Header Bar */} +
+
+
+ + HomAroma Control Center +
+
+ +
+ +
+
+ + {/* Main Content Area */} +
+
+ +
+
+
) } diff --git a/apps/admin/src/components/ContentLanguageTabs.tsx b/apps/admin/src/components/ContentLanguageTabs.tsx new file mode 100644 index 0000000..782acd4 --- /dev/null +++ b/apps/admin/src/components/ContentLanguageTabs.tsx @@ -0,0 +1,96 @@ +import { Copy } from 'lucide-react' + +export interface LanguageOption { + code: string + label: string + flag: string + nativeName: string +} + +export const DEFAULT_CONTENT_LANGUAGES: LanguageOption[] = [ + { code: 'th', label: 'TH', nativeName: 'ภาษาไทย', flag: '🇹🇭' }, + { code: 'en', label: 'EN', nativeName: 'English', flag: '🇬🇧' }, + { code: 'ja', label: 'JA', nativeName: '日本語', flag: '🇯🇵' }, + { code: 'zh', label: 'ZH', nativeName: '简体中文', flag: '🇨🇳' }, +] + +interface ContentLanguageTabsProps { + activeLang: string + onSelectLang: (lang: string) => void + translationsMap?: Record + onCopyFrom?: (fromLang: string) => void + languages?: LanguageOption[] + className?: string +} + +export default function ContentLanguageTabs({ + activeLang, + onSelectLang, + translationsMap = {}, + onCopyFrom, + languages = DEFAULT_CONTENT_LANGUAGES, + className = '', +}: ContentLanguageTabsProps) { + return ( +
+ {/* Language Tabs */} +
+ {languages.map((lang) => { + const isActive = activeLang === lang.code + const rawVal = translationsMap[lang.code] + const isTranslated = + rawVal !== undefined && + rawVal !== null && + (typeof rawVal === 'string' ? rawVal.trim().length > 0 : Object.keys(rawVal).length > 0) + + return ( + + ) + })} +
+ + {/* Quick Action: Copy from other lang */} + {onCopyFrom && ( +
+ + Copy from: + {languages + .filter((l) => l.code !== activeLang) + .map((lang) => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/admin/src/components/LanguageSelector.tsx b/apps/admin/src/components/LanguageSelector.tsx new file mode 100644 index 0000000..f6eb86c --- /dev/null +++ b/apps/admin/src/components/LanguageSelector.tsx @@ -0,0 +1,117 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Globe, ChevronDown, Check } from 'lucide-react' +import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from '../i18n' + +interface LanguageSelectorProps { + isCompact?: boolean + className?: string +} + +export default function LanguageSelector({ isCompact = false, className = '' }: LanguageSelectorProps) { + const { i18n } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + const currentLang = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) || SUPPORTED_LANGUAGES[0] + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleSelectLanguage = (code: SupportedLanguageCode) => { + i18n.changeLanguage(code) + localStorage.setItem('admin_lang', code) + setIsOpen(false) + } + + if (isCompact) { + return ( +
+ + + {isOpen && ( +
+
+ Select Language +
+ {SUPPORTED_LANGUAGES.map((lang) => { + const isSelected = i18n.language === lang.code + return ( + + ) + })} +
+ )} +
+ ) + } + + return ( +
+ + + {isOpen && ( +
+
+ Language / ภาษา +
+ {SUPPORTED_LANGUAGES.map((lang) => { + const isSelected = i18n.language === lang.code + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 3ebeda0..b7d493a 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { NavLink, useNavigate, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { LayoutDashboard, Package, @@ -36,105 +37,16 @@ import { Map, } from 'lucide-react' -const MAIN_MENU = [ - { icon: LayoutDashboard, label: 'Dashboard', to: '/' }, -] - -const PRODUCT_MENU = [ - { 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' }, -] - -const SALES_MENU = [ - { icon: ShoppingCart, label: 'Orders', to: '/orders' }, - { icon: Ticket, label: 'Promotions', to: '/promotions' }, - { icon: Ticket, label: 'Coupons', to: '/coupons' }, - { icon: Globe2, label: 'All Analytics', to: '/global-analytics' }, -] - -const BUSINESS_ANALYTICS_MENU = [ - { icon: Globe2, label: 'Omnichannel Sales Map', to: '/business-analytics/sales-map' }, - { icon: Package, label: 'Product Sales Map', to: '/business-analytics/sales-map-products' }, -] - -const TOOL_MENU = [ - { icon: Package, label: 'Marketplace Products', to: '/tools/marketplace-products' }, -] - -const TIKTOK_MENU = [ - { icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' }, - { icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' }, - { icon: BarChart3, label: 'TikTok Analytics', to: '/tiktok-analytics' }, - { icon: Layers, label: 'TikTok ทุกร้าน', to: '/tiktok-overview' }, - { icon: Calculator, label: 'TikTok Price Simulator', to: '/tiktok-price-simulator' }, - { icon: Info, label: 'TikTok Shop Case', to: '/tiktok-shop-case' }, -] - -const TIKTOK_LEGACY_MENU = [ - { icon: TrendingUp, label: 'TikTok Report Import', to: '/tiktok' }, - { icon: TrendingUp, label: 'Product SKU Analysis', to: '/tiktok-analysis' }, -] - -const SHOPEE_MENU = [ - { icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' }, - { icon: Map, label: 'Thailand Sales Map', to: '/shopee-sales-map' }, - { icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' }, - { icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' }, - { icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' }, -] - -const LAZADA_MENU = [ - { icon: ShoppingCart, label: 'Lazada Orders', to: '/lazada-order' }, - { icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' }, - { icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' }, -] - -const CMS_MENU = [ - { icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' }, - { icon: Sparkles, label: 'CMS Block Showcase', to: '/cms-mockup' }, - { icon: ImageIcon, label: 'Media Library', to: '/media' }, - { icon: BookOpen, label: 'Scent Guide', to: '/articles' }, -] - -const CUSTOMER_MENU = [ - { icon: Users, label: 'LINE Customers', to: '/customers' }, - { icon: Users, label: 'Subscribers', to: '/subscribers' }, - { icon: MessageSquare, label: 'Contact Messages', to: '/contacts' }, -] - -const MENU_MANAGER_MENU = [ - { icon: Layers, label: 'Main Navigation', to: '/menu-manager/main-navigation' }, - { icon: FileText, label: 'Footer Explore', to: '/menu-manager/footer-explore' }, - { icon: MessageSquare, label: 'Footer Support', to: '/menu-manager/footer-support' }, - { icon: Store, label: 'Footer Brand', to: '/menu-manager/footer-brand' }, -] - -const BACKUP_MENU = [ - { icon: DatabaseBackup, label: 'Database Backup', to: '/backup/database' }, - { icon: Package, label: 'Catalog Export', to: '/backup/catalog' }, - { icon: ShoppingCart, label: 'Orders Export', to: '/backup/orders' }, - { icon: FileJson, label: 'CMS Export', to: '/backup/cms' }, - { icon: FolderArchive, label: 'Media Files', to: '/backup/media' }, -] - -const SETTINGS_MENU = [ - { icon: Store, label: 'Store Settings', to: '/settings' }, - { icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' }, - { icon: Palette, label: 'Theme & Design', to: '/settings/theme' }, - { icon: FileText, label: 'Content', to: '/settings/content' }, - { icon: Wrench, label: 'System', to: '/settings/system' }, -] - interface SidebarProps { isCollapsed: boolean onToggle: () => void } export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { + const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() + const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p)) const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p)) const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') @@ -163,6 +75,97 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const [backupOpen, setBackupOpen] = useState(isInBackup) const [settingsOpen, setSettingsOpen] = useState(isInSettings) + const MAIN_MENU = [ + { icon: LayoutDashboard, label: t('nav.dashboard', 'Dashboard'), to: '/' }, + ] + + const PRODUCT_MENU = [ + { icon: Tags, label: t('nav.categories', 'Categories'), to: '/categories' }, + { icon: Package, label: t('nav.products', 'Products'), to: '/products' }, + { icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' }, + { icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' }, + ] + + const SALES_MENU = [ + { icon: ShoppingCart, label: t('nav.orders', 'Orders'), to: '/orders' }, + { icon: Ticket, label: t('nav.promotions', 'Promotions'), to: '/promotions' }, + { icon: Ticket, label: t('nav.coupons', 'Coupons'), to: '/coupons' }, + { icon: Globe2, label: t('nav.globalAnalytics', 'All Analytics'), to: '/global-analytics' }, + ] + + const BUSINESS_ANALYTICS_MENU = [ + { icon: Globe2, label: t('nav.omnichannelSalesMap', 'Omnichannel Sales Map'), to: '/business-analytics/sales-map' }, + { icon: Package, label: t('nav.productSalesMap', 'Product Sales Map'), to: '/business-analytics/sales-map-products' }, + ] + + const TOOL_MENU = [ + { icon: Package, label: t('nav.marketplaceProducts', 'Marketplace Products'), to: '/tools/marketplace-products' }, + ] + + const TIKTOK_MENU = [ + { icon: ShoppingCart, label: t('nav.tiktokOrders', 'TikTok Orders'), to: '/tiktok-order' }, + { icon: Map, label: t('nav.thailandSalesMap', 'Thailand Sales Map'), to: '/tiktok-sales-map' }, + { icon: BarChart3, label: t('nav.tiktokAnalytics', 'TikTok Analytics'), to: '/tiktok-analytics' }, + { icon: Layers, label: t('nav.tiktokOverview', 'TikTok All Shops'), to: '/tiktok-overview' }, + { icon: Calculator, label: t('nav.tiktokPriceSimulator', 'TikTok Price Simulator'), to: '/tiktok-price-simulator' }, + { icon: Info, label: t('nav.tiktokShopCase', 'TikTok Shop Case'), to: '/tiktok-shop-case' }, + ] + + const TIKTOK_LEGACY_MENU = [ + { icon: TrendingUp, label: t('nav.tiktokReportImport', 'TikTok Report Import'), to: '/tiktok' }, + { icon: TrendingUp, label: t('nav.productSkuAnalysis', 'Product SKU Analysis'), to: '/tiktok-analysis' }, + ] + + const SHOPEE_MENU = [ + { icon: ShoppingBag, label: t('nav.shopeeHomAroma', 'Shopee (Hom Aroma)'), to: '/shopee' }, + { icon: Map, label: t('nav.shopeeSalesMap', 'Thailand Sales Map'), to: '/shopee-sales-map' }, + { icon: BarChart3, label: t('nav.shopeeAnalytics', 'Shopee Analytics'), to: '/shopee-analytics' }, + { icon: Layers, label: t('nav.shopeeOverview', 'Shopee All Shops'), to: '/shopee-overview' }, + { icon: Info, label: t('nav.shopeeShopCase', 'Shopee Shop Case'), to: '/shopee-shop-case' }, + ] + + const LAZADA_MENU = [ + { icon: ShoppingCart, label: t('nav.lazadaOrders', 'Lazada Orders'), to: '/lazada-order' }, + { icon: BarChart3, label: t('nav.lazadaAnalytics', 'Lazada Analytics'), to: '/lazada-analytics' }, + { icon: Layers, label: t('nav.lazadaOverview', 'Lazada All Shops'), to: '/lazada-overview' }, + ] + + const CMS_MENU = [ + { icon: Layers, label: t('nav.pageBuilder', 'Page Builder (CMS)'), to: '/page-builder' }, + { icon: Sparkles, label: t('nav.cmsBlockShowcase', 'CMS Block Showcase'), to: '/cms-mockup' }, + { icon: ImageIcon, label: t('nav.mediaLibrary', 'Media Library'), to: '/media' }, + { icon: BookOpen, label: t('nav.scentGuide', 'Scent Guide'), to: '/articles' }, + ] + + const CUSTOMER_MENU = [ + { icon: Users, label: t('nav.customerList', 'Customers'), to: '/customers' }, + { icon: Users, label: t('nav.newsletterSubscribers', 'Subscribers'), to: '/subscribers' }, + { icon: MessageSquare, label: t('nav.contactMessages', 'Contact Messages'), to: '/contacts' }, + ] + + const MENU_MANAGER_MENU = [ + { icon: Layers, label: t('nav.mainNavigation', 'Main Navigation'), to: '/menu-manager/main-navigation' }, + { icon: FileText, label: t('nav.footerExplore', 'Footer Explore'), to: '/menu-manager/footer-explore' }, + { icon: MessageSquare, label: t('nav.footerSupport', 'Footer Support'), to: '/menu-manager/footer-support' }, + { icon: Store, label: t('nav.footerBrand', 'Footer Brand'), to: '/menu-manager/footer-brand' }, + ] + + const BACKUP_MENU = [ + { icon: DatabaseBackup, label: t('nav.backupDatabase', 'Database Backup'), to: '/backup/database' }, + { icon: Package, label: t('nav.backupCatalog', 'Catalog Backup'), to: '/backup/catalog' }, + { icon: ShoppingCart, label: t('nav.backupOrders', 'Orders Backup'), to: '/backup/orders' }, + { icon: FileJson, label: t('nav.backupCms', 'CMS Pages Backup'), to: '/backup/cms' }, + { icon: FolderArchive, label: t('nav.backupMedia', 'Media Backup'), to: '/backup/media' }, + ] + + const SETTINGS_MENU = [ + { icon: Store, label: t('nav.storeSettings', 'Store Settings'), to: '/settings' }, + { icon: CreditCard, label: t('nav.paymentSettings', 'Payment & Shipping'), to: '/settings/payments' }, + { icon: Palette, label: t('nav.themeSettings', 'Theme & Design'), to: '/settings/theme' }, + { icon: FileText, label: t('nav.contentSettings', 'Content'), to: '/settings/content' }, + { icon: Wrench, label: t('nav.systemMaintenance', 'System'), to: '/settings/system' }, + ] + const handleLogout = () => { localStorage.removeItem('homaroma_admin_token') localStorage.removeItem('homaroma_admin_user') @@ -189,7 +192,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { )} @@ -248,11 +251,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
@@ -278,11 +281,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
@@ -308,17 +311,107 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{!isCollapsed && toolsOpen && (
{TOOL_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ + {/* TikTok Channel Group */} +
+ + + {!isCollapsed && tiktokOpen && ( +
+ {TIKTOK_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ + {/* TikTok Report / Analysis (Legacy) */} +
+ + + {!isCollapsed && tiktokLegacyOpen && ( +
+ {TIKTOK_LEGACY_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ + {/* Shopee Channel Group */} +
+ + + {!isCollapsed && shopeeOpen && ( +
+ {SHOPEE_MENU.map((item) => ( - {/* TikTok Group */} -
- - - {!isCollapsed && tiktokOpen && ( -
- {TIKTOK_MENU.map((item) => ( - - `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` - } - > - - {item.label} - - ))} -
- )} -
- - {/* TikTok Legacy Group */} -
- - - {!isCollapsed && tiktokLegacyOpen && ( -
- {TIKTOK_LEGACY_MENU.map((item) => ( - - `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` - } - > - - {item.label} - - ))} -
- )} -
- - {/* Shopee Group */} -
- - - {!isCollapsed && shopeeOpen && ( -
- {SHOPEE_MENU.map((item) => ( - - `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` - } - > - - {item.label} - - ))} -
- )} -
- - {/* Lazada Group */} + {/* Lazada Channel Group */}
@@ -446,7 +449,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` } > - + {item.label} ))} @@ -454,15 +457,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { )}
- {/* Content (CMS) Group */} + {/* CMS Group */}
@@ -484,15 +487,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { )}
- {/* Customers & Messages Group */} + {/* Customers Group */}
@@ -518,11 +521,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
@@ -548,11 +551,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
@@ -578,11 +581,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
@@ -608,25 +611,26 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { {/* User / Bottom */}
-
+ +
A
{!isCollapsed && (
-

Admin Account

-

System Manager

+

{t('common.adminAccount', 'Admin Account')}

+

{t('common.systemManager', 'System Manager')}

)}
diff --git a/apps/admin/src/components/analytics/OmnichannelDistrictModal.tsx b/apps/admin/src/components/analytics/OmnichannelDistrictModal.tsx index 86d147f..bacd3f4 100644 --- a/apps/admin/src/components/analytics/OmnichannelDistrictModal.tsx +++ b/apps/admin/src/components/analytics/OmnichannelDistrictModal.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, useMemo, useRef } from 'react'; import * as d3Geo from 'd3-geo'; import { X, Search, RotateCcw, MapPin, ExternalLink } from 'lucide-react'; -import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; +import { useTranslation } from 'react-i18next'; +import { fmtBaht, fmtNum, getDistrictColor, getLocalizedProvinceName, getLocalizedDistrictName } from '../../utils/thaiGeoUtils'; import { API_BASE } from '@homaroma/types'; import { Link } from 'react-router-dom'; @@ -28,6 +29,7 @@ export default function OmnichannelDistrictModal({ dateTo, onClose, }: OmnichannelDistrictModalProps) { + const { t, i18n } = useTranslation(); const [districtGeo, setDistrictGeo] = useState(null); const [loadingMap, setLoadingMap] = useState(true); const [loadingData, setLoadingData] = useState(true); @@ -118,6 +120,7 @@ export default function OmnichannelDistrictModal({ return provinceDistrictsGeo.map((feature: any) => { const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim(); + const dEn = feature.properties.amp_en || ''; const pathData = generator(feature); const sales = districtSalesMap.get(dName); const revenue = sales?.revenue || 0; @@ -131,6 +134,7 @@ export default function OmnichannelDistrictModal({ feature, pathData, districtName: dName, + districtEn: dEn, revenue, orders, tiktokRevenue, @@ -209,13 +213,15 @@ export default function OmnichannelDistrictModal({
-

จังหวัด{provinceName}

+

+ {i18n.language === 'th' ? `จังหวัด${provinceName}` : getLocalizedProvinceName(provinceName, i18n.language)} +

Omnichannel (TikTok + Shopee + Lazada)

- วิเคราะห์ยอดขายแยกรายอำเภอ และตรวจสอบคำสั่งซื้อข้ามแพลตฟอร์ม + {t('salesMap.subtitle', 'วิเคราะห์ยอดขายแยกรายอำเภอ และตรวจสอบคำสั่งซื้อข้ามแพลตฟอร์ม')}

@@ -233,9 +239,8 @@ export default function OmnichannelDistrictModal({
- แผนที่รายอำเภอ ({paths.length} อำเภอ) + {t('salesMap.districtBreakdown', 'แผนที่รายอำเภอ')} ({paths.length}) - คลิกที่อำเภอเพื่อเลือกหลายพื้นที่
{loadingMap || loadingData ? (
- ⏳ กำลังโหลดแผนที่อำเภอ... + {t('salesMap.loadingDistricts', 'กำลังโหลดแผนที่อำเภอ...')}
) : paths.length === 0 ? (
- ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ + {t('salesMap.noData', 'ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้')}
) : ( @@ -285,10 +290,10 @@ export default function OmnichannelDistrictModal({ >

- อ.{hoveredDistrict.districtName} + {getLocalizedDistrictName(hoveredDistrict.districtName, hoveredDistrict.districtEn, i18n.language)}

- {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.orders} ออเดอร์) + {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.orders} {t('salesMap.orders', 'ออเดอร์')})

{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
@@ -315,7 +320,7 @@ export default function OmnichannelDistrictModal({
- เลือกอยู่ + Active
@@ -334,7 +339,7 @@ export default function OmnichannelDistrictModal({ : 'bg-white/5 text-slate-400 border-white/5 hover:text-white' }`} > - ทุกช่องทาง + {t('salesMap.allChannels', 'ทุกช่องทาง')} )}
@@ -381,12 +386,12 @@ export default function OmnichannelDistrictModal({ {/* KPI Cards for Selection */}
-

ยอดขายรวม

+

{t('salesMap.totalRevenue', 'ยอดขายรวม')}

{fmtBaht(selectionSummary.revenue)}

-

คำสั่งซื้อ

-

{selectionSummary.orders} ออเดอร์

+

{t('salesMap.totalOrders', 'คำสั่งซื้อ')}

+

{selectionSummary.orders} {t('salesMap.orders', 'ออเดอร์')}

TikTok

@@ -405,13 +410,13 @@ export default function OmnichannelDistrictModal({ {/* District Tags */} {selectedDistricts.size > 0 && (
- กรองเฉพาะ: + Filter: {Array.from(selectedDistricts).map((dName) => ( - อ.{dName} + {getLocalizedDistrictName(dName, undefined, i18n.language)} @@ -425,7 +430,7 @@ export default function OmnichannelDistrictModal({ setSearchQuery(e.target.value)} className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40" @@ -436,10 +441,10 @@ export default function OmnichannelDistrictModal({ {/* Orders Table */}
{loadingData ? ( -
กำลังโหลดข้อมูลคำสั่งซื้อ...
+
{t('salesMap.loadingMap', 'กำลังโหลดข้อมูลคำสั่งซื้อ...')}
) : filteredOrders.length === 0 ? (
- ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก + {t('salesMap.noData', 'ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก')}
) : (
diff --git a/apps/admin/src/components/analytics/ProductDistrictModal.tsx b/apps/admin/src/components/analytics/ProductDistrictModal.tsx index 5704b58..76ea9d6 100644 --- a/apps/admin/src/components/analytics/ProductDistrictModal.tsx +++ b/apps/admin/src/components/analytics/ProductDistrictModal.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, useMemo, useRef } from 'react'; import * as d3Geo from 'd3-geo'; import { X, Search, RotateCcw, MapPin, ExternalLink, Package } from 'lucide-react'; -import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; +import { useTranslation } from 'react-i18next'; +import { fmtBaht, fmtNum, getDistrictColor, getLocalizedProvinceName, getLocalizedDistrictName } from '../../utils/thaiGeoUtils'; import { API_BASE } from '@homaroma/types'; import { Link } from 'react-router-dom'; @@ -30,6 +31,7 @@ export default function ProductDistrictModal({ dateTo, onClose, }: ProductDistrictModalProps) { + const { t, i18n } = useTranslation(); const [districtGeo, setDistrictGeo] = useState(null); const [loadingMap, setLoadingMap] = useState(true); const [loadingData, setLoadingData] = useState(true); @@ -119,6 +121,7 @@ export default function ProductDistrictModal({ return provinceDistrictsGeo.map((feature: any) => { const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim(); + const dEn = feature.properties.amp_en || ''; const pathData = generator(feature); const sales = districtSalesMap.get(dName); const revenue = sales?.revenue || 0; @@ -133,6 +136,7 @@ export default function ProductDistrictModal({ feature, pathData, districtName: dName, + districtEn: dEn, revenue, unitsSold, orders, @@ -210,14 +214,16 @@ export default function ProductDistrictModal({
-

จังหวัด{provinceName}

+

+ {i18n.language === 'th' ? `จังหวัด${provinceName}` : getLocalizedProvinceName(provinceName, i18n.language)} +

{productName} {variantName ? `(${variantName})` : ''}

- สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม + {t('salesMap.productSubtitle', 'สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม')}

@@ -235,9 +241,8 @@ export default function ProductDistrictModal({
- แผนที่รายอำเภอ ({paths.length} อำเภอ) + {t('salesMap.districtBreakdown', 'แผนที่รายอำเภอ')} ({paths.length}) - คลิกที่อำเภอเพื่อเลือกหลายพื้นที่
{loadingMap || loadingData ? (
- ⏳ กำลังโหลดแผนที่อำเภอ... + {t('salesMap.loadingDistricts', 'กำลังโหลดแผนที่อำเภอ...')}
) : paths.length === 0 ? (
- ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ + {t('salesMap.noData', 'ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้')}
) : ( @@ -287,10 +292,10 @@ export default function ProductDistrictModal({ >

- อ.{hoveredDistrict.districtName} + {getLocalizedDistrictName(hoveredDistrict.districtName, hoveredDistrict.districtEn, i18n.language)}

- {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.unitsSold} ชิ้น) + {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.unitsSold} {t('salesMap.unitsSold', 'ชิ้น')})

{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
@@ -317,7 +322,7 @@ export default function ProductDistrictModal({
- เลือกอยู่ + Active
@@ -336,7 +341,7 @@ export default function ProductDistrictModal({ : 'bg-white/5 text-slate-400 border-white/5 hover:text-white' }`} > - ทุกช่องทาง + {t('salesMap.allChannels', 'ทุกช่องทาง')} )}
@@ -383,12 +388,12 @@ export default function ProductDistrictModal({ {/* KPI Cards for Selection */}
-

ยอดขายรวม

+

{t('salesMap.totalRevenue', 'ยอดขายรวม')}

{fmtBaht(selectionSummary.revenue)}

-

จำนวนชิ้นที่ขาย

-

{selectionSummary.units} ชิ้น

+

{t('salesMap.unitsSold', 'จำนวนชิ้นที่ขาย')}

+

{selectionSummary.units} {t('salesMap.unitsSold', 'ชิ้น')}

TikTok

@@ -407,13 +412,13 @@ export default function ProductDistrictModal({ {/* District Tags */} {selectedDistricts.size > 0 && (
- กรองเฉพาะ: + Filter: {Array.from(selectedDistricts).map((dName) => ( - อ.{dName} + {getLocalizedDistrictName(dName, undefined, i18n.language)} @@ -427,7 +432,7 @@ export default function ProductDistrictModal({ setSearchQuery(e.target.value)} className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" @@ -438,10 +443,10 @@ export default function ProductDistrictModal({ {/* Orders Table */}
{loadingData ? ( -
กำลังโหลดข้อมูลคำสั่งซื้อ...
+
{t('salesMap.loadingMap', 'กำลังโหลดข้อมูลคำสั่งซื้อ...')}
) : filteredOrders.length === 0 ? (
- ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก + {t('salesMap.noData', 'ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก')}
) : (
diff --git a/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx b/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx index 18b3d32..c28c273 100644 --- a/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx +++ b/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, useMemo, useRef } from 'react'; import * as d3Geo from 'd3-geo'; import { X, Search, Package, ShoppingBag, RotateCcw, MapPin, Tag } from 'lucide-react'; -import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; +import { useTranslation } from 'react-i18next'; +import { fmtBaht, fmtNum, getDistrictColor, getLocalizedDistrictName, getLocalizedProvinceName, getLocalizedRegionName } from '../../utils/thaiGeoUtils'; import { API_BASE } from '@homaroma/types'; interface DistrictMapModalProps { @@ -23,6 +24,7 @@ export default function DistrictMapModal({ apiEndpoint = '/tiktok-order/sales-map/district', onClose, }: DistrictMapModalProps) { + const { t, i18n } = useTranslation(); const [districtGeo, setDistrictGeo] = useState(null); const [loadingMap, setLoadingMap] = useState(true); const [loadingData, setLoadingData] = useState(true); @@ -182,6 +184,9 @@ export default function DistrictMapModal({ return sum; }, [selectedDistricts, districtSalesMap]); + const locProvinceName = getLocalizedProvinceName(provinceName, i18n.language); + const locRegionName = getLocalizedRegionName(districtData?.province?.regionTh, i18n.language); + return (
@@ -190,19 +195,19 @@ export default function DistrictMapModal({
- จังหวัดที่เลือก • {districtData?.province?.regionTh || 'ภาคอีสาน'} + {t('districtModal.selectedProvinceRegion', { region: locRegionName || '' })}

- แผนที่อำเภอ & คำสั่งซื้อ {platformName} • {provinceName} + {t('districtModal.title', { platform: platformName, province: locProvinceName })}

- คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก) + {t('districtModal.subtitle')}

@@ -216,10 +221,10 @@ export default function DistrictMapModal({
- แผนที่อำเภอ ({provinceFeatures.length} อำเภอ) + {t('districtModal.districtMapTitle', { count: provinceFeatures.length })}
- คลิกเพื่อเลือกหลายพื้นที่ + {t('districtModal.multiSelectHint')}
- กำลังเตรียมแผนที่อำเภอ... + {t('districtModal.loadingMap')}
) : provinceFeatures.length === 0 ? (
- ไม่พบข้อมูลพิกัดอำเภอของจังหวัด {provinceName} + {t('districtModal.geoNotFound', { province: locProvinceName })}
) : ( @@ -277,18 +282,18 @@ export default function DistrictMapModal({ }} > - อ.{hoveredDistrict.properties.amp_th} + {t('tiktokOrders.districtPrefix')}{getLocalizedDistrictName(hoveredDistrict.properties.amp_th, hoveredDistrict.properties.amp_en, i18n.language)}
- ยอดขาย: + {t('districtModal.revenueLabel')} {fmtBaht(districtSalesMap.get(hoveredDistrict.properties.amp_th)?.revenue || 0)}
- คำสั่งซื้อ: + {t('districtModal.ordersLabel')} - {districtSalesMap.get(hoveredDistrict.properties.amp_th)?.orders || 0} ออเดอร์ + {t('districtModal.ordersCount', { count: districtSalesMap.get(hoveredDistrict.properties.amp_th)?.orders || 0 })}
@@ -298,17 +303,17 @@ export default function DistrictMapModal({ {/* Bottom Scale & Attribution */}
- ยอดน้อย + {t('districtModal.lowSales')}
- ยอดมาก + {t('districtModal.highSales')}
- เลือกแล้ว + {t('districtModal.selected')}

- ขอบเขต ADM2: mapthai / OCHA CODs - CC BY 4.0 + {t('districtModal.attribution')}

@@ -317,16 +322,16 @@ export default function DistrictMapModal({

- {selectedDistricts.size} อำเภอที่เลือก + {t('districtModal.selectedDistrictsCount', { count: selectedDistricts.size })}

-

แตะพื้นที่เพื่อดูข้อมูล

+

{t('districtModal.touchHint')}

{selectedDistricts.size > 0 && ( )}
@@ -335,12 +340,13 @@ export default function DistrictMapModal({ {selectedDistricts.size === 0 ? (
-

ยังไม่ได้เลือกอำเภอ

-

คลิกบนแผนที่เพื่อกรองข้อมูลเฉพาะอำเภอ

+

{t('districtModal.noDistrictsSelected')}

+

{t('districtModal.clickMapToFilter')}

) : ( Array.from(selectedDistricts).map((dist) => { const st = districtSalesMap.get(dist) || { revenue: 0, orders: 0 }; + const locDist = getLocalizedDistrictName(dist, undefined, i18n.language); return (
- อ.{dist} + {t('tiktokOrders.districtPrefix')}{locDist} - ({st.orders} ออเดอร์) + {t('districtModal.ordersCountSub', { orders: st.orders })}
@@ -359,7 +365,7 @@ export default function DistrictMapModal({ @@ -372,7 +378,7 @@ export default function DistrictMapModal({ {selectedDistricts.size > 0 && (
- รวมยอดที่เลือก: + {t('districtModal.totalSelectedRevenue')} {fmtBaht(selectedDistrictsRevenue)} @@ -386,12 +392,12 @@ export default function DistrictMapModal({
- คำสั่งซื้อ {platformName} ในพื้นที่ ({filteredOrders.length}) + {t('districtModal.platformOrdersTitle', { platform: platformName, count: filteredOrders.length })}
{selectedDistricts.size > 0 && ( - กรอง {selectedDistricts.size} อำเภอ + {t('districtModal.filterDistrictsBadge', { count: selectedDistricts.size })} )}
@@ -403,7 +409,7 @@ export default function DistrictMapModal({ type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder="ค้นหา Order ID, สินค้า, SKU..." + placeholder={t('districtModal.searchOrdersPlaceholder')} className="w-full bg-slate-950/80 border border-white/10 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/50" />
@@ -413,7 +419,7 @@ export default function DistrictMapModal({ {filteredOrders.length === 0 ? (
-

ไม่พบรายการคำสั่งซื้อในเงื่อนไขที่เลือก

+

{t('districtModal.noOrdersFound')}

) : ( filteredOrders.map((o: any) => ( @@ -431,7 +437,7 @@ export default function DistrictMapModal({
- {o.items?.[0]?.productName || 'คำสั่งซื้อสินค้า'} + {o.items?.[0]?.productName || t('districtModal.defaultItemTitle')} {o.items?.[0]?.variation && ( ({o.items[0].variation}) @@ -442,7 +448,7 @@ export default function DistrictMapModal({
- อ.{o.district || 'ไม่ระบุ'} + {t('tiktokOrders.districtPrefix')}{getLocalizedDistrictName(o.district, undefined, i18n.language) || o.district || t('tiktokOrderDetail.unspecified')} {o.createdTime?.slice(0, 16) || ''} @@ -459,27 +465,27 @@ export default function DistrictMapModal({
- ยอดรวมทั้งจังหวัด:{' '} + {t('districtModal.provinceTotalLabel')}{' '} {fmtBaht(districtData?.summary?.totalRevenue || 0)} {' '} - ({fmtNum(districtData?.summary?.totalOrders || 0)} คำสั่งซื้อ) + {t('districtModal.ordersCountParen', { count: fmtNum(districtData?.summary?.totalOrders || 0) })} • - คำสั่งซื้อทั้งหมดที่ดึงมา:{' '} + {t('districtModal.totalFetchedOrdersLabel')}{' '} {fmtNum(districtData?.orders?.length || 0)} {' '} - รายการ + {t('districtModal.itemsUnit')}
diff --git a/apps/admin/src/components/tiktok-map/ThailandMapSvg.tsx b/apps/admin/src/components/tiktok-map/ThailandMapSvg.tsx index b0c4172..0928637 100644 --- a/apps/admin/src/components/tiktok-map/ThailandMapSvg.tsx +++ b/apps/admin/src/components/tiktok-map/ThailandMapSvg.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, useMemo, useRef } from 'react'; import * as d3Geo from 'd3-geo'; import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; -import { fmtBaht, fmtNum, getChoroplethColor } from '../../utils/thaiGeoUtils'; +import { useTranslation } from 'react-i18next'; +import { fmtBaht, fmtNum, getChoroplethColor, getLocalizedProvinceName, getLocalizedRegionName } from '../../utils/thaiGeoUtils'; interface ProvinceData { code: string; @@ -24,6 +25,7 @@ export default function ThailandMapSvg({ selectedProvince, onSelectProvince, }: ThailandMapSvgProps) { + const { t, i18n } = useTranslation(); const [geoData, setGeoData] = useState(null); const [loading, setLoading] = useState(true); const [hoveredFeature, setHoveredFeature] = useState(null); @@ -100,13 +102,6 @@ export default function ThailandMapSvg({ }; const handleMouseMove = (e: React.MouseEvent) => { - if (isDragging) { - setPan({ - x: e.clientX - dragStartRef.current.x, - y: e.clientY - dragStartRef.current.y, - }); - } - if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setTooltipPos({ @@ -114,9 +109,17 @@ export default function ThailandMapSvg({ y: e.clientY - rect.top, }); } + + if (!isDragging) return; + setPan({ + x: e.clientX - dragStartRef.current.x, + y: e.clientY - dragStartRef.current.y, + }); }; - const handleMouseUp = () => setIsDragging(false); + const handleMouseUp = () => { + setIsDragging(false); + }; if (loading) { return ( @@ -143,21 +146,21 @@ export default function ThailandMapSvg({
diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx index d5df34d..0cea99e 100644 --- a/apps/admin/src/pages/CategoriesPage.tsx +++ b/apps/admin/src/pages/CategoriesPage.tsx @@ -1,16 +1,18 @@ import { useState, useEffect } 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 MediaLibraryModal from '../components/MediaLibraryModal' +import ContentLanguageTabs from '../components/ContentLanguageTabs' export default function CategoriesPage() { + const { t, i18n } = useTranslation() const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) const [isModalOpen, setIsModalOpen] = useState(false) const [isUploading, setIsUploading] = useState(false) const [editingCategory, setEditingCategory] = useState | null>(null) const [activeLang, setActiveLang] = useState('th') - const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) @@ -48,7 +50,7 @@ export default function CategoriesPage() { body: JSON.stringify({ name: typeof editingCategory.name === 'string' ? editingCategory.name - : (editingCategory.name?.th || 'Untitled'), + : (editingCategory.name?.th || editingCategory.name?.en || 'Untitled'), imageUrl: editingCategory.imageUrl || null }) }) @@ -79,7 +81,7 @@ export default function CategoriesPage() { } const handleDelete = async (id: number) => { - if (!confirm('Are you sure you want to delete this category?')) return + if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this category?'))) return try { const res = await fetch(`${API_BASE}/categories/${id}`, { method: 'DELETE' @@ -137,7 +139,7 @@ export default function CategoriesPage() { const openAddModal = () => { setEditingCategory({ - name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '' } as any, + name: { th: '', en: '', ja: '', zh: '' } as any, imageUrl: '' }) setActiveLang('th') @@ -153,18 +155,37 @@ export default function CategoriesPage() { 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 getLocalizedCategoryName = (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 (
{!isModalOpen ? ( <>
-

Category Management

-

Managing scent classification and product groups.

+

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

+

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

@@ -187,13 +208,15 @@ export default function CategoriesPage() {
@@ -202,11 +225,11 @@ export default function CategoriesPage() {

- {typeof cat.name === 'string' ? cat.name : (cat.name?.th || cat.name?.en || 'Unnamed')} + {getLocalizedCategoryName(cat)}

- {cat._count?.products || 0} Products + {cat._count?.products || 0} {t('categories.itemCount', 'Products')}
@@ -222,11 +245,11 @@ export default function CategoriesPage() {

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

@@ -238,11 +261,11 @@ export default function CategoriesPage() { {/* Image Upload Area */}
- +
-
-
- -
- {languages.map(lang => ( - - ))} -
-
+ {/* Multilingual Name Field */} +
+ + +
@@ -316,16 +331,16 @@ export default function CategoriesPage() {
diff --git a/apps/admin/src/pages/DashboardPage.tsx b/apps/admin/src/pages/DashboardPage.tsx index 2716e9f..73cbeb9 100644 --- a/apps/admin/src/pages/DashboardPage.tsx +++ b/apps/admin/src/pages/DashboardPage.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { Package, Tag, Layers, TrendingUp } from 'lucide-react' import { API_BASE, type Category, type Product } from '@homaroma/types' export default function DashboardPage() { + const { t } = useTranslation() const [stats, setStats] = useState({ products: 0, categories: 0, @@ -61,18 +63,18 @@ export default function DashboardPage() { }, []) const cards = [ - { label: 'Total Revenue', value: `฿${stats.revenue.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' }, - { label: 'Total Orders', value: stats.orders, icon: Package, color: 'text-blue-400' }, - { label: 'Total Customers', value: stats.customers, icon: Tag, color: 'text-purple-400' }, - { label: 'Total Products', value: stats.products, icon: Layers, color: 'text-cyan-glow' }, + { label: t('dashboard.totalRevenue', 'Total Revenue'), value: `฿${stats.revenue.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' }, + { label: t('dashboard.totalOrders', 'Total Orders'), value: stats.orders, icon: Package, color: 'text-blue-400' }, + { label: t('dashboard.totalCustomers', 'Total Customers'), value: stats.customers, icon: Tag, color: 'text-purple-400' }, + { label: t('dashboard.totalProducts', 'Total Products'), value: stats.products, icon: Layers, color: 'text-cyan-glow' }, ] return (
-

System Overview

-

Real-time stats from the HomAroma platform.

+

{t('dashboard.title', 'System Overview')}

+

{t('dashboard.subtitle', 'Real-time stats from the HomAroma platform.')}

Last updated

@@ -101,53 +103,67 @@ export default function DashboardPage() { ))}
- {/* Placeholder for recent activities */} + {/* Analytics Highlights */}
-

Inventory Summary

+
+

{t('dashboard.salesTrend', 'Sales Channels')}

+ Live status +
- {/* Dummy chart-like bars */} - {[ - { label: 'Oil Ozone', val: 0.8 }, - { label: 'Aroma Oil', val: 0.6 }, - { label: 'Reed Diffuser', val: 0.4 }, - { label: 'Car Diffuser', val: 0.2 }, - ].map(i => ( -
-
- {i.label} - {Math.round(i.val * 100)}% Stock -
-
-
-
-
- ))} +
+ Website & SPA Direct + Active +
+
+ TikTok Shop Thailand + Integrated +
+
+ Shopee Mall + Connected +
+
+ Lazada Channel + Connected +
-

System Health

-
- {[ - { label: 'API Backend', status: 'Healthy', time: '12ms' }, - { label: 'Database Service', status: 'Healthy', time: '4ms' }, - { label: 'Admin App Bundle', status: 'Optimized', time: '128kb' }, - ].map(i => ( -
-
-

{i.label}

-

{i.status}

+
+

{t('common.language', 'Internationalization (4 Languages)')}

+ Online +
+
+
+
+ 🇹🇭 + ภาษาไทย (Thai)
-
-

Performance

-

{i.time}

+ Primary +
+
+
+ 🇬🇧 + English (UK/Global)
-
- ))} + Supported +
+
+
+ 🇯🇵 + 日本語 (Japanese) +
+ Supported +
+
+
+ 🇨🇳 + 简体中文 (Chinese) +
+ Supported +
diff --git a/apps/admin/src/pages/GlobalAnalyticsPage.tsx b/apps/admin/src/pages/GlobalAnalyticsPage.tsx index d506237..6c7bf8c 100644 --- a/apps/admin/src/pages/GlobalAnalyticsPage.tsx +++ b/apps/admin/src/pages/GlobalAnalyticsPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, - PieChart, Pie, Cell, Legend, + PieChart, Pie, Cell, } from 'recharts'; import { TrendingUp, DollarSign, Package, BadgePercent, ShoppingCart, Loader2, Store, Calendar, Globe2 @@ -9,7 +10,6 @@ import { const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); -// We adjust TikTok to Cyan/Pink mix or just Cyan, Lazada to Blue, Shopee to Orange. const COLORS = { Shopee: '#f97316', TikTok: '#06b6d4', @@ -32,17 +32,6 @@ function getPresetRange(preset: Preset): { from: string; to: string } { if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } return { from: '', to: '' }; } -const PRESETS: { key: Preset; label: string }[] = [ - { key: '7d', label: '7 วัน' }, - { key: '30d', label: '30 วัน' }, - { key: '90d', label: '90 วัน' }, - { key: 'thisMonth', label: 'เดือนนี้' }, - { key: 'lastMonth', label: 'เดือนที่แล้ว' }, - { key: 'year', label: 'ปีนี้' }, - { key: 'lastYear', label: 'ปีที่แล้ว' }, - { key: 'last12m', label: '12 เดือนที่ผ่านมา' }, - { key: 'all', label: 'ทั้งหมด' }, -]; function authHeaders() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; @@ -82,6 +71,7 @@ const CustomTooltip = ({ active, payload, label }: any) => { }; export default function GlobalAnalyticsPage() { + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -91,6 +81,18 @@ export default function GlobalAnalyticsPage() { const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); + const PRESETS: { key: Preset; label: string }[] = [ + { key: '7d', label: t('analytics.preset7d', '7 Days') }, + { key: '30d', label: t('analytics.preset30d', '30 Days') }, + { key: '90d', label: t('analytics.preset90d', '90 Days') }, + { key: 'thisMonth', label: t('analytics.presetThisMonth', 'This Month') }, + { key: 'lastMonth', label: t('analytics.presetLastMonth', 'Last Month') }, + { key: 'year', label: t('analytics.presetThisYear', 'This Year') }, + { key: 'lastYear', label: t('analytics.presetLastYear', 'Last Year') }, + { key: 'last12m', label: t('analytics.presetLast12m', 'Last 12 Months') }, + { key: 'all', label: t('analytics.presetAll', 'All Time') }, + ]; + const loadData = async (from: string, to: string) => { setLoading(true); setError(null); setData(null); const params = new URLSearchParams(); @@ -180,7 +182,7 @@ export default function GlobalAnalyticsPage() { return (
- กำลังรวมข้อมูลระดับ Global (ทุกแพลตฟอร์ม)... + {t('analytics.loadingGlobal', 'Aggregating global platform data...')}
); } @@ -189,7 +191,7 @@ export default function GlobalAnalyticsPage() { return (
-

{error || 'เกิดข้อผิดพลาดในการโหลดข้อมูล'}

+

{error || t('common.error', 'Error loading data')}

); } @@ -207,8 +209,8 @@ export default function GlobalAnalyticsPage() {
-

Global Overview

-

ภาพรวมทุกแพลตฟอร์ม

+

{t('analytics.globalOverview', 'Global Overview')}

+

{t('analytics.allPlatformsOverview', 'All Platforms Overview')}

@@ -221,7 +223,7 @@ export default function GlobalAnalyticsPage() {
@@ -260,9 +262,9 @@ export default function GlobalAnalyticsPage() {
)} @@ -270,10 +272,34 @@ export default function GlobalAnalyticsPage() { {/* Grand KPI Row */}
- - - - + + + +
{/* Revenue Share Pie + Comparison table */} @@ -283,7 +309,7 @@ export default function GlobalAnalyticsPage() {
-

สัดส่วนยอดขายตามแพลตฟอร์ม

+

{t('analytics.revenueShareByPlatform', 'Revenue Share by Platform')}

@@ -320,16 +346,16 @@ export default function GlobalAnalyticsPage() {
-

เปรียบเทียบแพลตฟอร์ม

+

{t('analytics.platformComparison', 'Platform Comparison')}

- - - - + + + + @@ -360,7 +386,7 @@ export default function GlobalAnalyticsPage() {
-

ยอดขายรายเดือน (แยกตามแพลตฟอร์ม)

+

{t('analytics.monthlyRevenueByPlatform', 'Monthly Revenue (by Platform)')}

{monthly.length > 0 ? (
@@ -370,9 +396,8 @@ export default function GlobalAnalyticsPage() { v >= 1000 ? `฿${(v / 1000).toFixed(0)}k` : `฿${v}`} /> } cursor={{ fill: '#ffffff06' }} /> - {platforms.map((p: any, i: number) => { - const isTop = i === platforms.length - 1; // Simplification + const isTop = i === platforms.length - 1; return ( ); @@ -381,7 +406,7 @@ export default function GlobalAnalyticsPage() {
) : ( -

ยังไม่มีข้อมูลรายเดือน

+

{t('analytics.noMonthlyData', 'No monthly data available')}

)}
diff --git a/apps/admin/src/pages/LazadaAnalyticsPage.tsx b/apps/admin/src/pages/LazadaAnalyticsPage.tsx index 572b94f..6e00d1a 100644 --- a/apps/admin/src/pages/LazadaAnalyticsPage.tsx +++ b/apps/admin/src/pages/LazadaAnalyticsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell, LineChart, Line, Legend, @@ -68,17 +69,7 @@ function getPresetRange(preset: Preset): { from: string; to: string } { return { from: '', to: '' }; } -const PRESETS: { key: Preset; label: string }[] = [ - { key: '7d', label: '7 วัน' }, - { key: '30d', label: '30 วัน' }, - { key: '90d', label: '90 วัน' }, - { key: 'thisMonth', label: 'เดือนนี้' }, - { key: 'lastMonth', label: 'เดือนที่แล้ว' }, - { key: 'year', label: 'ปีนี้' }, - { key: 'lastYear', label: 'ปีที่แล้ว' }, - { key: 'last12m', label: '12 เดือนที่ผ่านมา' }, - { key: 'all', label: 'ทั้งหมด' }, -]; +const PRESET_KEYS: Preset[] = ['7d', '30d', '90d', 'thisMonth', 'lastMonth', 'year', 'lastYear', 'last12m', 'all']; function StatCard({ label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5', @@ -112,6 +103,7 @@ const CustomTooltip = ({ active, payload, label }: any) => { }; export default function LazadaAnalyticsPage() { + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -166,7 +158,7 @@ export default function LazadaAnalyticsPage() { return (
- กำลังคำนวณข้อมูลวิเคราะห์ Lazada... + {t('lazadaAnalytics.loading', 'กำลังคำนวณข้อมูลวิเคราะห์ Lazada...')}
); } @@ -175,9 +167,9 @@ export default function LazadaAnalyticsPage() { return (
-

{error || 'ไม่มีข้อมูล — กรุณา Import ไฟล์ Lazada ก่อน'}

+

{error || t('lazadaAnalytics.noData', 'ไม่มีข้อมูล — กรุณา Import ไฟล์ Lazada ก่อน')}

- กลับหน้า Lazada Orders + {t('lazadaAnalytics.backToOrders', 'กลับหน้า Lazada Orders')}
); @@ -204,12 +196,11 @@ export default function LazadaAnalyticsPage() {
Lazada / - Analytics + {t('lazadaAnalytics.breadcrumb', 'Analytics')}

- วิเคราะห์ยอดขาย — - {shopCfg.label} + {t('lazadaAnalytics.title', { shop: shopCfg.label, defaultValue: `วิเคราะห์ยอดขาย — ${shopCfg.label}` })}

@@ -238,17 +229,17 @@ export default function LazadaAnalyticsPage() { {/* Preset Buttons */}
- {PRESETS.map(p => ( + {PRESET_KEYS.map(p => ( ))}
@@ -258,14 +249,14 @@ export default function LazadaAnalyticsPage() { {/* Custom date range */}
- กำหนดเอง: + {t('lazadaAnalytics.customRange', 'กำหนดเอง:')} setDateFrom(e.target.value)} className={`px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:${shopCfg.activeBorder} focus:outline-none`} /> - ถึง + {t('lazadaAnalytics.to', 'ถึง')} - ดูข้อมูล + {t('lazadaAnalytics.btnApply', 'ดูข้อมูล')}
@@ -290,7 +281,7 @@ export default function LazadaAnalyticsPage() { onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }} className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" > - ✕ ล้าง + {t('lazadaAnalytics.btnClear', '✕ ล้าง')} )} @@ -298,25 +289,77 @@ export default function LazadaAnalyticsPage() { {/* KPI Cards Row 1 */}
- - - - + + + +
{/* KPI Cards Row 2 */}
- - - - + + + +
{/* Monthly Revenue Chart */}
-

ยอดขายรายเดือน

+

{t('lazadaAnalytics.monthlyRevenueTitle', 'ยอดขายรายเดือน')}

{monthlyChart.length > 0 ? (
@@ -327,14 +370,14 @@ export default function LazadaAnalyticsPage() { `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> } cursor={{ fill: '#ffffff06' }} /> - - - + + +
) : ( -

ยังไม่มีข้อมูลรายเดือน

+

{t('lazadaAnalytics.noMonthlyData', 'ยังไม่มีข้อมูลรายเดือน')}

)}
@@ -343,7 +386,7 @@ export default function LazadaAnalyticsPage() {
-

จำนวนคำสั่งซื้อรายเดือน

+

{t('lazadaAnalytics.monthlyOrdersTitle', 'จำนวนคำสั่งซื้อรายเดือน')}

@@ -352,8 +395,8 @@ export default function LazadaAnalyticsPage() { } /> - - + +
@@ -367,7 +410,7 @@ export default function LazadaAnalyticsPage() {
-

สัดส่วนสถานะคำสั่งซื้อ

+

{t('lazadaAnalytics.statusBreakdownTitle', 'สัดส่วนสถานะคำสั่งซื้อ')}

@@ -399,7 +442,7 @@ export default function LazadaAnalyticsPage() {
-

ช่องทางการชำระเงิน

+

{t('lazadaAnalytics.paymentMethodTitle', 'ช่องทางการชำระเงิน')}

{paymentBreakdown.map((p: any, i: number) => { @@ -407,8 +450,8 @@ export default function LazadaAnalyticsPage() { return (
- {p.method || 'ไม่ระบุ'} - {p.count} ออเดอร์ · {fmt(pct, 0)}% + {p.method || t('lazadaAnalytics.unspecifiedPayment', 'ไม่ระบุ')} + {t('lazadaAnalytics.ordersCountAndPct', { count: p.count, pct: fmt(pct, 0), defaultValue: `${p.count} ออเดอร์ · ${fmt(pct, 0)}%` })}
-

Top 10 สินค้าขายดี (by ยอดเงิน)

+

{t('lazadaAnalytics.topProductsTitle', 'Top 10 สินค้าขายดี (by ยอดเงิน)')}

แพลตฟอร์มยอดขายสัดส่วนออเดอร์{t('analytics.platform', 'Platform')}{t('analytics.revenue', 'Revenue')}{t('analytics.share', 'Share')}{t('analytics.orders', 'Orders')}
- - - - - - + + + + + + diff --git a/apps/admin/src/pages/LazadaOrderPage.tsx b/apps/admin/src/pages/LazadaOrderPage.tsx index 8897328..dcd26f3 100644 --- a/apps/admin/src/pages/LazadaOrderPage.tsx +++ b/apps/admin/src/pages/LazadaOrderPage.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { Upload, ShoppingBag, CheckCircle, AlertTriangle, ChevronLeft, ChevronRight, Search, Loader2, DollarSign, Package, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import * as XLSX from 'xlsx'; const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); @@ -11,8 +12,6 @@ export const LAZADA_SHOPS = [ { id: 'homaromashop', label: 'Relax', color: 'text-yellow-400', activeBg: 'bg-yellow-500/15', activeBorder: 'border-yellow-500/40' }, { id: 'conner', label: 'Conner', color: 'text-white', activeBg: 'bg-white/15', activeBorder: 'border-white/40' }, { id: 'december', label: 'December', color: 'text-white', activeBg: 'bg-white/15', activeBorder: 'border-white/40' }, - - ]; function getShop(id: string) { return LAZADA_SHOPS.find(s => s.id === id) || LAZADA_SHOPS[0]; } @@ -23,6 +22,7 @@ function authHeaders() { // ── Summary Cards ───────────────────────────────────────────────────────────── function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number }) { + const { t } = useTranslation(); const [data, setData] = useState({ orderCount: 0, totalAmount: 0, totalShipping: 0 }); useEffect(() => { @@ -32,9 +32,9 @@ function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number } }, [shop, refreshKey]); const cards = [ - { label: 'คำสั่งซื้อทั้งหมด', value: data.orderCount.toLocaleString(), icon: ShoppingBag, color: 'text-white' }, - { label: 'ยอดขายรวม (Gross)', value: `฿${data.totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: DollarSign, color: 'text-emerald-400' }, - { label: 'ค่าส่งรวม', value: `฿${data.totalShipping.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: Package, color: 'text-amber-400' }, + { label: t('lazadaOrder.totalOrders', 'คำสั่งซื้อทั้งหมด'), value: data.orderCount.toLocaleString(), icon: ShoppingBag, color: 'text-white' }, + { label: t('lazadaOrder.grossSales', 'ยอดขายรวม (Gross)'), value: `฿${data.totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: DollarSign, color: 'text-emerald-400' }, + { label: t('lazadaOrder.totalShipping', 'ค่าส่งรวม'), value: `฿${data.totalShipping.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: Package, color: 'text-amber-400' }, ]; return ( @@ -57,6 +57,7 @@ function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number } // ── Orders Table ────────────────────────────────────────────────────────────── function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) { + const { t } = useTranslation(); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -85,7 +86,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) return (
-

ทั้งหมด {total.toLocaleString()} คำสั่งซื้อ

+

{t('lazadaOrder.totalCount', { total: total.toLocaleString() })}

{page} / {totalPages} @@ -97,21 +98,21 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
#สินค้าตัวเลือกชิ้นยอดรายได้% จากยอดรวม{t('lazadaAnalytics.thIndex', '#')}{t('lazadaAnalytics.thProduct', 'สินค้า')}{t('lazadaAnalytics.thVariation', 'ตัวเลือก')}{t('lazadaAnalytics.thUnits', 'ชิ้น')}{t('lazadaAnalytics.thRevenue', 'ยอดรายได้')}{t('lazadaAnalytics.thPercentTotal', '% จากยอดรวม')}
- - - - - - - + + + + + + + {loading ? ( - + ) : items.length === 0 ? ( - + ) : items.map((order) => ( @@ -132,7 +133,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) ))} @@ -145,6 +146,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) // ── Main Page ───────────────────────────────────────────────────────────────── export default function LazadaOrderPage() { + const { t } = useTranslation(); const [shop, setShop] = useState(LAZADA_SHOPS[0].id); const [mode, setMode] = useState<'list' | 'import'>('list'); const [refreshKey, setRefreshKey] = useState(0); @@ -180,7 +182,7 @@ export default function LazadaOrderPage() { const rows = XLSX.utils.sheet_to_json(sheet); if (rows.length === 0) { - throw new Error("ไฟล์ไม่มีข้อมูล"); + throw new Error(t('lazadaOrder.errNoData', 'ไฟล์ไม่มีข้อมูล')); } const res = await fetch(`${API_URL}/api/lazada-order/import`, { @@ -196,23 +198,23 @@ export default function LazadaOrderPage() { if (fileRef.current) fileRef.current.value = ''; setRefreshKey(k => k + 1); // trigger list refresh } else { - setImportStatus({ type: 'error', msg: resData.message || 'นำเข้าไม่สำเร็จ' }); + setImportStatus({ type: 'error', msg: resData.message || t('lazadaOrder.errImportFail', 'นำเข้าไม่สำเร็จ') }); } } catch (err: any) { - setImportStatus({ type: 'error', msg: err.message || 'เกิดข้อผิดพลาดในการอ่านไฟล์ Excel' }); + setImportStatus({ type: 'error', msg: err.message || t('lazadaOrder.errReadExcel', 'เกิดข้อผิดพลาดในการอ่านไฟล์ Excel') }); } finally { setImporting(false); } }; reader.onerror = () => { - setImportStatus({ type: 'error', msg: 'เกิดข้อผิดพลาดในการอ่านไฟล์' }); + setImportStatus({ type: 'error', msg: t('lazadaOrder.errReadFile', 'เกิดข้อผิดพลาดในการอ่านไฟล์') }); setImporting(false); }; reader.readAsBinaryString(file); }; const handleClearData = async () => { - if (!confirm(`ยืนยันการลบข้อมูล Lazada ทั้งหมดของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; + if (!confirm(t('lazadaOrder.clearConfirm', { shop: shopCfg.label, defaultValue: `ยืนยันการลบข้อมูล Lazada ทั้งหมดของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้` }))) return; setClearing(true); setImportStatus(null); try { @@ -225,10 +227,10 @@ export default function LazadaOrderPage() { setImportStatus({ type: 'success', msg: data.message }); setRefreshKey(k => k + 1); } else { - setImportStatus({ type: 'error', msg: data.message || 'ลบข้อมูลไม่สำเร็จ' }); + setImportStatus({ type: 'error', msg: data.message || t('lazadaOrder.errClearFail', 'ลบข้อมูลไม่สำเร็จ') }); } } catch (err: any) { - setImportStatus({ type: 'error', msg: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อ' }); + setImportStatus({ type: 'error', msg: err.message || t('lazadaOrder.errConn', 'เกิดข้อผิดพลาดในการเชื่อมต่อ') }); } finally { setClearing(false); } @@ -243,10 +245,10 @@ export default function LazadaOrderPage() {
Lazada / - Orders + {t('lazadaOrder.breadcrumb', 'Orders')}

- คำสั่งซื้อ — + {t('lazadaOrder.breadcrumb', 'คำสั่งซื้อ')} — {shopCfg.label}

@@ -274,23 +276,23 @@ export default function LazadaOrderPage() { className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${mode === 'list' ? `bg-white/10 text-white` : 'text-slate-400 hover:text-white hover:bg-white/5' }`} > - ดูรายการคำสั่งซื้อ + {t('lazadaOrder.tabList', 'ดูรายการคำสั่งซื้อ')} {mode === 'import' ? (
-

นำเข้าไฟล์ยอดขาย Lazada (Excel)

+

{t('lazadaOrder.importTitle', 'นำเข้าไฟล์ยอดขาย Lazada (Excel)')}

- โหลดไฟล์ Export จาก Lazada Seller Center (ไฟล์ .xlsx หรือ .xls) + {t('lazadaOrder.importSubtitle', 'โหลดไฟล์ Export จาก Lazada Seller Center (ไฟล์ .xlsx หรือ .xls)')}

@@ -324,14 +326,14 @@ export default function LazadaOrderPage() { }`} > {importing ? ( - <> กำลังประมวลผล... + <> {t('lazadaOrder.importProcessing', 'กำลังประมวลผล...')} ) : ( - <> นำเข้าข้อมูลเข้าฐานข้อมูล + <> {t('lazadaOrder.importBtn', 'นำเข้าข้อมูลเข้าฐานข้อมูล')} )}
-

ต้องการล้างข้อมูลของร้านนี้เพื่ออัปโหลดใหม่ใช่หรือไม่?

+

{t('lazadaOrder.clearPrompt', 'ต้องการล้างข้อมูลของร้านนี้เพื่ออัปโหลดใหม่ใช่หรือไม่?')}

diff --git a/apps/admin/src/pages/LazadaOverviewPage.tsx b/apps/admin/src/pages/LazadaOverviewPage.tsx index 39e4289..14a13aa 100644 --- a/apps/admin/src/pages/LazadaOverviewPage.tsx +++ b/apps/admin/src/pages/LazadaOverviewPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, PieChart, Pie, Cell, Legend, @@ -46,17 +47,8 @@ function getPresetRange(preset: Preset): { from: string; to: string } { if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } return { from: '', to: '' }; } -const PRESETS: { key: Preset; label: string }[] = [ - { key: '7d', label: '7 วัน' }, - { key: '30d', label: '30 วัน' }, - { key: '90d', label: '90 วัน' }, - { key: 'thisMonth', label: 'เดือนนี้' }, - { key: 'lastMonth', label: 'เดือนที่แล้ว' }, - { key: 'year', label: 'ปีนี้' }, - { key: 'lastYear', label: 'ปีที่แล้ว' }, - { key: 'last12m', label: '12 เดือนที่ผ่านมา' }, - { key: 'all', label: 'ทั้งหมด' }, -]; + +const PRESET_KEYS: Preset[] = ['7d', '30d', '90d', 'thisMonth', 'lastMonth', 'year', 'lastYear', 'last12m', 'all']; function authHeaders() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; @@ -96,6 +88,7 @@ const CustomTooltip = ({ active, payload, label }: any) => { }; export default function LazadaOverviewPage() { + const { t } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -136,7 +129,7 @@ export default function LazadaOverviewPage() { return (
- กำลังรวมข้อมูลทุกร้าน... + {t('lazadaOverview.loading', 'กำลังรวมข้อมูลทุกร้าน...')}
); } @@ -145,9 +138,9 @@ export default function LazadaOverviewPage() { return (
-

{error || 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน'}

+

{error || t('lazadaOverview.noData', 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน')}

- ← กลับไปหน้า Lazada Orders + {t('lazadaOverview.backToOrders', '← กลับไปหน้า Lazada Orders')}
); @@ -168,8 +161,8 @@ export default function LazadaOverviewPage() {
-

Lazada

-

ภาพรวมทุกร้าน

+

{t('lazadaOverview.breadcrumb', 'Lazada')}

+

{t('lazadaOverview.title', 'ภาพรวมทุกร้าน')}

@@ -191,17 +184,17 @@ export default function LazadaOverviewPage() { {/* Preset Buttons */}
- {PRESETS.map(p => ( + {PRESET_KEYS.map(p => ( ))}
@@ -210,12 +203,12 @@ export default function LazadaOverviewPage() { {/* Custom date range */}
- กำหนดเอง: + {t('lazadaOverview.customRange', 'กำหนดเอง:')} setDateFrom(e.target.value)} className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-blue-500/40 focus:outline-none" /> - ถึง + {t('lazadaOverview.to', 'ถึง')} setDateTo(e.target.value)} className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-blue-500/40 focus:outline-none" @@ -224,7 +217,7 @@ export default function LazadaOverviewPage() { onClick={applyCustom} className="px-4 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-all" > - ดูข้อมูล + {t('lazadaOverview.btnApply', 'ดูข้อมูล')}
@@ -238,7 +231,7 @@ export default function LazadaOverviewPage() { onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }} className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" > - ✕ ล้าง + {t('lazadaOverview.btnClear', '✕ ล้าง')}
)} @@ -246,10 +239,34 @@ export default function LazadaOverviewPage() { {/* Grand KPI Row */}
- - 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%`} color="text-cyan-glow" icon={TrendingUp} /> - - + + 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1), defaultValue: `Margin ${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%` })} + color="text-cyan-glow" + icon={TrendingUp} + /> + +
{/* Per-Shop Cards */} @@ -270,19 +287,19 @@ export default function LazadaOverviewPage() {

{s.shop}

- {fmt(revShare, 1)}% of total + {t('lazadaOverview.percentOfTotal', { pct: fmt(revShare, 1), defaultValue: `${fmt(revShare, 1)}% of total` })} {/* Stats grid */}
{[ - { label: 'ยอดขาย', value: fmtB(s.revenue), color: 'text-emerald-400' }, - { label: 'ส่วนลด Lazada', value: fmtB(s.platformDiscount), color: 'text-purple-400' }, - { label: 'คำสั่งซื้อ', value: fmt(s.orders), color: 'text-white' }, - { label: 'ส่วนลดร้าน', value: fmtB(s.sellerDiscount), color: 'text-rose-400' }, - { label: 'ยกเลิก', value: `${fmt(s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, 1)}%`, color: 'text-amber-400' }, - { label: 'ค่าส่งรวม', value: fmtB(s.shippingFee), color: 'text-slate-300' }, + { label: t('lazadaOverview.statRevenue', 'ยอดขาย'), value: fmtB(s.revenue), color: 'text-emerald-400' }, + { label: t('lazadaOverview.statPlatformDiscount', 'ส่วนลด Lazada'), value: fmtB(s.platformDiscount), color: 'text-purple-400' }, + { label: t('lazadaOverview.statOrders', 'คำสั่งซื้อ'), value: fmt(s.orders), color: 'text-white' }, + { label: t('lazadaOverview.statSellerDiscount', 'ส่วนลดร้าน'), value: fmtB(s.sellerDiscount), color: 'text-rose-400' }, + { label: t('lazadaOverview.statCancelled', 'ยกเลิก'), value: `${fmt(s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, 1)}%`, color: 'text-amber-400' }, + { label: t('lazadaOverview.statShipping', 'ค่าส่งรวม'), value: fmtB(s.shippingFee), color: 'text-slate-300' }, ].map(item => (

{item.label}

@@ -303,7 +320,7 @@ export default function LazadaOverviewPage() { className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90" style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }} > - ดู Analytics + {t('lazadaOverview.viewAnalytics', 'ดู Analytics')}
); @@ -314,7 +331,7 @@ export default function LazadaOverviewPage() {
-

ยอดขายรายเดือน (แยกตามร้าน)

+

{t('lazadaOverview.monthlySalesTitle', 'ยอดขายรายเดือน (แยกตามร้าน)')}

{monthly.length > 0 ? (
@@ -332,7 +349,7 @@ export default function LazadaOverviewPage() {
) : ( -

ยังไม่มีข้อมูลรายเดือน

+

{t('lazadaOverview.noMonthlyData', 'ยังไม่มีข้อมูลรายเดือน')}

)}
@@ -343,7 +360,7 @@ export default function LazadaOverviewPage() {
-

สัดส่วนยอดขายตามร้าน

+

{t('lazadaOverview.salesShareTitle', 'สัดส่วนยอดขายตามร้าน')}

@@ -375,17 +392,17 @@ export default function LazadaOverviewPage() {
-

เปรียบเทียบร้าน

+

{t('lazadaOverview.shopComparisonTitle', 'เปรียบเทียบร้าน')}

Order IDสถานะผู้ซื้อวันที่สร้างSKUsยอดรวม (฿)ค่าส่ง (฿){t('lazadaOrder.thOrderId', 'Order ID')}{t('lazadaOrder.thStatus', 'สถานะ')}{t('lazadaOrder.thCustomer', 'ผู้ซื้อ')}{t('lazadaOrder.thDate', 'วันที่สร้าง')}{t('lazadaOrder.thSkus', 'SKUs')}{t('lazadaOrder.thTotal', 'ยอดรวม (฿)')}{t('lazadaOrder.thShipping', 'ค่าส่ง (฿)')}
กำลังโหลด...
{t('lazadaOrder.loading', 'กำลังโหลด...')}
ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน
{t('lazadaOrder.noData', 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน')}
{order.orderNumber}฿{order.orderAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ฿{order.shippingFee.toLocaleString(undefined, { minimumFractionDigits: 2 })} - +
- - - - - + + + + + @@ -405,7 +422,7 @@ export default function LazadaOverviewPage() { ))} {/* Total row */} - + @@ -421,18 +438,18 @@ export default function LazadaOverviewPage() {
-

Top 10 สินค้าขายดี — รวมทุกร้าน

+

{t('lazadaOverview.topProductsTitle', 'Top 10 สินค้าขายดี — รวมทุกร้าน')}

ร้านยอดขายส่วนลดแพลตฟอร์มออเดอร์ยกเลิก{t('lazadaOverview.thShop', 'ร้าน')}{t('lazadaOverview.thRevenue', 'ยอดขาย')}{t('lazadaOverview.thPlatformDiscount', 'ส่วนลดแพลตฟอร์ม')}{t('lazadaOverview.thOrders', 'ออเดอร์')}{t('lazadaOverview.thCancelled', 'ยกเลิก')}
รวมทั้งหมด{t('lazadaOverview.totalAll', 'รวมทั้งหมด')} {fmtB(summary.totalRevenue)} {fmtB(summary.totalPlatformDisc)} {fmt(summary.totalOrders)}
- - - - - - + + + + + + diff --git a/apps/admin/src/pages/LoginPage.tsx b/apps/admin/src/pages/LoginPage.tsx index ad56c88..8660419 100644 --- a/apps/admin/src/pages/LoginPage.tsx +++ b/apps/admin/src/pages/LoginPage.tsx @@ -1,9 +1,12 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { Lock, User, AlertCircle, ArrowRight, Loader2 } from 'lucide-react' import { API_BASE } from '@homaroma/types' +import LanguageSelector from '../components/LanguageSelector' export default function LoginPage() { + const { t } = useTranslation() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -25,7 +28,7 @@ export default function LoginPage() { const data = await response.json() if (!response.ok || !data.success) { - throw new Error(data.message || 'Login failed') + throw new Error(data.message || t('login.invalidCredentials', 'Login failed')) } // Store token @@ -43,6 +46,11 @@ export default function LoginPage() { return (
+ {/* Top right language selector */} +
+ +
+ {/* Background Glows */}
@@ -53,9 +61,9 @@ export default function LoginPage() { Authorized Access Only

- HomAroma Admin + HomAroma {t('login.title', 'Admin')}

-

Welcome back to the sensory command center.

+

{t('login.subtitle', 'Welcome back to the sensory command center.')}

@@ -68,7 +76,9 @@ export default function LoginPage() {
- +
- +
{loading ? ( ) : ( <> - Login to Console + {t('login.signIn', 'Sign In')} )} diff --git a/apps/admin/src/pages/OrderDetailPage.tsx b/apps/admin/src/pages/OrderDetailPage.tsx index e8defc1..3ba092b 100644 --- a/apps/admin/src/pages/OrderDetailPage.tsx +++ b/apps/admin/src/pages/OrderDetailPage.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react' import { useParams, Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { API_BASE, formatPrice, type Order } from '@homaroma/types' import { ArrowLeft, CheckCircle2, ExternalLink, Loader2, Truck, XCircle, FileImage } from 'lucide-react' export default function OrderDetailPage() { + const { t, i18n } = useTranslation() const { id } = useParams<{ id: string }>() const [order, setOrder] = useState(null) const [loading, setLoading] = useState(true) @@ -42,6 +44,16 @@ export default function OrderDetailPage() { } } + const getStatusLabel = (status: string) => { + switch (status) { + case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment') + case 'PAID': return t('orders.statusPaid', 'Paid') + case 'SHIPPED': return t('orders.statusShipped', 'Shipped') + case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled') + default: return status.replace('_', ' ') + } + } + if (loading || !order) { return (
@@ -54,12 +66,12 @@ export default function OrderDetailPage() {
{/* Header */}
- +

Order #{order.id.toString().padStart(4, '0')}

-

{new Date(order.createdAt).toLocaleString()}

+

{new Date(order.createdAt).toLocaleString(i18n.language === 'th' ? 'th-TH' : i18n.language === 'ja' ? 'ja-JP' : i18n.language === 'zh' ? 'zh-CN' : 'en-US')}

- {order.status.replace('_', ' ')} + {getStatusLabel(order.status)}
- {/* Left Col: Order Info & Items */}
-
-

Order Items

+

+ {t('orders.orderItems', 'Order Items')} +

{order.items.map(item => (
@@ -85,7 +97,7 @@ export default function OrderDetailPage() { Product
-

{item.productName}

+

{item.productName}

{item.variantName}

@@ -101,22 +113,24 @@ export default function OrderDetailPage() { {formatPrice(order.totalAmount - order.shippingFee)}
- Shipping + {t('orders.fulfillment', 'Shipping')} {formatPrice(order.shippingFee)}
- Total + {t('orders.total', 'Total')} {formatPrice(order.totalAmount)}
-

Customer Details

+

+ {t('orders.customerDetails', 'Customer Details')} +

{order.customer && (
- +

LINE Customer

{order.customer.displayName}

@@ -126,12 +140,12 @@ export default function OrderDetailPage() {
-

Name

-

{order.customerName}

+

{t('orders.customer', 'Name')}

+

{order.customerName}

Phone

-

{order.customerPhone}

+

{order.customerPhone}

Email

@@ -143,14 +157,14 @@ export default function OrderDetailPage() {
-
{/* Right Col: Verification & Actions */}
-
-

Payment Verification

+

+ {t('orders.paymentSlip', 'Payment Verification')} +

{order.paymentSlipUrl ? (
@@ -162,7 +176,7 @@ export default function OrderDetailPage() { > Slip
-
+
Open Original
@@ -177,34 +191,36 @@ export default function OrderDetailPage() {
-

Actions

+

+ {t('orders.action', 'Actions')} +

{order.status === 'PENDING_PAYMENT' && ( )} {order.status === 'PAID' && ( )} {order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && ( )} {order.status === 'SHIPPED' && ( @@ -214,7 +230,6 @@ export default function OrderDetailPage() { )}
-
diff --git a/apps/admin/src/pages/OrdersPage.tsx b/apps/admin/src/pages/OrdersPage.tsx index ae831d1..7719aff 100644 --- a/apps/admin/src/pages/OrdersPage.tsx +++ b/apps/admin/src/pages/OrdersPage.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { API_BASE, formatPrice, type Order } from '@homaroma/types' import { Search, ChevronRight, Clock, CheckCircle2, Truck, XCircle } from 'lucide-react' export default function OrdersPage() { + const { t, i18n } = useTranslation() const [orders, setOrders] = useState([]) const [loading, setLoading] = useState(true) const [filter, setFilter] = useState('ALL') @@ -14,7 +16,7 @@ export default function OrdersPage() { try { const res = await fetch(`${API_BASE}/orders`) const data = await res.json() - setOrders(data) + setOrders(Array.isArray(data) ? data : []) } catch (err) { console.error(err) } finally { @@ -26,13 +28,13 @@ export default function OrdersPage() { fetchData() }, []) - const filteredOrders = orders.filter(o => { + const filteredOrders = orders.filter((o) => { const matchesFilter = filter === 'ALL' || o.status === filter const searchLower = searchTerm.toLowerCase() const matchesSearch = o.id.toString().includes(searchLower) || - o.customerName.toLowerCase().includes(searchLower) || - o.customerPhone.includes(searchLower) || + (o.customerName && o.customerName.toLowerCase().includes(searchLower)) || + (o.customerPhone && o.customerPhone.includes(searchLower)) || (o.customer?.displayName?.toLowerCase() || '').includes(searchLower) return matchesFilter && matchesSearch @@ -48,37 +50,55 @@ export default function OrdersPage() { } } + const getStatusLabel = (status: string) => { + switch (status) { + case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment') + case 'PAID': return t('orders.statusPaid', 'Paid') + case 'SHIPPED': return t('orders.statusShipped', 'Shipped') + case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled') + default: return status.replace('_', ' ') + } + } + + const filterTabs = [ + { key: 'ALL', label: t('orders.statusAll', 'All') }, + { key: 'PENDING_PAYMENT', label: t('orders.statusPending', 'Pending Payment') }, + { key: 'PAID', label: t('orders.statusPaid', 'Paid') }, + { key: 'SHIPPED', label: t('orders.statusShipped', 'Shipped') }, + { key: 'CANCELLED', label: t('orders.statusCancelled', 'Cancelled') }, + ] + return (
{/* Header */}
-

Orders Management

-

Review incoming orders and verify payments.

+

{t('orders.title', 'Orders Management')}

+

{t('orders.subtitle', 'Review incoming orders and verify payments.')}

{/* Filters & Search */}
- {['ALL', 'PENDING_PAYMENT', 'PAID', 'SHIPPED', 'CANCELLED'].map((f) => ( + {filterTabs.map((tab) => ( ))}
-
+
setSearchTerm(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-glow/50 transition-colors" @@ -87,17 +107,17 @@ export default function OrdersPage() {
{/* Orders Table */} -
-
+
+
#สินค้าตัวเลือกชิ้นยอดรายได้% รวม{t('lazadaOverview.thIndex', '#')}{t('lazadaOverview.thProduct', 'สินค้า')}{t('lazadaOverview.thVariation', 'ตัวเลือก')}{t('lazadaOverview.thUnits', 'ชิ้น')}{t('lazadaOverview.thRevenueCol', 'ยอดรายได้')}{t('lazadaOverview.thPercentTotal', '% รวม')}
- - - - - - - + + + + + + + @@ -109,47 +129,49 @@ export default function OrdersPage() { - + )) ) : filteredOrders.length === 0 ? ( - + ) : ( filteredOrders.map((order) => ( - - - - diff --git a/apps/admin/src/pages/ProductAttributesPage.tsx b/apps/admin/src/pages/ProductAttributesPage.tsx index 997f05e..4f679e6 100644 --- a/apps/admin/src/pages/ProductAttributesPage.tsx +++ b/apps/admin/src/pages/ProductAttributesPage.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types' import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react' +import ContentLanguageTabs from '../components/ContentLanguageTabs' -const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const +const LANGUAGES = ['th', 'en', 'ja', 'zh'] as const const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const type EditableDefinition = { @@ -57,6 +59,7 @@ function parseDefinition(definition: AttributeDefinition): EditableDefinition { } export default function ProductAttributesPage() { + const { t, i18n } = useTranslation() const [definitions, setDefinitions] = useState([]) const [categories, setCategories] = useState([]) const [editing, setEditing] = useState(null) @@ -94,20 +97,32 @@ export default function ProductAttributesPage() { setError(null) try { - const baseLabel = editing.translations.th.label || editing.translations.en.label + 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, + code: editing.code, type: editing.type, - unit: editing.unit || null, + unit: editing.unit, isRequired: editing.isRequired, isFilterable: editing.isFilterable, sortOrder: Number(editing.sortOrder) || 0, - categoryId: editing.categoryId || null, - options: editing.type === 'SELECT' ? editing.options : null, + categoryId: editing.categoryId ? Number(editing.categoryId) : null, + label: Object.fromEntries( + Object.entries(editing.translations) + .filter(([, val]) => val.label.trim()) + .map(([lang, val]) => [lang, val.label.trim()]) + ), + description: Object.fromEntries( + Object.entries(editing.translations) + .filter(([, val]) => val.description.trim()) + .map(([lang, val]) => [lang, val.description.trim()]) + ), + options: editing.type === 'SELECT' + ? editing.options.map((opt) => Object.fromEntries( + Object.entries(opt).filter(([, val]) => typeof val === 'string' && val.trim()) + )).filter((opt) => Object.keys(opt).length > 0) + : undefined, } const url = editing.id @@ -118,39 +133,15 @@ export default function ProductAttributesPage() { 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()) - ), - }), + body: JSON.stringify(payload), }) - if (!res.ok) { - const err = await res.json().catch(() => null) - throw new Error(err?.message || 'Failed to save attribute definition') + const errData = await res.json().catch(() => null) + throw new Error(errData?.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() + setEditing(null) } catch (err: any) { setError(err.message) } finally { @@ -158,56 +149,106 @@ export default function ProductAttributesPage() { } } - const openCreate = () => { - setEditing(emptyDefinition()) - setActiveLang('th') + const handleDelete = async (id: number) => { + if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this attribute definition?'))) return + try { + const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, { method: 'DELETE' }) + if (!res.ok) { + const errData = await res.json().catch(() => null) + throw new Error(errData?.message || 'Failed to delete attribute definition') + } + await fetchData() + if (editing?.id === id) setEditing(null) + } catch (err: any) { + setError(err.message) + } } - const categoryNameMap = useMemo( - () => new Map(categories.map((category) => [category.id, typeof category.name === 'string' ? category.name : category.name.th || category.name.en || 'Untitled'])), - [categories] - ) + const categoryNameMap = useMemo(() => { + const map = new Map() + const currentLang = i18n.language + categories.forEach((cat) => { + const name = typeof cat.name === 'string' + ? cat.name + : (cat.name?.[currentLang] || cat.name?.th || cat.name?.en || `Category #${cat.id}`) + map.set(cat.id, name) + }) + return map + }, [categories, i18n.language]) + + const handleCopyFromLang = (fromLang: string) => { + if (!editing) return + const src = editing.translations[fromLang] + if (!src) return + setEditing({ + ...editing, + translations: { + ...editing.translations, + [activeLang]: { + label: src.label || '', + description: src.description || '', + }, + }, + }) + } + + const labelTranslationsMap = useMemo(() => { + if (!editing) return {} + return Object.fromEntries( + Object.entries(editing.translations).map(([lang, val]) => [lang, val.label]) + ) + }, [editing]) return (
-
+
-

Product Attribute Definitions

-

Create reusable product specs and maintain translations across languages.

+
+ +

{t('nav.productAttributes', 'Product Attributes')}

+
+

Define multilingual specs, units, and select options for products.

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

Definitions

-
- +

Defined Attributes ({definitions.length})

- {loading && Array.from({ length: 4 }).map((_, idx) => ( -
+ {loading && Array.from({ length: 4 }).map((_, i) => ( +
))} {!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 || '' + const currentLang = i18n.language + const label = typeof definition.label === 'string' + ? definition.label + : definition.label?.[currentLang] || definition.label?.th || definition.label?.en || definition.code + const description = typeof definition.description === 'string' + ? definition.description + : definition.description?.[currentLang] || definition.description?.th || definition.description?.en || '' return ( -
-
+
+

{label}

- + {definition.type}
@@ -217,15 +258,25 @@ export default function ProductAttributesPage() { {definition.categoryId ? `Category: ${categoryNameMap.get(definition.categoryId) || definition.categoryId}` : 'Global attribute'}

- +
+ + +
) })} @@ -236,12 +287,12 @@ export default function ProductAttributesPage() {

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

-

Type-aware definitions with localized labels and SELECT options.

+

Type-aware definitions with 4-language localized labels and SELECT options.

{editing && ( @@ -250,31 +301,27 @@ export default function ProductAttributesPage() { {!editing ? (
- Select a definition to edit or create a new one. + Select an attribute definition to edit or click Create Definition.
) : ( -
- {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" + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none" + placeholder="e.g. burn_time, weight" />
@@ -282,7 +329,7 @@ export default function ProductAttributesPage() { @@ -293,7 +340,8 @@ export default function ProductAttributesPage() { type="text" value={editing.unit} onChange={(e) => 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" + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none" + placeholder="e.g. g, ml, hrs" />
@@ -301,121 +349,127 @@ export default function ProductAttributesPage() { 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" + onChange={(e) => setEditing({ ...editing, sortOrder: Number(e.target.value) })} + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none" />
-
- - -
-
- -
- -
- + setEditing({ ...editing, translations: { ...editing.translations, - [activeLang]: { ...editing.translations[activeLang], label: e.target.value }, + [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" + className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none" + placeholder={`Label in ${activeLang.toUpperCase()}`} />
- +
Order IDCustomerDateTotalStatusAction
{t('orders.orderId', 'Order ID')}{t('orders.customer', 'Customer')}{t('orders.date', 'Date')}{t('orders.total', 'Total')}{t('orders.status', 'Status')}{t('orders.action', 'Action')}
No orders found. + {t('orders.noOrders', 'No orders found.')} +
+ #{order.id.toString().padStart(4, '0')} {order.customer ? (
- + LINE: {order.customer.displayName}
) : null} -

{order.customerName}

-

{order.customerEmail}

+

{order.customerName}

+

{order.customerEmail || order.customerPhone || '-'}

- {new Date(order.createdAt).toLocaleDateString()} + + {new Date(order.createdAt).toLocaleDateString(i18n.language === 'th' ? 'th-TH' : i18n.language === 'ja' ? 'ja-JP' : i18n.language === 'zh' ? 'zh-CN' : 'en-US')} + {formatPrice(order.totalAmount)}
- {order.status.replace('_', ' ')} + {getStatusLabel(order.status)}
+ - Review + {t('orders.review', 'Review')}