This commit is contained in:
Your Name committed 2026-08-21 23:07:44 +00:00
1 parent 2ac5809895
commit 3afe32db7d
10 files changed
+1782 -1

No files matched your search

+13
View File
@@ -47,6 +47,12 @@ import ProductAttributesPage from './pages/ProductAttributesPage'
import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage' import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage'
import TiktokShopCasePage from './pages/TiktokShopCasePage' import TiktokShopCasePage from './pages/TiktokShopCasePage'
import ShopeeShopCasePage from './pages/ShopeeShopCasePage' import ShopeeShopCasePage from './pages/ShopeeShopCasePage'
import BackupDashboardPage from './pages/backup/BackupDashboardPage'
import BackupDatabasePage from './pages/backup/BackupDatabasePage'
import BackupCatalogPage from './pages/backup/BackupCatalogPage'
import BackupOrdersPage from './pages/backup/BackupOrdersPage'
import BackupCmsPage from './pages/backup/BackupCmsPage'
import BackupMediaPage from './pages/backup/BackupMediaPage'
function App() { function App() {
return ( return (
@@ -105,6 +111,13 @@ function App() {
<Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} /> <Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} />
<Route path="/lazada-overview" element={<LazadaOverviewPage />} /> <Route path="/lazada-overview" element={<LazadaOverviewPage />} />
<Route path="/global-analytics" element={<GlobalAnalyticsPage />} /> <Route path="/global-analytics" element={<GlobalAnalyticsPage />} />
{/* Backup & Restore */}
<Route path="/backup" element={<BackupDashboardPage />} />
<Route path="/backup/database" element={<BackupDatabasePage />} />
<Route path="/backup/catalog" element={<BackupCatalogPage />} />
<Route path="/backup/orders" element={<BackupOrdersPage />} />
<Route path="/backup/cms" element={<BackupCmsPage />} />
<Route path="/backup/media" element={<BackupMediaPage />} />
</Route> </Route>
</Route> </Route>
</Routes> </Routes>
+43
View File
@@ -30,6 +30,9 @@ import {
Calculator, Calculator,
Info, Info,
ChevronLeft, ChevronLeft,
DatabaseBackup,
FileJson,
FolderArchive,
} from 'lucide-react' } from 'lucide-react'
const MAIN_MENU = [ const MAIN_MENU = [
@@ -96,6 +99,14 @@ const MENU_MANAGER_MENU = [
{ icon: Store, label: 'Footer Brand', to: '/menu-manager/footer-brand' }, { 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 = [ const SETTINGS_MENU = [
{ icon: Store, label: 'Store Settings', to: '/settings' }, { icon: Store, label: 'Store Settings', to: '/settings' },
{ icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' }, { icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' },
@@ -121,6 +132,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p)) const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p))
const isInCustomer = ['/customers', '/subscribers', '/contacts'].some(p => location.pathname.startsWith(p)) const isInCustomer = ['/customers', '/subscribers', '/contacts'].some(p => location.pathname.startsWith(p))
const isInMenuManager = location.pathname.startsWith('/menu-manager') const isInMenuManager = location.pathname.startsWith('/menu-manager')
const isInBackup = location.pathname.startsWith('/backup')
const isInSettings = location.pathname.startsWith('/settings') const isInSettings = location.pathname.startsWith('/settings')
const [productOpen, setProductOpen] = useState(isInProduct) const [productOpen, setProductOpen] = useState(isInProduct)
@@ -132,6 +144,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const [cmsOpen, setCmsOpen] = useState(isInCms) const [cmsOpen, setCmsOpen] = useState(isInCms)
const [customerOpen, setCustomerOpen] = useState(isInCustomer) const [customerOpen, setCustomerOpen] = useState(isInCustomer)
const [menuManagerOpen, setMenuManagerOpen] = useState(isInMenuManager) const [menuManagerOpen, setMenuManagerOpen] = useState(isInMenuManager)
const [backupOpen, setBackupOpen] = useState(isInBackup)
const [settingsOpen, setSettingsOpen] = useState(isInSettings) const [settingsOpen, setSettingsOpen] = useState(isInSettings)
const handleLogout = () => { const handleLogout = () => {
@@ -455,6 +468,36 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)} )}
</div> </div>
{/* Backup & Restore Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setBackupOpen)}
title={isCollapsed ? 'Backup & Restore' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInBackup ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<DatabaseBackup className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Backup & Restore</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${backupOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && backupOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{BACKUP_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Settings Group */} {/* Settings Group */}
<div className="pt-1"> <div className="pt-1">
<button <button
@@ -0,0 +1,184 @@
import { useState } from 'react'
import { Package, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types'
type ExportFormat = 'json' | 'csv'
type ExportSection = 'products' | 'categories' | 'attributes' | 'brands' | 'all'
const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
{ key: 'all', label: 'ทั้งหมด (Full Export)', description: 'รวม Products + Categories + Attributes + Brands' },
{ key: 'products', label: 'Products & Variants', description: 'Products, Variants, Images, Translations' },
{ key: 'categories', label: 'Categories', description: 'Categories + Translations' },
{ key: 'attributes', label: 'Product Attributes', description: 'AttributeDefinitions + ProductAttributes' },
{ key: 'brands', label: 'Product Brands', description: 'Brands + Translations' },
]
export default function BackupCatalogPage() {
const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
const [format, setFormat] = useState<ExportFormat>('json')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
const handleExport = async () => {
setLoading(true)
setResult(null)
try {
const res = await fetch(`${API_BASE}/backup/catalog?section=${selectedSection}&format=${format}`)
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Export failed' }))
setResult({ success: false, message: err.error || 'Export failed' })
return
}
const blob = await res.blob()
const ext = format === 'json' ? 'json' : 'csv'
const filename = `homaroma_catalog_${selectedSection}_${new Date().toISOString().slice(0,10)}.${ext}`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
} catch {
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
} finally {
setLoading(false)
}
}
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
<NavLink
to="/backup"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Backup & Restore
</NavLink>
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-cyan-500/10 rounded-xl border border-cyan-500/20">
<Package className="w-7 h-7 text-cyan-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Catalog Export</h1>
<p className="text-slate-400 text-sm mt-0.5">
Export Products, Categories, Attributes และ Brands เป็น JSON หรือ CSV
</p>
</div>
</div>
{/* Phase 2 notice */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-slate-400">
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/catalog</code>{' '}
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
</p>
</div>
<div className="admin-card p-6 space-y-6">
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-cyan-500/40 bg-cyan-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key
? 'border-cyan-400 bg-cyan-400'
: 'border-slate-600'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
</div>
</div>
{/* Format Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Format
</label>
<div className="flex gap-3">
{(['json', 'csv'] as ExportFormat[]).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
format === f
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-400'
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
}`}
>
{f}
</button>
))}
</div>
{format === 'json' && (
<p className="text-xs text-slate-500">JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ</p>
)}
{format === 'csv' && (
<p className="text-xs text-slate-500">CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets</p>
)}
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 text-sm font-bold uppercase tracking-widest hover:bg-cyan-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find(s => s.key === selectedSection)?.label}
</>
)}
</button>
{/* Result */}
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,175 @@
import { useState } from 'react'
import { FileJson, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types'
type ExportSection = 'all' | 'pages' | 'menus' | 'articles' | 'settings'
const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
{ key: 'all', label: 'ทั้งหมด (Full CMS Export)', description: 'Pages + Menus + Articles + Store Settings' },
{ key: 'pages', label: 'Pages & Sections', description: 'Page builder blocks และ Section configs ทั้งหมด' },
{ key: 'menus', label: 'Navigation Menus', description: 'Main Navigation + Footer Menus (Explore / Support / Brand)' },
{ key: 'articles', label: 'Articles (Scent Guide)', description: 'Articles, content และ Translations' },
{ key: 'settings', label: 'Store Settings', description: 'StoreSettings + Theme tokens + Translations' },
]
export default function BackupCmsPage() {
const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
const handleExport = async () => {
setLoading(true)
setResult(null)
try {
const res = await fetch(`${API_BASE}/backup/cms?section=${selectedSection}`)
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Export failed' }))
setResult({ success: false, message: err.error || 'Export failed' })
return
}
const blob = await res.blob()
const filename = `homaroma_cms_${selectedSection}_${new Date().toISOString().slice(0, 10)}.json`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
} catch {
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
} finally {
setLoading(false)
}
}
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
<NavLink
to="/backup"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Backup & Restore
</NavLink>
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-500/10 rounded-xl border border-amber-500/20">
<FileJson className="w-7 h-7 text-amber-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">CMS & Content Export</h1>
<p className="text-slate-400 text-sm mt-0.5">
Export Pages/Sections, Menus, Articles และ Store Settings เป็น JSON
</p>
</div>
</div>
{/* Phase 2 notice */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-slate-400">
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/cms</code>{' '}
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
</p>
</div>
<div className="admin-card p-6 space-y-6">
{/* What's included detail */}
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-3">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">ข้อมูลที่รวมอยู่ใน CMS Export</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
'Home Page sections', 'Shop Page sections',
'Product Detail sections', 'Category Page sections',
'All dynamic CMS pages', 'Main Navigation tree',
'Footer Explore menu', 'Footer Support menu',
'Footer Brand info', 'Articles (Scent Guide)',
'Store Settings', 'Theme tokens & colors',
'Newsletter settings', 'Footer copy & translations',
].map((item) => (
<div key={item} className="flex items-center gap-2 text-xs text-slate-400">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
{item}
</div>
))}
</div>
</div>
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-amber-500/40 bg-amber-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key ? 'border-amber-400 bg-amber-400' : 'border-slate-600'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
</div>
</div>
{/* Export Button — JSON only for CMS */}
<div className="space-y-2">
<p className="text-xs text-slate-600">Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)</p>
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm font-bold uppercase tracking-widest hover:bg-amber-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {SECTIONS.find(s => s.key === selectedSection)?.label}
</>
)}
</button>
</div>
{/* Result */}
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,204 @@
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import {
DatabaseBackup,
Package,
ShoppingCart,
FileJson,
FolderArchive,
Shield,
Clock,
AlertTriangle,
ChevronRight,
Info,
} from 'lucide-react'
interface BackupCard {
icon: React.ElementType
title: string
description: string
to: string
accentColor: string
badgeText?: string
badgeColor?: string
note?: string
}
const BACKUP_CARDS: BackupCard[] = [
{
icon: DatabaseBackup,
title: 'Full Database Backup',
description:
'Export PostgreSQL dump ทั้งหมด (.sql) ครอบคลุมทุกตาราง — ใช้สำหรับ Disaster Recovery',
to: '/backup/database',
accentColor: 'text-rose-400 bg-rose-500/10 border-rose-500/20',
badgeText: 'CRITICAL',
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
note: 'ต้องการ backend endpoint (Phase 2)',
},
{
icon: Package,
title: 'Catalog Export',
description:
'Products, Variants, Categories, Attributes, Brands และ Translations ทั้งหมด',
to: '/backup/catalog',
accentColor: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20',
badgeText: 'CRITICAL',
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
},
{
icon: ShoppingCart,
title: 'Orders & Customers Export',
description:
'Orders, Customers, Addresses, Coupons และ Promotions พร้อม Date Range Filter',
to: '/backup/orders',
accentColor: 'text-violet-400 bg-violet-500/10 border-violet-500/20',
badgeText: 'CRITICAL',
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
},
{
icon: FileJson,
title: 'CMS & Content Export',
description:
'Pages/Sections, Menus (Main + Footer), Articles, Store Settings',
to: '/backup/cms',
accentColor: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
badgeText: 'HIGH',
badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
},
{
icon: FolderArchive,
title: 'Media Files',
description:
'ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ — download เป็น .zip',
to: '/backup/media',
accentColor: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
badgeText: 'HIGH',
badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
note: 'ต้องการ backend endpoint (Phase 2)',
},
]
export default function BackupDashboardPage() {
const [acknowledged, setAcknowledged] = useState(false)
return (
<div className="max-w-5xl space-y-8 animate-fade-in">
{/* Header */}
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-cyan-500/10 rounded-xl border border-cyan-500/20">
<DatabaseBackup className="w-6 h-6 text-cyan-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Backup & Restore</h1>
<p className="text-slate-400 text-sm mt-0.5">
จัดการและดาวน์โหลดข้อมูลสำรองของระบบ HomAroma
</p>
</div>
</div>
</div>
{/* Warning Banner */}
{!acknowledged && (
<div className="admin-card p-5 border border-amber-500/30 bg-amber-500/5">
<div className="flex gap-4">
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-semibold text-amber-300">
คำแนะนำก่อนใช้งาน Backup
</p>
<ul className="text-xs text-slate-400 space-y-1 list-disc list-inside">
<li>ควร Backup ก่อนทุกครั้งที่จะทำการเปลี่ยนแปลงข้อมูลสำคัญ</li>
<li>เก็บไฟล์ Backup ไว้ในที่ปลอดภัย — ไม่ควรเก็บไว้บนเครื่องเดียวกับ Production</li>
<li>ทดสอบ Restore บน Staging ก่อนใช้ในกรณีฉุกเฉิน</li>
<li>ข้อมูลบางส่วน (Database Full Backup, Media Files) ต้องการ Backend API ซึ่งจะเปิดใช้งานใน Phase 2</li>
</ul>
<button
onClick={() => setAcknowledged(true)}
className="mt-2 text-xs font-bold text-amber-400 hover:text-amber-300 transition-colors underline underline-offset-2"
>
รับทราบและปิดข้อความนี้
</button>
</div>
</div>
</div>
)}
{/* Status Bar */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card p-4 flex items-center gap-3">
<div className="p-2 bg-emerald-500/10 rounded-lg">
<Shield className="w-4 h-4 text-emerald-400" />
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
<p className="text-sm font-semibold text-emerald-400">ระบบพร้อมใช้งาน</p>
</div>
</div>
<div className="admin-card p-4 flex items-center gap-3">
<div className="p-2 bg-slate-500/10 rounded-lg">
<Clock className="w-4 h-4 text-slate-400" />
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Last Backup</p>
<p className="text-sm font-semibold text-slate-300">ยังไม่มีประวัติ</p>
</div>
</div>
<div className="admin-card p-4 flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg">
<Info className="w-4 h-4 text-blue-400" />
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Phase</p>
<p className="text-sm font-semibold text-blue-400">1 — Manual Export</p>
</div>
</div>
</div>
{/* Backup Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{BACKUP_CARDS.map((card) => (
<NavLink
key={card.to}
to={card.to}
className="admin-card p-6 flex flex-col gap-4 group hover:border-white/15 transition-all duration-200 cursor-pointer"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl border ${card.accentColor}`}>
<card.icon className="w-5 h-5" />
</div>
<div>
<p className="font-semibold text-white text-sm">{card.title}</p>
{card.badgeText && (
<span className={`inline-block mt-1 text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-widest ${card.badgeColor}`}>
{card.badgeText}
</span>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors shrink-0 mt-1" />
</div>
<p className="text-sm text-slate-400 leading-relaxed">
{card.description}
</p>
{card.note && (
<div className="flex items-center gap-1.5 text-xs text-slate-600">
<Info className="w-3 h-3 shrink-0" />
<span>{card.note}</span>
</div>
)}
</NavLink>
))}
</div>
{/* Footer Note */}
<div className="text-xs text-slate-600 text-center pb-4">
Backup & Restore — Phase 1 (Manual Export) · Phase 2 จะเพิ่ม Scheduled Backup และ Server-side Storage
</div>
</div>
)
}
@@ -0,0 +1,172 @@
import { useState, useEffect } from 'react'
import { DatabaseBackup, AlertTriangle, CheckCircle2, Info, Terminal, ChevronLeft, Loader2, RefreshCw } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types'
interface DbStats {
scannedAt: string
tableCounts: Record<string, number>
pgDumpNote: string
}
export default function BackupDatabasePage() {
const [stats, setStats] = useState<DbStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadStats = async () => {
setLoading(true)
setError(null)
try {
const token = localStorage.getItem('homaroma_admin_token')
const res = await fetch(`${API_BASE}/backup/database`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
setError('ไม่สามารถดึงข้อมูลได้')
return
}
const data = await res.json()
setStats(data)
} catch {
setError('Network error — ไม่สามารถเชื่อมต่อ backend')
} finally {
setLoading(false)
}
}
useEffect(() => { loadStats() }, [])
const totalRows = stats
? Object.values(stats.tableCounts).reduce((a, b) => a + b, 0)
: 0
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
<NavLink
to="/backup"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Backup & Restore
</NavLink>
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-rose-500/10 rounded-xl border border-rose-500/20">
<DatabaseBackup className="w-7 h-7 text-rose-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Full Database Backup</h1>
<p className="text-slate-400 text-sm mt-0.5">
ภาพรวมข้อมูลในฐานข้อมูล + คำแนะนำการ Backup ผ่าน SSH
</p>
</div>
</div>
{/* Live DB Stats */}
<div className="admin-card p-6 space-y-5">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
Database Overview
</h2>
</div>
<button
onClick={loadStats}
disabled={loading}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{loading && (
<div className="flex items-center justify-center py-8 gap-3 text-slate-500">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">กำลังโหลดข้อมูล...</span>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 p-3 bg-red-500/10 rounded-lg border border-red-500/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{stats && !loading && (
<>
<div className="grid grid-cols-3 gap-3 mb-2">
<div className="bg-white/3 rounded-xl border border-white/5 p-3 text-center">
<p className="text-2xl font-bold text-white">{totalRows.toLocaleString()}</p>
<p className="text-xs text-slate-500 mt-1">Total Rows</p>
</div>
<div className="bg-white/3 rounded-xl border border-white/5 p-3 text-center">
<p className="text-2xl font-bold text-white">{Object.keys(stats.tableCounts).length}</p>
<p className="text-xs text-slate-500 mt-1">Tables Scanned</p>
</div>
<div className="bg-white/3 rounded-xl border border-white/5 p-3 text-center">
<p className="text-xs font-mono text-emerald-400 break-all">{new Date(stats.scannedAt).toLocaleTimeString('th-TH')}</p>
<p className="text-xs text-slate-500 mt-1">Last Scanned</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(stats.tableCounts).map(([table, count]) => (
<div key={table} className="flex items-center justify-between py-2 px-3 bg-white/3 rounded-lg border border-white/5">
<span className="text-xs text-slate-400 capitalize">{table.replace(/([A-Z])/g, ' $1').trim()}</span>
<span className="text-sm font-bold text-white">{count.toLocaleString()}</span>
</div>
))}
</div>
<p className="text-[10px] text-slate-600 text-right">
Scanned at {new Date(stats.scannedAt).toLocaleString('th-TH')}
</p>
</>
)}
</div>
{/* pg_dump instructions */}
<div className="admin-card p-6 space-y-4">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
<Terminal className="w-5 h-5 text-amber-400" />
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
Manual Backup (ผ่าน SSH)
</h2>
</div>
<p className="text-sm text-slate-400">
สำหรับ Full Database Backup ให้ใช้วิธีนี้ผ่าน SSH บน Production Server:
</p>
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
<p className="text-slate-500"># SSH เข้า server</p>
<p>ssh ti@kitizone.com</p>
<p className="text-slate-500 mt-3"># ตรวจสอบชื่อ database จาก .env</p>
<p>{'cat /home/ti/projects/homaroma-api/.env | grep DATABASE_URL'}</p>
<p className="text-slate-500 mt-3"># สร้าง pg_dump (แทนที่ <dbname> ด้วยชื่อจริง)</p>
<p>{'pg_dump -U postgres <dbname> > ~/backup_$(date +%Y%m%d_%H%M).sql'}</p>
<p className="text-slate-500 mt-3"># Download กลับมาเครื่อง local</p>
<p>{'scp ti@kitizone.com:~/backup_*.sql ./'}</p>
</div>
<div className="flex items-start gap-2 text-xs text-amber-400/70">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<p>Full pg_dump ต้องการ shell access — ไม่สามารถทำผ่าน Admin UI ได้ในขณะนี้</p>
</div>
</div>
{/* Phase 2 notice */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-slate-400">
ข้อมูลสรุปด้านบนดึงมาจาก{' '}
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /api/backup/database</code>{' '}
— สำหรับ pg_dump ผ่าน UI จะต้องการ shell execution capability บน backend ซึ่งอยู่ใน roadmap ถัดไป
</p>
</div>
</div>
)
}
@@ -0,0 +1,160 @@
import { useState } from 'react'
import { FolderArchive, Download, ChevronLeft, Info, AlertTriangle, Loader2, CheckCircle2, Image, FileVideo, File } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types'
export default function BackupMediaPage() {
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
const handleDownload = async () => {
setLoading(true)
setResult(null)
try {
const res = await fetch(`${API_BASE}/backup/media/zip`)
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Download failed' }))
setResult({ success: false, message: err.error || 'Download failed' })
return
}
const blob = await res.blob()
const filename = `homaroma_media_${new Date().toISOString().slice(0, 10)}.zip`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
} catch {
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
} finally {
setLoading(false)
}
}
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
<NavLink
to="/backup"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Backup & Restore
</NavLink>
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-emerald-500/10 rounded-xl border border-emerald-500/20">
<FolderArchive className="w-7 h-7 text-emerald-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Media Files Backup</h1>
<p className="text-slate-400 text-sm mt-0.5">
Download ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip
</p>
</div>
</div>
{/* Phase 2 Notice */}
<div className="admin-card p-6 border border-blue-500/20 bg-blue-500/5 space-y-4">
<div className="flex items-center gap-3">
<Info className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<p className="font-semibold text-blue-300 text-sm">Phase 2 Feature</p>
<p className="text-xs text-slate-500 mt-0.5">
Media Backup ต้องการ Backend API endpoint ที่จะพัฒนาใน Phase 2
</p>
</div>
</div>
<p className="text-sm text-slate-400 leading-relaxed">
เมื่อ Backend endpoint{' '}
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">GET /backup/media/zip</code>{' '}
พร้อม ระบบจะ zip โฟลเดอร์{' '}
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">public/uploads/</code>{' '}
และ stream ไฟล์กลับมา download โดยตรง
</p>
</div>
{/* What's included */}
<div className="admin-card p-6 space-y-5">
<h2 className="text-sm font-bold text-slate-400 uppercase tracking-widest border-b border-white/5 pb-3">
ไฟล์ที่จะถูก Backup
</h2>
<div className="space-y-3">
{[
{ icon: Image, label: 'Product Images', desc: 'รูปภาพสินค้าทุกตัวที่อัปโหลดผ่าน Admin', color: 'text-cyan-400' },
{ icon: Image, label: 'Category Images', desc: 'รูป Thumbnail ของแต่ละหมวดหมู่สินค้า', color: 'text-violet-400' },
{ icon: Image, label: 'Article Images', desc: 'รูปประกอบบทความ Scent Guide', color: 'text-amber-400' },
{ icon: Image, label: 'CMS Media', desc: 'รูปที่ใช้ใน Page Builder sections', color: 'text-emerald-400' },
{ icon: FileVideo, label: 'Video Assets', desc: 'วิดีโอหรือ GIF ที่ใช้ใน hero sections', color: 'text-rose-400' },
{ icon: File, label: 'Other Assets', desc: 'ไฟล์อื่นๆ ใน public/uploads/', color: 'text-slate-400' },
].map(({ icon: Icon, label, desc, color }) => (
<div key={label} className="flex items-center gap-3 py-2 border-b border-white/5 last:border-0">
<Icon className={`w-4 h-4 shrink-0 ${color}`} />
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-slate-500">{desc}</p>
</div>
</div>
))}
</div>
</div>
{/* Size Warning */}
<div className="flex items-start gap-3 text-sm text-amber-400/80">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-xs">
ไฟล์ Media อาจมีขนาดใหญ่มาก (หลาย GB) — ใช้ SCP หรือ rsync ผ่าน SSH สำหรับ Production Server เพื่อประสิทธิภาพที่ดีกว่า
</p>
</div>
{/* Manual SCP */}
<div className="admin-card p-5 space-y-3">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Manual Download (ผ่าน SSH/SCP)</p>
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
<p className="text-slate-500"># rsync media folder จาก production</p>
<p>{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}</p>
</div>
</div>
{/* Download Button */}
<div className="admin-card p-6 space-y-4">
<p className="text-sm text-slate-400">
เมื่อ Phase 2 พร้อม ปุ่มด้านล่างจะ trigger download .zip ของ /uploads/ ทั้งหมด
</p>
<button
onClick={handleDownload}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Download...
</>
) : (
<>
<Download className="w-4 h-4" />
Download Media Files (.zip)
</>
)}
</button>
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,239 @@
import { useState } from 'react'
import { ShoppingCart, Download, CheckCircle2, ChevronLeft, Loader2, Info, AlertCircle } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types'
type ExportFormat = 'json' | 'csv'
type ExportSection = 'all' | 'orders' | 'customers' | 'coupons' | 'promotions'
const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
{ key: 'all', label: 'ทั้งหมด (Full Export)', description: 'Orders + Customers + Coupons + Promotions' },
{ key: 'orders', label: 'Orders', description: 'Orders พร้อม OrderItems และ Tracking' },
{ key: 'customers', label: 'Customers & Addresses', description: 'ข้อมูลลูกค้าและที่อยู่' },
{ key: 'coupons', label: 'Coupons', description: 'Coupon codes และ usage stats' },
{ key: 'promotions', label: 'Promotions', description: 'Promotion rules ทั้งหมด' },
]
function formatDate(d: Date) {
return d.toISOString().slice(0, 10)
}
export default function BackupOrdersPage() {
const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
const [format, setFormat] = useState<ExportFormat>('json')
const [useDateRange, setUseDateRange] = useState(false)
const [dateFrom, setDateFrom] = useState(formatDate(new Date(Date.now() - 30 * 86400_000)))
const [dateTo, setDateTo] = useState(formatDate(new Date()))
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
const handleExport = async () => {
setLoading(true)
setResult(null)
try {
const params = new URLSearchParams({ section: selectedSection, format })
if (useDateRange) {
params.set('from', dateFrom)
params.set('to', dateTo)
}
const res = await fetch(`${API_BASE}/backup/orders?${params}`)
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Export failed' }))
setResult({ success: false, message: err.error || 'Export failed' })
return
}
const blob = await res.blob()
const ext = format === 'json' ? 'json' : 'csv'
const suffix = useDateRange ? `${dateFrom}_to_${dateTo}` : 'all_time'
const filename = `homaroma_orders_${selectedSection}_${suffix}.${ext}`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
} catch {
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
} finally {
setLoading(false)
}
}
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
<NavLink
to="/backup"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Backup & Restore
</NavLink>
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-violet-500/10 rounded-xl border border-violet-500/20">
<ShoppingCart className="w-7 h-7 text-violet-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Orders & Customers Export</h1>
<p className="text-slate-400 text-sm mt-0.5">
Export Orders, Customers, Coupons และ Promotions พร้อม Date Range Filter
</p>
</div>
</div>
{/* Phase 2 notice */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-slate-400">
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/orders</code>{' '}
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
</p>
</div>
<div className="admin-card p-6 space-y-6">
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-violet-500/40 bg-violet-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key ? 'border-violet-400 bg-violet-400' : 'border-slate-600'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
</div>
</div>
{/* Date Range */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Date Range Filter
</label>
<button
onClick={() => setUseDateRange(v => !v)}
className={`text-xs font-semibold px-3 py-1 rounded-lg border transition-all ${
useDateRange
? 'border-violet-500/40 bg-violet-500/10 text-violet-400'
: 'border-white/10 text-slate-500 hover:text-slate-300'
}`}
>
{useDateRange ? 'เปิดใช้งาน' : 'ปิดใช้งาน'}
</button>
</div>
{useDateRange && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-slate-500">จากวันที่</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-500">ถึงวันที่</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50"
/>
</div>
</div>
)}
{!useDateRange && (
<p className="text-xs text-slate-600">Export ข้อมูลทุกช่วงเวลา (ไม่จำกัดวันที่)</p>
)}
</div>
{/* Format Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">Format</label>
<div className="flex gap-3">
{(['json', 'csv'] as ExportFormat[]).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
format === f
? 'border-violet-500/40 bg-violet-500/10 text-violet-400'
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
}`}
>
{f}
</button>
))}
</div>
</div>
{/* Privacy note */}
<div className="flex items-start gap-2 text-xs text-amber-400/70">
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<p>ข้อมูล Customer มีข้อมูลส่วนตัว — เก็บไฟล์ที่ Export ไว้ในที่ปลอดภัยและไม่แชร์โดยไม่จำเป็น</p>
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-violet-500/10 border border-violet-500/30 text-violet-400 text-sm font-bold uppercase tracking-widest hover:bg-violet-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {SECTIONS.find(s => s.key === selectedSection)?.label}
</>
)}
</button>
{/* Result */}
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
</div>
)
}
+2 -1
View File
@@ -28,6 +28,7 @@ import { newsletterRoute } from "./routes/newsletter";
import { mediaRoute } from "./routes/media"; import { mediaRoute } from "./routes/media";
import { contactsRoute } from "./routes/contacts"; import { contactsRoute } from "./routes/contacts";
import { productAttributesRoute } from "./routes/product-attributes"; import { productAttributesRoute } from "./routes/product-attributes";
import { backupRoute } from "./routes/backup";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -135,7 +136,7 @@ const app = new Elysia()
return file; return file;
}) })
.group("/api", (app) => .group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute) app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute)
) )
.listen({ port: PORT, hostname: "0.0.0.0" }); .listen({ port: PORT, hostname: "0.0.0.0" });
+590
View File
@@ -0,0 +1,590 @@
import { Elysia } from "elysia";
import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import path from "path";
import fs from "fs";
// ─── Helper: stream JSON as file download ────────────────────────────────────
function jsonResponse(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2);
const bytes = Buffer.from(json, "utf-8");
return new Response(bytes, {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(bytes.length),
},
});
}
function csvResponse(rows: Record<string, unknown>[], filename: string) {
if (!rows || rows.length === 0) {
return new Response("", {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
const firstRow = rows[0];
if (!firstRow) {
return new Response("", {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
const headers = Object.keys(firstRow);
const csvLines = [
headers.join(","),
...rows.map((row) =>
headers
.map((h) => {
const val = (row as any)[h];
if (val === null || val === undefined) return "";
const str = typeof val === "object" ? JSON.stringify(val) : String(val);
return `"${str.replace(/"/g, '""')}"`;
})
.join(",")
),
];
const csv = csvLines.join("\r\n");
const bytes = Buffer.from(csv, "utf-8");
return new Response(bytes, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(bytes.length),
},
});
}
function today() {
return new Date().toISOString().slice(0, 10);
}
// ─── Route ───────────────────────────────────────────────────────────────────
export const backupRoute = new Elysia({ prefix: "/backup" })
.use(adminOnly)
// ── 1. Catalog Export ──────────────────────────────────────────────────────
/**
* GET /api/backup/catalog?section=all|products|categories|attributes|brands&format=json|csv
* Exports product catalog data as JSON or CSV download.
*/
.get("/catalog", async ({ query, set }: { query: any; set: any }) => {
try {
const section = (query.section as string) || "all";
const format = (query.format as string) === "csv" ? "csv" : "json";
const dt = today();
if (section === "products" || section === "all") {
const products = await prisma.product.findMany({
include: {
variants: true,
images: true,
translations: true,
attributes: { include: { attributeDefinition: true, translations: true } },
brand: { include: { translations: true } },
category: { select: { id: true, name: true } },
},
orderBy: { id: "asc" },
});
if (section === "products") {
if (format === "csv") {
const rows = products.map((p: any) => ({
id: p.id,
name: p.name,
description: p.description,
categoryId: p.categoryId,
brandId: p.brandId,
variantCount: p.variants.length,
imageCount: p.images.length,
createdAt: p.createdAt,
}));
return csvResponse(rows, `homaroma_products_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: products.length, data: products }, `homaroma_products_${dt}.json`);
}
// full catalog
const categories = await prisma.category.findMany({
where: { deletedAt: null },
include: { translations: true },
orderBy: { id: "asc" },
});
const attributes = await prisma.attributeDefinition.findMany({
include: { translations: true },
orderBy: { sortOrder: "asc" },
});
const brands = await prisma.productBrand.findMany({
include: { translations: true },
orderBy: { id: "asc" },
});
if (format === "csv") {
// For CSV full export, flatten products only (complex nested data → JSON)
const rows = products.map((p: any) => ({
id: p.id,
name: p.name,
description: p.description,
categoryId: p.categoryId,
categoryName: p.category?.name,
brandId: p.brandId,
brandName: p.brand?.name,
variantCount: p.variants.length,
imageCount: p.images.length,
createdAt: p.createdAt,
}));
return csvResponse(rows, `homaroma_catalog_${dt}.csv`);
}
return jsonResponse(
{
exportedAt: new Date().toISOString(),
summary: {
products: products.length,
categories: categories.length,
attributes: attributes.length,
brands: brands.length,
},
data: { products, categories, attributes, brands },
},
`homaroma_catalog_all_${dt}.json`
);
}
if (section === "categories") {
const categories = await prisma.category.findMany({
where: { deletedAt: null },
include: { translations: true },
orderBy: { id: "asc" },
});
if (format === "csv") {
const rows = categories.map((c: any) => ({
id: c.id,
name: c.name,
imageUrl: c.imageUrl,
createdAt: c.createdAt,
}));
return csvResponse(rows, `homaroma_categories_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: categories.length, data: categories }, `homaroma_categories_${dt}.json`);
}
if (section === "attributes") {
const attributes = await prisma.attributeDefinition.findMany({
include: { translations: true },
orderBy: { sortOrder: "asc" },
});
if (format === "csv") {
const rows = attributes.map((a: any) => ({
id: a.id,
code: a.code,
label: a.label,
type: a.type,
unit: a.unit,
isRequired: a.isRequired,
isFilterable: a.isFilterable,
sortOrder: a.sortOrder,
}));
return csvResponse(rows, `homaroma_attributes_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: attributes.length, data: attributes }, `homaroma_attributes_${dt}.json`);
}
if (section === "brands") {
const brands = await prisma.productBrand.findMany({
include: { translations: true },
orderBy: { id: "asc" },
});
if (format === "csv") {
const rows = brands.map((b: any) => ({ id: b.id, name: b.name, createdAt: b.createdAt }));
return csvResponse(rows, `homaroma_brands_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: brands.length, data: brands }, `homaroma_brands_${dt}.json`);
}
set.status = 400;
return { error: `Unknown section: ${section}. Valid values: all, products, categories, attributes, brands` };
} catch (err: any) {
console.error("[Backup] Catalog export error:", err);
set.status = 500;
return { error: "Catalog export failed", detail: err?.message };
}
})
// ── 2. Orders & Customers Export ──────────────────────────────────────────
/**
* GET /api/backup/orders?section=all|orders|customers|coupons|promotions&format=json|csv&from=YYYY-MM-DD&to=YYYY-MM-DD
*/
.get("/orders", async ({ query, set }: { query: any; set: any }) => {
try {
const section = (query.section as string) || "all";
const format = (query.format as string) === "csv" ? "csv" : "json";
const from = query.from as string | undefined;
const to = query.to as string | undefined;
const dt = today();
const dateFilter =
from || to
? {
createdAt: {
...(from ? { gte: new Date(`${from}T00:00:00.000Z`) } : {}),
...(to ? { lte: new Date(`${to}T23:59:59.999Z`) } : {}),
},
}
: {};
if (section === "orders" || section === "all") {
const orders = await prisma.order.findMany({
where: dateFilter,
include: { items: true, customer: { select: { id: true, displayName: true, lineUserId: true } } },
orderBy: { createdAt: "desc" },
});
if (section === "orders") {
if (format === "csv") {
const rows = orders.map((o: any) => ({
id: o.id,
customerName: o.customerName,
customerEmail: o.customerEmail,
customerPhone: o.customerPhone,
totalAmount: o.totalAmount,
status: o.status,
trackingNo: o.trackingNo,
createdAt: o.createdAt,
}));
return csvResponse(rows, `homaroma_orders_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: orders.length, data: orders }, `homaroma_orders_${dt}.json`);
}
// full
const customers = await prisma.customer.findMany({
include: { addresses: true },
orderBy: { id: "asc" },
});
const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } });
const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } });
if (format === "csv") {
const rows = orders.map((o: any) => ({
id: o.id,
customerName: o.customerName,
customerEmail: o.customerEmail,
totalAmount: o.totalAmount,
status: o.status,
createdAt: o.createdAt,
}));
return csvResponse(rows, `homaroma_orders_all_${dt}.csv`);
}
return jsonResponse(
{
exportedAt: new Date().toISOString(),
dateRange: from || to ? { from: from ?? "all", to: to ?? "all" } : "all",
summary: {
orders: orders.length,
customers: customers.length,
coupons: coupons.length,
promotions: promotions.length,
},
data: { orders, customers, coupons, promotions },
},
`homaroma_orders_full_${dt}.json`
);
}
if (section === "customers") {
const customers = await prisma.customer.findMany({
include: { addresses: true },
orderBy: { id: "asc" },
});
if (format === "csv") {
const rows = customers.map((c: any) => ({
id: c.id,
displayName: c.displayName,
email: c.email,
phone: c.phone,
points: c.points,
createdAt: c.createdAt,
}));
return csvResponse(rows, `homaroma_customers_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: customers.length, data: customers }, `homaroma_customers_${dt}.json`);
}
if (section === "coupons") {
const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } });
if (format === "csv") {
const rows = coupons.map((c: any) => ({
id: c.id,
code: c.code,
type: c.type,
value: c.value,
usageLimit: c.usageLimit,
usedCount: c.usedCount,
isActive: c.isActive,
expiresAt: c.expiresAt,
}));
return csvResponse(rows, `homaroma_coupons_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: coupons.length, data: coupons }, `homaroma_coupons_${dt}.json`);
}
if (section === "promotions") {
const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } });
if (format === "csv") {
const rows = promotions.map((p: any) => ({
id: p.id,
name: p.name,
level: p.level,
type: p.type,
value: p.value,
minPurchase: p.minPurchase,
isActive: p.isActive,
}));
return csvResponse(rows, `homaroma_promotions_${dt}.csv`);
}
return jsonResponse({ exportedAt: new Date().toISOString(), count: promotions.length, data: promotions }, `homaroma_promotions_${dt}.json`);
}
set.status = 400;
return { error: `Unknown section: ${section}. Valid values: all, orders, customers, coupons, promotions` };
} catch (err: any) {
console.error("[Backup] Orders export error:", err);
set.status = 500;
return { error: "Orders export failed", detail: err?.message };
}
})
// ── 3. CMS & Content Export ────────────────────────────────────────────────
/**
* GET /api/backup/cms?section=all|pages|menus|articles|settings
* Always returns JSON (CMS data is structured — CSV not useful here).
*/
.get("/cms", async ({ query, set }: { query: any; set: any }) => {
try {
const section = (query.section as string) || "all";
const dt = today();
if (section === "pages" || section === "all") {
const pages = await prisma.page.findMany({
include: { sections: true, translations: true },
orderBy: { id: "asc" },
});
if (section === "pages") {
return jsonResponse({ exportedAt: new Date().toISOString(), count: pages.length, data: pages }, `homaroma_cms_pages_${dt}.json`);
}
// fetch rest for full export
const menus = await prisma.menu.findMany({
include: { items: { include: { translations: true } } },
orderBy: { id: "asc" },
});
const articles = await prisma.article.findMany({
include: { translations: true },
orderBy: { createdAt: "desc" },
});
const settings = await prisma.storeSettings.findFirst({
include: { translations: true },
});
const footerMenus = await prisma.footerMenu.findMany({
include: { items: true },
orderBy: { id: "asc" },
}).catch(() => []); // graceful if table not present
return jsonResponse(
{
exportedAt: new Date().toISOString(),
summary: {
pages: pages.length,
menus: menus.length,
articles: articles.length,
hasSettings: !!settings,
},
data: { pages, menus, footerMenus, articles, settings },
},
`homaroma_cms_all_${dt}.json`
);
}
if (section === "menus") {
const menus = await prisma.menu.findMany({
include: { items: { include: { translations: true } } },
orderBy: { id: "asc" },
});
const footerMenus = await prisma.footerMenu.findMany({
include: { items: true },
orderBy: { id: "asc" },
}).catch(() => []);
return jsonResponse(
{ exportedAt: new Date().toISOString(), data: { menus, footerMenus } },
`homaroma_cms_menus_${dt}.json`
);
}
if (section === "articles") {
const articles = await prisma.article.findMany({
include: { translations: true },
orderBy: { createdAt: "desc" },
});
return jsonResponse({ exportedAt: new Date().toISOString(), count: articles.length, data: articles }, `homaroma_cms_articles_${dt}.json`);
}
if (section === "settings") {
const settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
return jsonResponse({ exportedAt: new Date().toISOString(), data: settings }, `homaroma_cms_settings_${dt}.json`);
}
set.status = 400;
return { error: `Unknown section: ${section}. Valid values: all, pages, menus, articles, settings` };
} catch (err: any) {
console.error("[Backup] CMS export error:", err);
set.status = 500;
return { error: "CMS export failed", detail: err?.message };
}
})
// ── 4. Media Files — list ──────────────────────────────────────────────────
/**
* GET /api/backup/media
* Returns JSON list of all media file records + filesystem info.
*/
.get("/media", async ({ set }: { set: any }) => {
try {
const media = await prisma.media.findMany({ orderBy: { createdAt: "desc" } });
const uploadsDir = path.resolve("public/uploads");
let fsFiles: string[] = [];
try {
fsFiles = fs.readdirSync(uploadsDir);
} catch {
// uploads dir may not exist yet
}
return {
exportedAt: new Date().toISOString(),
dbRecords: media.length,
fsFiles: fsFiles.length,
uploadsPath: uploadsDir,
media,
};
} catch (err: any) {
console.error("[Backup] Media list error:", err);
set.status = 500;
return { error: "Media list failed", detail: err?.message };
}
})
// ── 5. Media Files — zip download ─────────────────────────────────────────
/**
* GET /api/backup/media/zip
* Zips the public/uploads/ directory and streams the .zip file.
* Uses Bun's native spawn to call system zip command.
*/
.get("/media/zip", async ({ set }: { set: any }) => {
try {
const uploadsDir = path.resolve("public/uploads");
if (!fs.existsSync(uploadsDir)) {
set.status = 404;
return { error: "uploads directory not found", path: uploadsDir };
}
const dt = today();
const tmpZip = path.join("/tmp", `homaroma_media_${dt}_${Date.now()}.zip`);
// Use Bun.spawn to call system zip
const proc = Bun.spawn(["zip", "-r", tmpZip, "."], {
cwd: uploadsDir,
stderr: "pipe",
});
await proc.exited;
if (!fs.existsSync(tmpZip)) {
set.status = 500;
return { error: "zip creation failed — ensure `zip` is installed on the server" };
}
const fileData = await Bun.file(tmpZip).arrayBuffer();
// Clean up temp file
try { fs.unlinkSync(tmpZip); } catch { /* ignore */ }
return new Response(fileData, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="homaroma_media_${dt}.zip"`,
"Content-Length": String(fileData.byteLength),
},
});
} catch (err: any) {
console.error("[Backup] Media zip error:", err);
set.status = 500;
return { error: "Media zip failed", detail: err?.message };
}
})
// ── 6. Database info ──────────────────────────────────────────────────────
/**
* GET /api/backup/database
* Returns table row counts for dashboard display.
* Full pg_dump requires shell access — provide instructions.
*/
.get("/database", async ({ set }: { set: any }) => {
try {
const [
products, categories, customers, orders,
articles, pages, menus, coupons, promotions,
media, subscribers, rawMaterials, tiktokOrders, shopeeOrders, lazadaOrders,
] = await Promise.all([
prisma.product.count(),
prisma.category.count({ where: { deletedAt: null } }),
prisma.customer.count(),
prisma.order.count(),
prisma.article.count(),
prisma.page.count(),
prisma.menu.count(),
prisma.coupon.count(),
prisma.promotion.count(),
prisma.media.count(),
prisma.newsletterSubscription.count().catch(() => 0),
prisma.rawMaterial.count(),
prisma.tiktokOrder.count(),
prisma.shopeeOrder.count(),
prisma.lazadaOrder.count(),
]);
return {
scannedAt: new Date().toISOString(),
tableCounts: {
products,
categories,
customers,
orders,
articles,
pages,
menus,
coupons,
promotions,
media,
subscribers,
rawMaterials,
tiktokOrders,
shopeeOrders,
lazadaOrders,
},
pgDumpNote: "Full pg_dump requires shell access. Use SSH: pg_dump -U postgres <dbname> > backup.sql",
};
} catch (err: any) {
console.error("[Backup] Database info error:", err);
set.status = 500;
return { error: "Database info failed", detail: err?.message };
}
});