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 ArticlesPage from './pages/ArticlesPage'
|
||||||
import ArticleEditPage from './pages/ArticleEditPage'
|
import ArticleEditPage from './pages/ArticleEditPage'
|
||||||
import CouponsPage from './pages/CouponsPage'
|
import CouponsPage from './pages/CouponsPage'
|
||||||
|
import PromotionsPage from './pages/PromotionsPage'
|
||||||
import TiktokPage from './pages/TiktokPage'
|
import TiktokPage from './pages/TiktokPage'
|
||||||
import TiktokAnalysisPage from './pages/TiktokAnalysisPage'
|
import TiktokAnalysisPage from './pages/TiktokAnalysisPage'
|
||||||
import ShopeePage from './pages/ShopeePage'
|
import ShopeePage from './pages/ShopeePage'
|
||||||
@@ -47,6 +48,7 @@ function App() {
|
|||||||
<Route path="/hero" element={<HeroSettingsPage />} />
|
<Route path="/hero" element={<HeroSettingsPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
|
<Route path="/promotions" element={<PromotionsPage />} />
|
||||||
<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 />} />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const MENU_ITEMS = [
|
|||||||
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
|
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
|
||||||
{ icon: Users, label: 'LINE Customers', to: '/customers' },
|
{ icon: Users, label: 'LINE Customers', to: '/customers' },
|
||||||
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
|
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
|
||||||
|
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
|
||||||
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
|
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
|
||||||
{ icon: Image, label: 'Hero Section', to: '/hero' },
|
{ icon: Image, label: 'Hero Section', to: '/hero' },
|
||||||
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
|
{ 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 [bankName, setBankName] = useState('')
|
||||||
const [bankAccountName, setBankAccountName] = useState('')
|
const [bankAccountName, setBankAccountName] = useState('')
|
||||||
const [bankAccountNumber, setBankAccountNumber] = useState('')
|
const [bankAccountNumber, setBankAccountNumber] = useState('')
|
||||||
|
const [pointsEarnRate, setPointsEarnRate] = useState<string>('1')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/settings`)
|
fetch(`${API_BASE}/settings`)
|
||||||
@@ -20,6 +21,7 @@ export default function SystemSettingsPage() {
|
|||||||
setBankName(data.bankName)
|
setBankName(data.bankName)
|
||||||
setBankAccountName(data.bankAccountName)
|
setBankAccountName(data.bankAccountName)
|
||||||
setBankAccountNumber(data.bankAccountNumber)
|
setBankAccountNumber(data.bankAccountNumber)
|
||||||
|
setPointsEarnRate(data.pointsEarnRate.toString())
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -37,7 +39,8 @@ export default function SystemSettingsPage() {
|
|||||||
shippingFee: parseFloat(shippingFee) || 0,
|
shippingFee: parseFloat(shippingFee) || 0,
|
||||||
bankName,
|
bankName,
|
||||||
bankAccountName,
|
bankAccountName,
|
||||||
bankAccountNumber
|
bankAccountNumber,
|
||||||
|
pointsEarnRate: parseFloat(pointsEarnRate) || 1
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (res.ok) alert('Settings saved successfully!')
|
if (res.ok) alert('Settings saved successfully!')
|
||||||
@@ -89,6 +92,34 @@ export default function SystemSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Bank Settings */}
|
||||||
<div className="admin-card p-8 space-y-6">
|
<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="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' }
|
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(),
|
shippingFee: t.Number(),
|
||||||
bankName: t.String(),
|
bankName: t.String(),
|
||||||
bankAccountName: t.String(),
|
bankAccountName: t.String(),
|
||||||
bankAccountNumber: t.String()
|
bankAccountNumber: t.String(),
|
||||||
|
pointsEarnRate: t.Number()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -93,6 +93,7 @@ export interface StoreSettings {
|
|||||||
bankName: string
|
bankName: string
|
||||||
bankAccountName: string
|
bankAccountName: string
|
||||||
bankAccountNumber: string
|
bankAccountNumber: string
|
||||||
|
pointsEarnRate: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
@@ -113,6 +114,20 @@ export interface Order {
|
|||||||
customerEmail: string
|
customerEmail: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
shippingAddress: 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
|
totalAmount: number
|
||||||
shippingFee: number
|
shippingFee: number
|
||||||
status: string
|
status: string
|
||||||
|
|||||||
Reference in new issue
Block a user