This commit is contained in:
Your Name committed 2026-06-13 19:45:46 -04:00
1 parent 59363616c4
commit 20f43bd58d
13 files changed
+789 -42

No files matched your search

+4
View File
@@ -13,6 +13,8 @@ import ProtectedRoute from './components/ProtectedRoute'
import OrdersPage from './pages/OrdersPage' import OrdersPage from './pages/OrdersPage'
import OrderDetailPage from './pages/OrderDetailPage' import OrderDetailPage from './pages/OrderDetailPage'
import SystemSettingsPage from './pages/SystemSettingsPage' import SystemSettingsPage from './pages/SystemSettingsPage'
import MenuManagerPage from './pages/MenuManagerPage'
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
import ArticlesPage from './pages/ArticlesPage' import ArticlesPage from './pages/ArticlesPage'
import ArticleEditPage from './pages/ArticleEditPage' import ArticleEditPage from './pages/ArticleEditPage'
import CouponsPage from './pages/CouponsPage' import CouponsPage from './pages/CouponsPage'
@@ -54,6 +56,8 @@ function App() {
<Route path="/coupons" element={<CouponsPage />} /> <Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} /> <Route path="/raw-materials" element={<RawMaterialsPage />} />
<Route path="/settings" element={<SystemSettingsPage />} /> <Route path="/settings" element={<SystemSettingsPage />} />
<Route path="/menu-manager" element={<MenuManagerPage />} />
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
<Route path="/articles" element={<ArticlesPage />} /> <Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticleEditPage />} /> <Route path="/articles/:id" element={<ArticleEditPage />} />
<Route path="/tiktok" element={<TiktokPage />} /> <Route path="/tiktok" element={<TiktokPage />} />
+2
View File
@@ -41,6 +41,8 @@ const MENU_ITEMS = [
{ icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' }, { icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' },
{ icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' }, { icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' },
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' }, { icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
{ icon: Layers, label: 'Menu Manager', to: '/menu-manager' },
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
{ icon: Settings, label: 'Settings', to: '/settings' }, { icon: Settings, label: 'Settings', to: '/settings' },
] ]
+168
View File
@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react'
import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, GripVertical } from 'lucide-react'
export default function MenuManagerPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [menus, setMenus] = useState<Menu[]>([])
// We'll manage multiple menus, e.g., 'main' and 'footer-explore'
const [selectedMenuName, setSelectedMenuName] = useState('main')
useEffect(() => {
fetch(`${API_BASE}/menus`)
.then(r => r.json())
.then((data: Menu[]) => {
setMenus(data)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const currentMenu = menus.find(m => m.name === selectedMenuName) || { id: 0, name: selectedMenuName, items: [] }
const handleAddItem = () => {
const newItems = [...currentMenu.items, { id: Date.now(), label: 'New Link', url: '/', order: currentMenu.items.length }]
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
// If menu doesn't exist yet in state
if (!menus.find(m => m.name === selectedMenuName)) {
setMenus([...menus, { id: Date.now(), name: selectedMenuName, items: newItems }])
}
}
const handleUpdateItem = (idx: number, field: string, value: string) => {
const newItems = [...currentMenu.items]
newItems[idx] = { ...newItems[idx], [field]: value }
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
}
const handleRemoveItem = (idx: number) => {
const newItems = currentMenu.items.filter((_, i) => i !== idx)
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const res = await fetch(`${API_BASE}/menus/${selectedMenuName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: currentMenu.items })
})
if (res.ok) {
alert('Menu saved successfully!')
} else {
alert('Failed to save menu')
}
} catch (err) {
console.error(err)
alert('Error saving menu')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
</div>
)
}
return (
<div className="max-w-4xl space-y-8 animate-fade-in">
<div className="space-y-1">
<h1 className="text-3xl font-bold">Menu Manager</h1>
<p className="text-slate-500 text-sm">Manage navigation links for the main navbar and footer.</p>
</div>
<div className="flex gap-4 border-b border-white/5 pb-4">
{['main', 'footer-explore'].map(name => (
<button
key={name}
onClick={() => setSelectedMenuName(name)}
className={`px-4 py-2 font-bold text-xs uppercase tracking-widest rounded-lg transition-colors ${
selectedMenuName === name ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400 hover:bg-white/10'
}`}
>
{name === 'main' ? 'Main Navigation' : 'Footer Explore'}
</button>
))}
</div>
<form onSubmit={handleSave} className="space-y-6">
<div className="admin-card p-8 space-y-6">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
Menu Items ({selectedMenuName})
</h2>
<button
type="button"
onClick={handleAddItem}
className="flex items-center gap-2 text-xs font-bold text-cyan-glow hover:underline"
>
<Plus className="w-4 h-4" /> Add Item
</button>
</div>
<div className="space-y-4">
{currentMenu.items.length === 0 ? (
<p className="text-slate-500 text-sm italic">No items in this menu.</p>
) : (
currentMenu.items.map((item, idx) => (
<div key={item.id} className="flex gap-4 items-center bg-white/5 p-4 rounded-xl border border-white/5 group">
<GripVertical className="w-5 h-5 text-slate-600 cursor-move" />
<div className="flex-1 grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Label</label>
<input
type="text"
value={item.label}
onChange={e => handleUpdateItem(idx, 'label', e.target.value)}
className="admin-input w-full"
placeholder="e.g. Shop All"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">URL path</label>
<input
type="text"
value={item.url}
onChange={e => handleUpdateItem(idx, 'url', e.target.value)}
className="admin-input w-full"
placeholder="e.g. /shop"
/>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveItem(idx)}
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
title="Remove item"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))
)}
</div>
</div>
<button
type="submit"
disabled={saving}
className="btn-primary py-4 px-12 flex items-center gap-2 ml-auto text-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Menu
</button>
</form>
</div>
)
}
@@ -0,0 +1,115 @@
import { useState, useEffect } from 'react'
import { API_BASE } from '@homaroma/types'
import { Download, Loader2, Mail } from 'lucide-react'
interface Subscriber {
id: number
email: string
isActive: boolean
createdAt: string
}
export default function NewsletterSubscribersPage() {
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`${API_BASE}/newsletter/subscribers`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
setSubscribers(data)
}
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const handleExportCSV = () => {
const csvContent = "data:text/csv;charset=utf-8,"
+ "ID,Email,Date\n"
+ subscribers.map(e => `${e.id},${e.email},${new Date(e.createdAt).toLocaleString()}`).join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `subscribers_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
</div>
)
}
return (
<div className="max-w-4xl space-y-8 animate-fade-in">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-3xl font-bold flex items-center gap-3">
<Mail className="w-8 h-8 text-cyan-glow" />
Newsletter Subscribers
</h1>
<p className="text-slate-500 text-sm">View and export all email subscribers.</p>
</div>
<button
onClick={handleExportCSV}
disabled={subscribers.length === 0}
className="btn-primary py-3 px-6 flex items-center gap-2 text-sm disabled:opacity-50"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
<div className="admin-card overflow-hidden">
<table className="w-full text-left text-sm text-slate-300">
<thead className="text-xs uppercase bg-white/5 border-b border-white/10 text-slate-400">
<tr>
<th className="px-6 py-4 font-bold tracking-widest">ID</th>
<th className="px-6 py-4 font-bold tracking-widest">Email</th>
<th className="px-6 py-4 font-bold tracking-widest">Date Subscribed</th>
<th className="px-6 py-4 font-bold tracking-widest text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{subscribers.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-slate-500 italic">
No subscribers yet.
</td>
</tr>
) : (
subscribers.map(sub => (
<tr key={sub.id} className="hover:bg-white/5 transition-colors">
<td className="px-6 py-4 font-mono text-slate-500">#{sub.id}</td>
<td className="px-6 py-4 font-bold">{sub.email}</td>
<td className="px-6 py-4 text-slate-400">
{new Date(sub.createdAt).toLocaleDateString()} {new Date(sub.createdAt).toLocaleTimeString()}
</td>
<td className="px-6 py-4 text-right">
{sub.isActive ? (
<span className="px-2 py-1 text-[10px] uppercase font-bold tracking-widest bg-cyan-glow/20 text-cyan-glow rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-[10px] uppercase font-bold tracking-widest bg-rose-500/20 text-rose-500 rounded-full">
Unsubscribed
</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
+138 -5
View File
@@ -12,16 +12,33 @@ export default function SystemSettingsPage() {
const [bankAccountName, setBankAccountName] = useState('') const [bankAccountName, setBankAccountName] = useState('')
const [bankAccountNumber, setBankAccountNumber] = useState('') const [bankAccountNumber, setBankAccountNumber] = useState('')
const [pointsEarnRate, setPointsEarnRate] = useState<string>('1') const [pointsEarnRate, setPointsEarnRate] = useState<string>('1')
const [footerDescription, setFooterDescription] = useState('')
const [facebookUrl, setFacebookUrl] = useState('')
const [instagramUrl, setInstagramUrl] = useState('')
const [tiktokUrl, setTiktokUrl] = useState('')
const [newsletterTitle, setNewsletterTitle] = useState('')
const [newsletterDescription, setNewsletterDescription] = useState('')
const [newsletterPlaceholder, setNewsletterPlaceholder] = useState('')
const [newsletterButtonText, setNewsletterButtonText] = useState('')
useEffect(() => { useEffect(() => {
fetch(`${API_BASE}/settings`) fetch(`${API_BASE}/settings`)
.then(r => r.json()) .then(r => r.json())
.then((data: StoreSettings) => { .then((data: StoreSettings) => {
setShippingFee(data.shippingFee.toString()) setShippingFee(data.shippingFee.toString())
setBankName(data.bankName) setBankName(data.bankName || '')
setBankAccountName(data.bankAccountName) setBankAccountName(data.bankAccountName || '')
setBankAccountNumber(data.bankAccountNumber) setBankAccountNumber(data.bankAccountNumber || '')
setPointsEarnRate(data.pointsEarnRate.toString()) setPointsEarnRate((data.pointsEarnRate || 1).toString())
setFooterDescription(data.footerDescription || '')
setFacebookUrl(data.facebookUrl || '')
setInstagramUrl(data.instagramUrl || '')
setTiktokUrl(data.tiktokUrl || '')
setNewsletterTitle(data.newsletterTitle || '')
setNewsletterDescription(data.newsletterDescription || '')
setNewsletterPlaceholder(data.newsletterPlaceholder || '')
setNewsletterButtonText(data.newsletterButtonText || '')
}) })
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -40,7 +57,15 @@ export default function SystemSettingsPage() {
bankName, bankName,
bankAccountName, bankAccountName,
bankAccountNumber, bankAccountNumber,
pointsEarnRate: parseFloat(pointsEarnRate) || 1 pointsEarnRate: parseFloat(pointsEarnRate) || 1,
footerDescription,
facebookUrl,
instagramUrl,
tiktokUrl,
newsletterTitle,
newsletterDescription,
newsletterPlaceholder,
newsletterButtonText
}) })
}) })
if (res.ok) alert('Settings saved successfully!') if (res.ok) alert('Settings saved successfully!')
@@ -166,6 +191,114 @@ export default function SystemSettingsPage() {
</div> </div>
</div> </div>
{/* Brand & Footer Settings */}
<div className="admin-card p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
<div className="p-2 bg-purple-500/10 rounded-lg">
<span className="text-xl">✨</span>
</div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand & Footer Settings</h2>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Footer Description</label>
<textarea
value={footerDescription}
onChange={e => setFooterDescription(e.target.value)}
className="admin-input w-full min-h-[80px]"
placeholder="Where essential oils meet modern living..."
/>
</div>
<div className="grid md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Facebook URL</label>
<input
type="text"
value={facebookUrl}
onChange={e => setFacebookUrl(e.target.value)}
className="admin-input w-full"
placeholder="https://facebook.com/..."
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Instagram URL</label>
<input
type="text"
value={instagramUrl}
onChange={e => setInstagramUrl(e.target.value)}
className="admin-input w-full"
placeholder="https://instagram.com/..."
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">TikTok URL</label>
<input
type="text"
value={tiktokUrl}
onChange={e => setTiktokUrl(e.target.value)}
className="admin-input w-full"
placeholder="https://tiktok.com/@..."
/>
</div>
</div>
</div>
</div>
{/* Newsletter Settings */}
<div className="admin-card p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
<div className="p-2 bg-cyan-glow/10 rounded-lg">
<span className="text-xl">📧</span>
</div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Newsletter Settings</h2>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Newsletter Title</label>
<input
type="text"
value={newsletterTitle}
onChange={e => setNewsletterTitle(e.target.value)}
className="admin-input w-full"
placeholder="Newsletter"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Value Proposition (Description)</label>
<input
type="text"
value={newsletterDescription}
onChange={e => setNewsletterDescription(e.target.value)}
className="admin-input w-full"
placeholder="Subscribe for exclusive releases..."
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Input Placeholder Text</label>
<input
type="text"
value={newsletterPlaceholder}
onChange={e => setNewsletterPlaceholder(e.target.value)}
className="admin-input w-full"
placeholder="Your email"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Submit Button Text</label>
<input
type="text"
value={newsletterButtonText}
onChange={e => setNewsletterButtonText(e.target.value)}
className="admin-input w-full"
placeholder="Join"
/>
</div>
</div>
</div>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
+34 -1
View File
@@ -155,7 +155,22 @@ model StoreSettings {
bankAccountName String @default("HomAroma Co., Ltd.") bankAccountName String @default("HomAroma Co., Ltd.")
bankAccountNumber String @default("012-3-45678-9") bankAccountNumber String @default("012-3-45678-9")
pointsEarnRate Float @default(1.0) pointsEarnRate Float @default(1.0)
updatedAt DateTime @updatedAt facebookUrl String?
footerDescription String?
instagramUrl String?
tiktokUrl String?
newsletterTitle String?
newsletterDescription String?
newsletterPlaceholder String?
newsletterButtonText String?
updatedAt DateTime @updatedAt
}
model NewsletterSubscriber {
id Int @id @default(autoincrement())
email String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
} }
model Order { model Order {
@@ -468,3 +483,21 @@ model NewsletterSubscription {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Menu {
id Int @id @default(autoincrement())
name String @unique // e.g., "header", "footer"
items MenuItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MenuItem {
id Int @id @default(autoincrement())
menuId Int
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
label String
url String
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+3 -1
View File
@@ -24,6 +24,8 @@ import { pagesRoute } from "./routes/pages";
import { paymentsRoute } from "./routes/payments"; import { paymentsRoute } from "./routes/payments";
import { profileRoute } from "./routes/profile"; import { profileRoute } from "./routes/profile";
import { lineRoutes } from "./routes/line"; import { lineRoutes } from "./routes/line";
import { menusRoute } from "./routes/menus";
import { newsletterRoute } from "./routes/newsletter";
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_scent"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
@@ -97,7 +99,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) 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)
) )
.listen({ port: PORT, hostname: "0.0.0.0" }); .listen({ port: PORT, hostname: "0.0.0.0" });
+96
View File
@@ -0,0 +1,96 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const menusRoute = new Elysia({ prefix: "/menus" })
.get("/", async () => {
return await prisma.menu.findMany({
include: {
items: {
orderBy: { order: "asc" }
}
}
});
})
.get("/:name", async ({ params: { name }, set }) => {
const menu = await prisma.menu.findUnique({
where: { name },
include: {
items: {
orderBy: { order: "asc" }
}
}
});
if (!menu) {
set.status = 404;
return { error: "Menu not found" };
}
return menu;
})
.post("/", async ({ body, set }) => {
try {
const { name, items } = body as { name: string; items?: { label: string; url: string; order: number }[] };
const menu = await prisma.menu.create({
data: {
name,
items: items ? {
create: items
} : undefined
},
include: { items: true }
});
return menu;
} catch (err) {
console.error(err);
set.status = 500;
return { error: "Failed to create menu" };
}
}, {
body: t.Object({
name: t.String(),
items: t.Optional(t.Array(t.Object({
label: t.String(),
url: t.String(),
order: t.Number()
})))
})
})
.put("/:name", async ({ params: { name }, body, set }) => {
try {
const { items } = body as { items: { id?: number; label: string; url: string; order: number }[] };
let menu = await prisma.menu.findUnique({ where: { name } });
if (!menu) {
menu = await prisma.menu.create({ data: { name } });
}
await prisma.$transaction([
prisma.menuItem.deleteMany({ where: { menuId: menu.id } }),
prisma.menuItem.createMany({
data: items.map(item => ({
menuId: menu.id,
label: item.label,
url: item.url,
order: item.order
}))
})
]);
return await prisma.menu.findUnique({
where: { name },
include: { items: { orderBy: { order: "asc" } } }
});
} catch (err) {
console.error(err);
set.status = 500;
return { error: "Failed to update menu" };
}
}, {
body: t.Object({
items: t.Array(t.Object({
id: t.Optional(t.Number()),
label: t.String(),
url: t.String(),
order: t.Number()
}))
})
});
+36
View File
@@ -0,0 +1,36 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
.post("/subscribe", async ({ body, set }) => {
try {
const { email } = body as { email: string };
const existing = await prisma.newsletterSubscriber.findUnique({
where: { email }
});
if (existing) {
return { message: "Email already subscribed" };
}
await prisma.newsletterSubscriber.create({
data: { email }
});
return { message: "Successfully subscribed" };
} catch (err) {
console.error(err);
set.status = 500;
return { error: "Failed to subscribe" };
}
}, {
body: t.Object({
email: t.String({ format: "email" })
})
})
.get("/subscribers", async () => {
return await prisma.newsletterSubscriber.findMany({
orderBy: { createdAt: "desc" }
});
});
+9 -1
View File
@@ -41,6 +41,14 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
bankName: t.String(), bankName: t.String(),
bankAccountName: t.String(), bankAccountName: t.String(),
bankAccountNumber: t.String(), bankAccountNumber: t.String(),
pointsEarnRate: t.Number() pointsEarnRate: t.Number(),
facebookUrl: t.Optional(t.String()),
instagramUrl: t.Optional(t.String()),
tiktokUrl: t.Optional(t.String()),
footerDescription: t.Optional(t.String()),
newsletterTitle: t.Optional(t.String()),
newsletterDescription: t.Optional(t.String()),
newsletterPlaceholder: t.Optional(t.String()),
newsletterButtonText: t.Optional(t.String())
}) })
}); });
+124 -26
View File
@@ -1,13 +1,65 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import type { Category } from '../types' import { useState, useEffect } from 'react'
import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface FooterProps { interface FooterProps {
categories?: Category[] categories?: Category[]
} }
export default function Footer({ categories = [] }: FooterProps) { export default function Footer({ categories = [] }: FooterProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
const [settings, setSettings] = useState<StoreSettings | null>(null)
const [email, setEmail] = useState('')
const [subscribing, setSubscribing] = useState(false)
const [subscribeStatus, setSubscribeStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [subscribeMessage, setSubscribeMessage] = useState('')
const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) return
setSubscribing(true)
setSubscribeStatus('idle')
try {
const res = await fetch(`${API_BASE}/newsletter/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
})
const data = await res.json()
if (res.ok) {
setSubscribeStatus('success')
setSubscribeMessage(data.message || 'Subscribed!')
setEmail('')
} else {
setSubscribeStatus('error')
setSubscribeMessage(data.error || data.message || 'Failed to subscribe')
}
} catch (err) {
setSubscribeStatus('error')
setSubscribeMessage('An error occurred. Please try again.')
} finally {
setSubscribing(false)
}
}
useEffect(() => {
fetch(`${API_BASE}/menus/footer-explore`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && data.items) setExploreMenu(data.items.sort((a: any, b: any) => a.order - b.order))
})
.catch(console.error)
fetch(`${API_BASE}/settings`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) setSettings(data)
})
.catch(console.error)
}, [])
return ( return (
<footer className="bg-black py-20 px-6 border-t border-white/5"> <footer className="bg-black py-20 px-6 border-t border-white/5">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12"> <div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
@@ -16,26 +68,48 @@ export default function Footer({ categories = [] }: FooterProps) {
HomAroma <span className="text-cyan-glow text-sm font-light italic">Essential</span> HomAroma <span className="text-cyan-glow text-sm font-light italic">Essential</span>
</div> </div>
<p className="text-slate-500 text-sm leading-relaxed"> <p className="text-slate-500 text-sm leading-relaxed">
{t('footer.catchphrase')} {settings?.footerDescription || t('footer.catchphrase')}
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
{['IG', 'FB', 'TW', 'YT'].map((s) => ( {settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
<a key={s} href="#" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300"> <>
{s} {settings.facebookUrl && (
</a> <a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">FB</a>
))} )}
{settings.instagramUrl && (
<a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">IG</a>
)}
{settings.tiktokUrl && (
<a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">TT</a>
)}
</>
) : (
['IG', 'FB', 'TW', 'YT'].map((s) => (
<a key={s} href="#" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">
{s}
</a>
))
)}
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4> <h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4>
<div className="flex flex-col space-y-2 text-sm text-slate-400"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
<Link to="/" className="hover:text-white transition-colors">{t('nav.home')}</Link> {exploreMenu.length > 0 ? (
<Link to="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link> exploreMenu.map(item => (
<Link to="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link> <Link key={item.id} to={item.url} className="hover:text-white transition-colors">{item.label}</Link>
<Link to="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link> ))
<Link to="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link> ) : (
<Link to="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link> <>
<Link to="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
<Link to="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link>
<Link to="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link>
<Link to="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link>
<Link to="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link>
<Link to="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
</>
)}
</div> </div>
</div> </div>
@@ -51,18 +125,42 @@ export default function Footer({ categories = [] }: FooterProps) {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<h4 className="font-bold uppercase tracking-widest text-xs text-white">Newsletter</h4> <h4 className="font-bold uppercase tracking-widest text-xs text-white">
<p className="text-xs text-slate-500">Subscribe for exclusive releases and sensory wisdom.</p> {settings?.newsletterTitle || 'Newsletter'}
<div className="flex bg-white/5 border border-white/10 p-1 rounded-sm"> </h4>
<input <p className="text-xs text-slate-500">
type="email" {settings?.newsletterDescription || 'Subscribe for exclusive releases and sensory wisdom.'}
placeholder="Your email" </p>
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
/> {subscribeStatus === 'success' ? (
<button className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0"> <div className="p-4 bg-cyan-glow/10 border border-cyan-glow/20 rounded-lg text-cyan-glow text-sm text-center animate-fade-in">
Join ✨ {subscribeMessage}
</button> </div>
</div> ) : (
<form onSubmit={handleSubscribe} className="space-y-2">
<div className="flex bg-white/5 border border-white/10 p-1 rounded-sm focus-within:border-cyan-glow/50 transition-colors">
<input
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
placeholder={settings?.newsletterPlaceholder || "Your email"}
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
/>
<button
type="submit"
disabled={subscribing}
className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0 disabled:opacity-50"
>
{subscribing ? '...' : (settings?.newsletterButtonText || 'Join')}
</button>
</div>
{subscribeStatus === 'error' && (
<p className="text-[10px] text-rose-500">{subscribeMessage}</p>
)}
</form>
)}
<p className="text-[10px] text-slate-600 uppercase tracking-widest"> <p className="text-[10px] text-slate-600 uppercase tracking-widest">
© {new Date().getFullYear()} HomAroma. {t('footer.rights')} © {new Date().getFullYear()} HomAroma. {t('footer.rights')}
</p> </p>
+39 -8
View File
@@ -1,6 +1,6 @@
import { Link, NavLink, useNavigate } from 'react-router-dom' import { Link, NavLink, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import type { Category } from '../types' import { API_BASE, type Category, type MenuItem } from '../types'
import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart } from 'lucide-react' import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart } from 'lucide-react'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { useWishlistStore } from '../store/wishlistStore' import { useWishlistStore } from '../store/wishlistStore'
@@ -20,6 +20,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
const [localOrdersCount, setLocalOrdersCount] = useState(0) const [localOrdersCount, setLocalOrdersCount] = useState(0)
const [hasAnnouncement, setHasAnnouncement] = useState(false) const [hasAnnouncement, setHasAnnouncement] = useState(false)
const [menuItems, setMenuItems] = useState<MenuItem[]>([])
const navigate = useNavigate() const navigate = useNavigate()
const { toggleCart, getCartCount } = useCartStore() const { toggleCart, getCartCount } = useCartStore()
@@ -56,6 +57,19 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
try { setLocalOrdersCount(JSON.parse(stored).length) } catch (e) {} try { setLocalOrdersCount(JSON.parse(stored).length) } catch (e) {}
} }
// Fetch dynamic menu
fetch(`${API_BASE}/menus/main`)
.then(res => {
if (!res.ok) throw new Error('Menu not found')
return res.json()
})
.then(data => {
if (data && data.items) {
setMenuItems(data.items.sort((a: any, b: any) => a.order - b.order))
}
})
.catch(console.error)
return () => { return () => {
window.removeEventListener('scroll', onScroll) window.removeEventListener('scroll', onScroll)
clearTimeout(timer) clearTimeout(timer)
@@ -104,9 +118,19 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
{/* ── DESKTOP NAV ── */} {/* ── DESKTOP NAV ── */}
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase"> <div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink> {menuItems.length > 0 ? (
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink> menuItems.map(item => (
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink> <NavLink key={item.id} to={item.url} end={item.url === '/'} className={navLinkClass}>
{item.label}
</NavLink>
))
) : (
<>
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink>
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink>
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink>
</>
)}
{/* Collections dropdown */} {/* Collections dropdown */}
{categories.length > 0 && ( {categories.length > 0 && (
@@ -136,8 +160,12 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
</div> </div>
)} )}
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink> {menuItems.length === 0 && (
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink> <>
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
</>
)}
</div> </div>
{/* ── DESKTOP RIGHT ACTIONS ── */} {/* ── DESKTOP RIGHT ACTIONS ── */}
@@ -362,7 +390,10 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
{/* Drawer Nav Links */} {/* Drawer Nav Links */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1"> <div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
{[ {(menuItems.length > 0 ? [
...menuItems.map(item => ({ to: item.url, label: item.label, end: item.url === '/' })),
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
] : [
{ to: '/', label: t('nav.home'), end: true }, { to: '/', label: t('nav.home'), end: true },
{ to: '/shop', label: t('nav.shop') }, { to: '/shop', label: t('nav.shop') },
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []), ...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
@@ -371,7 +402,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
{ to: '/about', label: t('nav.about') }, { to: '/about', label: t('nav.about') },
{ to: '/faq', label: t('nav.faq') }, { to: '/faq', label: t('nav.faq') },
{ to: '/contact', label: t('nav.contact') }, { to: '/contact', label: t('nav.contact') },
].map(item => ( ]).map((item: any) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
+21
View File
@@ -94,6 +94,27 @@ export interface StoreSettings {
bankAccountName: string bankAccountName: string
bankAccountNumber: string bankAccountNumber: string
pointsEarnRate: number pointsEarnRate: number
footerDescription?: string | null
facebookUrl?: string | null
instagramUrl?: string | null
tiktokUrl?: string | null
newsletterTitle?: string | null
newsletterDescription?: string | null
newsletterPlaceholder?: string | null
newsletterButtonText?: string | null
}
export interface MenuItem {
id: number
label: string
url: string
order: number
}
export interface Menu {
id: number
name: string
items: MenuItem[]
} }
export interface OrderItem { export interface OrderItem {