feat(admin): implement promotions management and loyalty points configuration
This commit is contained in:
1 parent
77c52e0534
commit
f72876587e
7 files changed
+325
-2
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
});
|
||||
@@ -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()
|
||||
})
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user