feat(admin): implement promotions management and loyalty points configuration

This commit is contained in:
Your Name committed 2026-06-06 20:03:39 +07:00
1 parent 77c52e0534
commit f72876587e
7 files changed
+325 -2

No files matched your search

+2
View File
@@ -15,6 +15,7 @@ import SystemSettingsPage from './pages/SystemSettingsPage'
import ArticlesPage from './pages/ArticlesPage'
import ArticleEditPage from './pages/ArticleEditPage'
import CouponsPage from './pages/CouponsPage'
import PromotionsPage from './pages/PromotionsPage'
import TiktokPage from './pages/TiktokPage'
import TiktokAnalysisPage from './pages/TiktokAnalysisPage'
import ShopeePage from './pages/ShopeePage'
@@ -47,6 +48,7 @@ function App() {
<Route path="/hero" element={<HeroSettingsPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} />
<Route path="/settings" element={<SystemSettingsPage />} />
+1
View File
@@ -26,6 +26,7 @@ const MENU_ITEMS = [
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
{ icon: Users, label: 'LINE Customers', to: '/customers' },
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
{ icon: Image, label: 'Hero Section', to: '/hero' },
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
+214
View File
@@ -0,0 +1,214 @@
import { useState, useEffect } from 'react'
import { API_BASE } from '@homaroma/types'
import { Plus, Edit2, Trash2, Tag, Loader2, Save, X } from 'lucide-react'
interface Promotion {
id: number
name: string
level: number
type: string
value: number
minPurchase: number
isActive: boolean
startDate: string | null
endDate: string | null
}
export default function PromotionsPage() {
const [promotions, setPromotions] = useState<Promotion[]>([])
const [loading, setLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
// Form State
const [name, setName] = useState('')
const [level, setLevel] = useState(1)
const [type, setType] = useState('PERCENTAGE')
const [value, setValue] = useState(0)
const [minPurchase, setMinPurchase] = useState(0)
const [isActive, setIsActive] = useState(true)
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
useEffect(() => {
fetchPromotions()
}, [])
const fetchPromotions = async () => {
try {
const res = await fetch(`${API_BASE}/promotions`) // We need to add this to backend
if (!res.ok) {
// Fallback for missing endpoint
setPromotions([])
} else {
const data = await res.json()
setPromotions(data)
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const openModal = (promo?: Promotion) => {
if (promo) {
setEditingId(promo.id)
setName(promo.name)
setLevel(promo.level)
setType(promo.type)
setValue(promo.value)
setMinPurchase(promo.minPurchase)
setIsActive(promo.isActive)
setStartDate(promo.startDate ? promo.startDate.split('T')[0] : '')
setEndDate(promo.endDate ? promo.endDate.split('T')[0] : '')
} else {
setEditingId(null)
setName('')
setLevel(1)
setType('PERCENTAGE')
setValue(0)
setMinPurchase(0)
setIsActive(true)
setStartDate('')
setEndDate('')
}
setIsModalOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const payload = {
name,
level,
type,
value,
minPurchase,
isActive,
startDate: startDate ? new Date(startDate).toISOString() : null,
endDate: endDate ? new Date(endDate).toISOString() : null
}
try {
const url = editingId ? `${API_BASE}/promotions/${editingId}` : `${API_BASE}/promotions`
const method = editingId ? 'PUT' : 'POST'
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
setIsModalOpen(false)
fetchPromotions()
} catch (err) {
console.error(err)
}
}
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this promotion?')) return
try {
await fetch(`${API_BASE}/promotions/${id}`, { method: 'DELETE' })
fetchPromotions()
} catch (err) {
console.error(err)
}
}
if (loading) return <div className="p-8 text-slate-400">Loading Promotions...</div>
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold tracking-tight">Auto Promotions</h1>
<button
onClick={() => openModal()}
className="bg-cyan-glow text-black px-4 py-2 rounded-lg font-semibold flex items-center gap-2 hover:bg-cyan-glow/80"
>
<Plus className="w-5 h-5" /> New Promotion
</button>
</div>
<div className="grid gap-4">
{promotions.map(promo => (
<div key={promo.id} className="bg-white/5 border border-white/10 p-6 rounded-xl flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-widest ${promo.level === 1 ? 'bg-blue-500/20 text-blue-400' : 'bg-purple-500/20 text-purple-400'}`}>
Level {promo.level}
</span>
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-widest ${promo.isActive ? 'bg-emerald-500/20 text-emerald-400' : 'bg-rose-500/20 text-rose-400'}`}>
{promo.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<h3 className="text-lg font-bold">{promo.name}</h3>
<p className="text-slate-400 text-sm">
Discount: {promo.type === 'PERCENTAGE' ? `${promo.value}%` : `฿${promo.value}`} | Min Purchase: ฿{promo.minPurchase}
</p>
</div>
<div className="flex gap-2">
<button onClick={() => openModal(promo)} className="p-2 bg-white/10 rounded hover:bg-white/20 transition-colors">
<Edit2 className="w-4 h-4 text-cyan-glow" />
</button>
<button onClick={() => handleDelete(promo.id)} className="p-2 bg-white/10 rounded hover:bg-white/20 transition-colors">
<Trash2 className="w-4 h-4 text-rose-500" />
</button>
</div>
</div>
))}
</div>
{isModalOpen && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50">
<div className="bg-night-900 border border-white/10 rounded-2xl p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">{editingId ? 'Edit' : 'New'} Promotion</h2>
<button onClick={() => setIsModalOpen(false)}><X className="w-5 h-5 text-slate-400" /></button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs uppercase text-slate-400 mb-1">Name</label>
<input required value={name} onChange={e => setName(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg p-2" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase text-slate-400 mb-1">Level</label>
<select value={level} onChange={e => setLevel(Number(e.target.value))} className="w-full bg-white/5 border border-white/10 rounded-lg p-2">
<option value={1}>Level 1 (Applied First)</option>
<option value={2}>Level 2 (Applied Second)</option>
</select>
</div>
<div>
<label className="block text-xs uppercase text-slate-400 mb-1">Status</label>
<select value={isActive ? 'true' : 'false'} onChange={e => setIsActive(e.target.value === 'true')} className="w-full bg-white/5 border border-white/10 rounded-lg p-2">
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase text-slate-400 mb-1">Type</label>
<select value={type} onChange={e => setType(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg p-2">
<option value="PERCENTAGE">Percentage (%)</option>
<option value="FIXED_AMOUNT">Fixed Amount (฿)</option>
</select>
</div>
<div>
<label className="block text-xs uppercase text-slate-400 mb-1">Value</label>
<input type="number" required min="0" step="0.1" value={value} onChange={e => setValue(Number(e.target.value))} className="w-full bg-white/5 border border-white/10 rounded-lg p-2" />
</div>
</div>
<div>
<label className="block text-xs uppercase text-slate-400 mb-1">Min Purchase Amount (฿)</label>
<input type="number" required min="0" value={minPurchase} onChange={e => setMinPurchase(Number(e.target.value))} className="w-full bg-white/5 border border-white/10 rounded-lg p-2" />
</div>
<button type="submit" className="w-full bg-cyan-glow text-black font-bold py-2 rounded-lg mt-4">
Save Promotion
</button>
</form>
</div>
</div>
)}
</div>
)
}
+32 -1
View File
@@ -11,6 +11,7 @@ export default function SystemSettingsPage() {
const [bankName, setBankName] = useState('')
const [bankAccountName, setBankAccountName] = useState('')
const [bankAccountNumber, setBankAccountNumber] = useState('')
const [pointsEarnRate, setPointsEarnRate] = useState<string>('1')
useEffect(() => {
fetch(`${API_BASE}/settings`)
@@ -20,6 +21,7 @@ export default function SystemSettingsPage() {
setBankName(data.bankName)
setBankAccountName(data.bankAccountName)
setBankAccountNumber(data.bankAccountNumber)
setPointsEarnRate(data.pointsEarnRate.toString())
})
.catch(console.error)
.finally(() => setLoading(false))
@@ -37,7 +39,8 @@ export default function SystemSettingsPage() {
shippingFee: parseFloat(shippingFee) || 0,
bankName,
bankAccountName,
bankAccountNumber
bankAccountNumber,
pointsEarnRate: parseFloat(pointsEarnRate) || 1
})
})
if (res.ok) alert('Settings saved successfully!')
@@ -89,6 +92,34 @@ export default function SystemSettingsPage() {
</div>
</div>
{/* Loyalty Points 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">Loyalty Points Configuration</h2>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Points Earning Rate</label>
<div className="flex items-center gap-4">
<span className="text-sm font-bold text-slate-400">1 THB spent =</span>
<input
type="number"
required
min="0"
step="0.01"
value={pointsEarnRate}
onChange={e => setPointsEarnRate(e.target.value)}
className="admin-input w-32"
/>
<span className="text-sm font-bold text-slate-400">Points</span>
</div>
<p className="text-[10px] text-slate-500">For example, if you set 1.0, spending 100 THB gives 100 points. If you set 0.5, spending 100 THB gives 50 points.</p>
</div>
</div>
{/* Bank Settings */}
<div className="admin-card p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
+59
View File
@@ -17,4 +17,63 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
},
orderBy: { level: 'asc' }
});
})
.get("/", async () => {
return await prisma.promotion.findMany({
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
});
})
.post("/", async ({ body, set }) => {
try {
const data = body as any;
return await prisma.promotion.create({ data });
} catch (err) {
set.status = 500;
return { error: "Failed to create promotion" };
}
}, {
body: t.Object({
name: t.String(),
level: t.Number(),
type: t.String(),
value: t.Number(),
minPurchase: t.Number(),
isActive: t.Boolean(),
startDate: t.Optional(t.Nullable(t.String())),
endDate: t.Optional(t.Nullable(t.String()))
})
})
.put("/:id", async ({ params: { id }, body, set }) => {
try {
const data = body as any;
return await prisma.promotion.update({
where: { id: Number(id) },
data
});
} catch (err) {
set.status = 500;
return { error: "Failed to update promotion" };
}
}, {
body: t.Object({
name: t.String(),
level: t.Number(),
type: t.String(),
value: t.Number(),
minPurchase: t.Number(),
isActive: t.Boolean(),
startDate: t.Optional(t.Nullable(t.String())),
endDate: t.Optional(t.Nullable(t.String()))
})
})
.delete("/:id", async ({ params: { id }, set }) => {
try {
await prisma.promotion.delete({
where: { id: Number(id) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete promotion" };
}
});
+2 -1
View File
@@ -40,6 +40,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
shippingFee: t.Number(),
bankName: t.String(),
bankAccountName: t.String(),
bankAccountNumber: t.String()
bankAccountNumber: t.String(),
pointsEarnRate: t.Number()
})
});
+15
View File
@@ -93,6 +93,7 @@ export interface StoreSettings {
bankName: string
bankAccountName: string
bankAccountNumber: string
pointsEarnRate: number
}
export interface OrderItem {
@@ -113,6 +114,20 @@ export interface Order {
customerEmail: string
customerPhone: string
shippingAddress: string
subtotal?: number
promoLevel1Name?: string | null
promoLevel1Disc?: number
promoLevel2Name?: string | null
promoLevel2Disc?: number
couponCode?: string | null
discountAmount?: number
pointsUsed?: number
pointsDiscount?: number
pointsEarned?: number
totalAmount: number
shippingFee: number
status: string