x
This commit is contained in:
1 parent
7cd288e67e
commit
ed69a4b840
6 files changed
+3
-626
No files matched your search
@@ -5,7 +5,6 @@ import ProductsPage from './pages/ProductsPage'
|
|||||||
import ProductEditPage from './pages/ProductEditPage'
|
import ProductEditPage from './pages/ProductEditPage'
|
||||||
import CategoriesPage from './pages/CategoriesPage'
|
import CategoriesPage from './pages/CategoriesPage'
|
||||||
import CustomersPage from './pages/CustomersPage'
|
import CustomersPage from './pages/CustomersPage'
|
||||||
import HeroSettingsPage from './pages/HeroSettingsPage'
|
|
||||||
import PageBuilderPage from './pages/PageBuilderPage'
|
import PageBuilderPage from './pages/PageBuilderPage'
|
||||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
@@ -50,7 +49,6 @@ function App() {
|
|||||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/hero" element={<HeroSettingsPage />} />
|
|
||||||
<Route path="/page-builder" element={<PageBuilderPage />} />
|
<Route path="/page-builder" element={<PageBuilderPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
CheckCircle2,
|
|
||||||
Circle,
|
|
||||||
Image as ImageIcon,
|
|
||||||
UploadCloud,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
ExternalLink,
|
|
||||||
ChevronRight,
|
|
||||||
Info,
|
|
||||||
ClipboardPaste
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { API_BASE, type Hero } from '@homaroma/types'
|
|
||||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
|
||||||
|
|
||||||
export default function HeroSettingsPage() {
|
|
||||||
const [heroes, setHeroes] = useState<Hero[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
|
||||||
const [editingHero, setEditingHero] = useState<Partial<Hero> | null>(null)
|
|
||||||
|
|
||||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
|
||||||
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/hero/all`)
|
|
||||||
const data = await res.json()
|
|
||||||
setHeroes(data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fetch error:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!editingHero) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/hero`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(editingHero)
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
setIsModalOpen(false)
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save error:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivate = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/hero/${id}/activate`, {
|
|
||||||
method: 'PATCH'
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Activate error:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this Hero entry?')) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/hero/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete error:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
|
||||||
setIsUploading(true)
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.url) {
|
|
||||||
setEditingHero(prev => ({ ...prev, imageUrl: data.url }))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload error:', err)
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasteFromClipboard = async () => {
|
|
||||||
try {
|
|
||||||
const items = await navigator.clipboard.read()
|
|
||||||
for (const item of items) {
|
|
||||||
const imageTypes = item.types.filter(t => t.startsWith('image/'))
|
|
||||||
for (const type of imageTypes) {
|
|
||||||
const blob = await item.getType(type)
|
|
||||||
const file = new File([blob], 'pasted-image.png', { type })
|
|
||||||
handleUpload(file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert('No image found in clipboard')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Clipboard error:', err)
|
|
||||||
alert('Please allow clipboard access or copy an image first.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAddModal = () => {
|
|
||||||
setEditingHero({
|
|
||||||
tagline: 'A New Paradigm of Calm',
|
|
||||||
title: 'Inhale [Serenity]',
|
|
||||||
description: 'Crafting premium sensory experiences that transform your space into a sanctuary of peace.',
|
|
||||||
imageUrl: '/images/hero.png',
|
|
||||||
buttonText: 'Explore Collection',
|
|
||||||
buttonLink: '/shop',
|
|
||||||
isActive: false
|
|
||||||
})
|
|
||||||
setIsModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditModal = (hero: Hero) => {
|
|
||||||
setEditingHero(hero)
|
|
||||||
setIsModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-fade-in">
|
|
||||||
{!isModalOpen ? (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-3xl font-bold">Hero Management</h1>
|
|
||||||
<p className="text-slate-500 text-sm">Control the first impression of your sanctuary.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={openAddModal}
|
|
||||||
className="btn-primary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Create New Hero
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
{loading ? (
|
|
||||||
Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-48 admin-card bg-white/5 animate-pulse" />
|
|
||||||
))
|
|
||||||
) : heroes.length === 0 ? (
|
|
||||||
<div className="admin-card py-20 text-center space-y-4">
|
|
||||||
<LayoutDashboard className="w-12 h-12 text-slate-700 mx-auto" />
|
|
||||||
<p className="text-slate-500 italic">No hero entries found. Create your first one!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
heroes.map((hero) => (
|
|
||||||
<div
|
|
||||||
key={hero.id}
|
|
||||||
className={`admin-card overflow-hidden group border-l-4 transition-all duration-500 ${
|
|
||||||
hero.isActive ? 'border-l-cyan-glow' : 'border-l-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
{/* Preview Image */}
|
|
||||||
<div className="lg:w-96 aspect-video rounded-2xl overflow-hidden border border-white/5 bg-night-950/50 flex-shrink-0 relative">
|
|
||||||
<img
|
|
||||||
src={hero.imageUrl}
|
|
||||||
className={`w-full h-full object-cover transition-all duration-700 ${hero.isActive ? 'grayscale-0' : 'grayscale opacity-40'}`}
|
|
||||||
alt="Hero Preview"
|
|
||||||
/>
|
|
||||||
{!hero.isActive && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-night-950/40 backdrop-blur-[2px]">
|
|
||||||
<span className="px-3 py-1 bg-night-950 border border-white/10 rounded-full text-[10px] font-bold uppercase tracking-widest text-slate-500">Inactive</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 flex flex-col justify-between py-2">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="px-2 py-1 bg-cyan-glow/10 text-cyan-glow text-[10px] font-bold uppercase tracking-widest border border-cyan-glow/20 rounded-md">
|
|
||||||
{hero.tagline}
|
|
||||||
</span>
|
|
||||||
{hero.isActive && (
|
|
||||||
<span className="flex items-center gap-1.5 text-cyan-glow text-[10px] font-bold uppercase tracking-widest animate-pulse">
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
||||||
Live on Site
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-slate-600 font-mono">ID: HERO-{hero.id.toString().padStart(3, '0')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-2xl font-bold flex items-baseline gap-2">
|
|
||||||
{hero.title.split('[').map((part, i) => {
|
|
||||||
if (part.includes(']')) {
|
|
||||||
const [accent, rest] = part.split(']')
|
|
||||||
return (
|
|
||||||
<span key={i}>
|
|
||||||
<span className="text-cyan-glow italic font-serif">{accent}</span>
|
|
||||||
{rest}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <span key={i}>{part}</span>
|
|
||||||
})}
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-400 text-sm leading-relaxed max-w-2xl line-clamp-2">
|
|
||||||
{hero.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 pt-2">
|
|
||||||
<div className="flex items-center gap-2 group/btn">
|
|
||||||
<span className="w-8 h-px bg-white/10 group-hover/btn:bg-cyan-glow/30 transition-colors" />
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-cyan-glow">{hero.buttonText}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-slate-600 text-[10px] font-bold">
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
{hero.buttonLink}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-6 lg:pt-0">
|
|
||||||
{!hero.isActive && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleActivate(hero.id)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-white text-xs font-bold transition-all border border-white/5"
|
|
||||||
>
|
|
||||||
<Circle className="w-4 h-4" />
|
|
||||||
Set Active
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(hero)}
|
|
||||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
|
|
||||||
title="Edit Hero"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(hero.id)}
|
|
||||||
className="p-2.5 rounded-xl bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white transition-all border border-rose-500/10"
|
|
||||||
title="Delete Entry"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
editingHero && (
|
|
||||||
<div className="w-full bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-fade-in">
|
|
||||||
<div className="p-6 border-b border-white/5 flex flex-shrink-0 items-center justify-between bg-white/[0.02]">
|
|
||||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
|
||||||
<LayoutDashboard className="w-5 h-5 text-cyan-glow" />
|
|
||||||
{editingHero.id ? 'Edit Hero Section' : 'Create New Hero'}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="p-2 rounded-xl hover:bg-white/5 text-slate-500 hover:text-white transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="p-8 space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* Image Upload Area */}
|
|
||||||
<div className="md:col-span-2 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Background Image</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handlePasteFromClipboard}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20"
|
|
||||||
>
|
|
||||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
|
||||||
Paste Image
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-dashed border-white/5 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
|
||||||
onClick={() => {
|
|
||||||
setOnSelectMedia(() => (url: string) => setEditingHero(prev => ({ ...prev, imageUrl: url })))
|
|
||||||
setIsMediaModalOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{editingHero.imageUrl ? (
|
|
||||||
<>
|
|
||||||
<img src={editingHero.imageUrl} className="w-full h-full object-cover" alt="Hero Background" />
|
|
||||||
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
|
||||||
<UploadCloud className="w-10 h-10 text-white" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<ImageIcon className="w-10 h-10 text-slate-700 mx-auto" />
|
|
||||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Image</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUploading && (
|
|
||||||
<div className="absolute inset-0 bg-night-950/60 flex items-center justify-center">
|
|
||||||
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2 text-[10px] text-slate-500">
|
|
||||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
|
||||||
<p>Recommended size: 1920x1080px. High quality but optimized for web.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Fields */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Tagline</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editingHero.tagline || ''}
|
|
||||||
onChange={(e) => setEditingHero({ ...editingHero, tagline: e.target.value })}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. A New Paradigm of Calm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editingHero.title || ''}
|
|
||||||
onChange={(e) => setEditingHero({ ...editingHero, title: e.target.value })}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. Inhale [Serenity]"
|
|
||||||
/>
|
|
||||||
<p className="text-[9px] text-slate-600 font-medium tracking-wide">Use [brackets] for the accent serif font.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Description</label>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
required
|
|
||||||
value={editingHero.description || ''}
|
|
||||||
onChange={(e) => setEditingHero({ ...editingHero, description: e.target.value })}
|
|
||||||
className="admin-input w-full resize-none"
|
|
||||||
placeholder="Tell the story of your sanctuary..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Button Text</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editingHero.buttonText || ''}
|
|
||||||
onChange={(e) => setEditingHero({ ...editingHero, buttonText: e.target.value })}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. Explore Collection"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Button Link</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editingHero.buttonLink || ''}
|
|
||||||
onChange={(e) => setEditingHero({ ...editingHero, buttonLink: e.target.value })}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. /shop"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6 border-t border-white/5 flex flex-col md:flex-row gap-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isUploading}
|
|
||||||
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{editingHero.id ? 'Update Hero Settings' : 'Create Hero Entry'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="px-8 py-3 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MediaLibraryModal
|
|
||||||
isOpen={isMediaModalOpen}
|
|
||||||
onClose={() => setIsMediaModalOpen(false)}
|
|
||||||
onSelect={(url) => {
|
|
||||||
if (onSelectMedia) onSelectMedia(url)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -147,34 +147,6 @@ model CustomerAddress {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Hero {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
tagline String
|
|
||||||
title String
|
|
||||||
description String
|
|
||||||
imageUrl String
|
|
||||||
buttonText String @default("Explore Collection")
|
|
||||||
buttonLink String @default("/shop")
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
translations HeroTranslation[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model HeroTranslation {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
heroId Int
|
|
||||||
hero Hero @relation(fields: [heroId], references: [id], onDelete: Cascade)
|
|
||||||
langCode String
|
|
||||||
tagline String?
|
|
||||||
title String?
|
|
||||||
description String?
|
|
||||||
buttonText String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([heroId, langCode])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Coupon {
|
model Coupon {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { categoriesRoute } from "./routes/categories";
|
|||||||
import { productsRoute } from "./routes/products";
|
import { productsRoute } from "./routes/products";
|
||||||
import { authRoute } from "./routes/auth";
|
import { authRoute } from "./routes/auth";
|
||||||
import { uploadRoute } from "./routes/upload";
|
import { uploadRoute } from "./routes/upload";
|
||||||
import { heroRoute } from "./routes/hero";
|
|
||||||
import { ordersRoute } from "./routes/orders";
|
import { ordersRoute } from "./routes/orders";
|
||||||
import { settingsRoute } from "./routes/settings";
|
import { settingsRoute } from "./routes/settings";
|
||||||
import { articlesRoute } from "./routes/articles";
|
import { articlesRoute } from "./routes/articles";
|
||||||
@@ -126,7 +125,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.group("/api", (app) =>
|
||||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).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(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)
|
||||||
)
|
)
|
||||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Elysia, t } from "elysia";
|
|
||||||
import prisma from "../lib/prisma";
|
|
||||||
|
|
||||||
export const heroRoute = new Elysia({ prefix: "/hero" })
|
|
||||||
.get("/", async () => {
|
|
||||||
return await prisma.hero.findFirst({
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { updatedAt: 'desc' }
|
|
||||||
}) || await prisma.hero.findFirst({
|
|
||||||
orderBy: { updatedAt: 'desc' }
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.get("/all", async () => {
|
|
||||||
return await prisma.hero.findMany({
|
|
||||||
orderBy: { updatedAt: 'desc' }
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.post("/", async ({ body, set }) => {
|
|
||||||
try {
|
|
||||||
const data = body as any;
|
|
||||||
const { id, ...heroData } = data;
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
return await prisma.hero.update({
|
|
||||||
where: { id: Number(id) },
|
|
||||||
data: heroData
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return await prisma.hero.create({
|
|
||||||
data: heroData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Hero] Error:", err);
|
|
||||||
set.status = 500;
|
|
||||||
return { error: "Failed to save hero data" };
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
body: t.Object({
|
|
||||||
id: t.Optional(t.Number()),
|
|
||||||
tagline: t.String(),
|
|
||||||
title: t.String(),
|
|
||||||
description: t.String(),
|
|
||||||
imageUrl: t.String(),
|
|
||||||
buttonText: t.String(),
|
|
||||||
buttonLink: t.String(),
|
|
||||||
isActive: t.Boolean()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.patch("/:id/activate", async ({ params: { id }, set }) => {
|
|
||||||
try {
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.hero.updateMany({
|
|
||||||
data: { isActive: false }
|
|
||||||
}),
|
|
||||||
prisma.hero.update({
|
|
||||||
where: { id: Number(id) },
|
|
||||||
data: { isActive: true }
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
set.status = 500;
|
|
||||||
return { error: "Failed to activate hero" };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.delete("/:id", async ({ params: { id }, set }) => {
|
|
||||||
try {
|
|
||||||
await prisma.hero.delete({
|
|
||||||
where: { id: Number(id) }
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
set.status = 500;
|
|
||||||
return { error: "Failed to delete hero" };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.patch("/:id/translations", async ({ params, body, set }) => {
|
|
||||||
const id = Number(params.id);
|
|
||||||
if (isNaN(id)) {
|
|
||||||
set.status = 400;
|
|
||||||
return { error: "Invalid hero ID" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = body as any;
|
|
||||||
const results: any[] = [];
|
|
||||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
|
||||||
|
|
||||||
for (const lang of LANGS) {
|
|
||||||
const tagline = typeof payload.tagline === 'string' ? (lang === 'th' ? payload.tagline : null) : payload.tagline?.[lang];
|
|
||||||
const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang];
|
|
||||||
const description = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description?.[lang];
|
|
||||||
const buttonText = typeof payload.buttonText === 'string' ? (lang === 'th' ? payload.buttonText : null) : payload.buttonText?.[lang];
|
|
||||||
|
|
||||||
if (!tagline && !title && !description && !buttonText) continue;
|
|
||||||
|
|
||||||
const existing = await prisma.heroTranslation.findUnique({
|
|
||||||
where: { heroId_langCode: { heroId: id, langCode: lang } }
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateData: any = {};
|
|
||||||
if (tagline) updateData.tagline = tagline;
|
|
||||||
if (title) updateData.title = title;
|
|
||||||
if (description) updateData.description = description;
|
|
||||||
if (buttonText) updateData.buttonText = buttonText;
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const updated = await prisma.heroTranslation.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: updateData
|
|
||||||
});
|
|
||||||
results.push(updated);
|
|
||||||
} else {
|
|
||||||
const created = await prisma.heroTranslation.create({
|
|
||||||
data: { heroId: id, langCode: lang, ...updateData }
|
|
||||||
});
|
|
||||||
results.push(created);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.hero.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: { translations: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, translations: results, hero: updated };
|
|
||||||
});
|
|
||||||
@@ -158,22 +158,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["id", "translations"]
|
required: ["id", "translations"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "update_hero_translation",
|
|
||||||
description: "Add or update translations for a specific hero.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Hero ID" },
|
|
||||||
lang: { type: "string", description: "Language code" },
|
|
||||||
tagline: { type: "string" },
|
|
||||||
title: { type: "string" },
|
|
||||||
description: { type: "string" },
|
|
||||||
buttonText: { type: "string" }
|
|
||||||
},
|
|
||||||
required: ["id", "lang"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "update_menu_item_translation",
|
name: "update_menu_item_translation",
|
||||||
description: "Add or update translations for a specific menu item.",
|
description: "Add or update translations for a specific menu item.",
|
||||||
@@ -522,16 +507,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: id, translations: res.data?.translations ?? res.data, page: res.data?.page ?? res.data }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: id, translations: res.data?.translations ?? res.data, page: res.data?.page ?? res.data }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "update_hero_translation": {
|
|
||||||
const { id, lang, tagline, title, description, buttonText } = params as any;
|
|
||||||
const payload: any = {};
|
|
||||||
if (tagline) payload.tagline = { [lang]: tagline };
|
|
||||||
if (title) payload.title = { [lang]: title };
|
|
||||||
if (description) payload.description = { [lang]: description };
|
|
||||||
if (buttonText) payload.buttonText = { [lang]: buttonText };
|
|
||||||
const res = await api.patch(`/hero/${id}/translations`, payload);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "update_menu_item_translation": {
|
case "update_menu_item_translation": {
|
||||||
const { id, lang, label } = params as any;
|
const { id, lang, label } = params as any;
|
||||||
const res = await api.patch(`/menus/items/${id}/translations`, {
|
const res = await api.patch(`/menus/items/${id}/translations`, {
|
||||||
|
|||||||
Reference in new issue
Block a user