This commit is contained in:
Your Name committed 2026-06-22 20:09:42 -04:00
1 parent 7cd288e67e
commit ed69a4b840
6 files changed
+3 -626

No files matched your search

-2
View File
@@ -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 />} />
-441
View File
@@ -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>
)
}
-28
View File
@@ -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())
+1 -2
View File
@@ -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" });
-127
View File
@@ -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 };
});
+2 -26
View File
@@ -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`, {