From f72876587ea233ed041c8220733e9bea4b51afe8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 6 Jun 2026 20:03:39 +0700 Subject: [PATCH] feat(admin): implement promotions management and loyalty points configuration --- apps/admin/src/App.tsx | 2 + apps/admin/src/components/Sidebar.tsx | 1 + apps/admin/src/pages/PromotionsPage.tsx | 214 ++++++++++++++++++++ apps/admin/src/pages/SystemSettingsPage.tsx | 33 ++- apps/backend/src/routes/promotions.ts | 59 ++++++ apps/backend/src/routes/settings.ts | 3 +- packages/types/index.ts | 15 ++ 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/pages/PromotionsPage.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 42bd214..f7e1819 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index fe807d1..04e42c8 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -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' }, diff --git a/apps/admin/src/pages/PromotionsPage.tsx b/apps/admin/src/pages/PromotionsPage.tsx new file mode 100644 index 0000000..dfba094 --- /dev/null +++ b/apps/admin/src/pages/PromotionsPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [isModalOpen, setIsModalOpen] = useState(false) + const [editingId, setEditingId] = useState(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
Loading Promotions...
+ + return ( +
+
+

Auto Promotions

+ +
+ +
+ {promotions.map(promo => ( +
+
+
+ + Level {promo.level} + + + {promo.isActive ? 'Active' : 'Inactive'} + +
+

{promo.name}

+

+ Discount: {promo.type === 'PERCENTAGE' ? `${promo.value}%` : `฿${promo.value}`} | Min Purchase: ฿{promo.minPurchase} +

+
+
+ + +
+
+ ))} +
+ + {isModalOpen && ( +
+
+
+

{editingId ? 'Edit' : 'New'} Promotion

+ +
+
+
+ + setName(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg p-2" /> +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + setValue(Number(e.target.value))} className="w-full bg-white/5 border border-white/10 rounded-lg p-2" /> +
+
+
+ + setMinPurchase(Number(e.target.value))} className="w-full bg-white/5 border border-white/10 rounded-lg p-2" /> +
+ +
+
+
+ )} +
+ ) +} diff --git a/apps/admin/src/pages/SystemSettingsPage.tsx b/apps/admin/src/pages/SystemSettingsPage.tsx index 21715f3..d753305 100644 --- a/apps/admin/src/pages/SystemSettingsPage.tsx +++ b/apps/admin/src/pages/SystemSettingsPage.tsx @@ -11,6 +11,7 @@ export default function SystemSettingsPage() { const [bankName, setBankName] = useState('') const [bankAccountName, setBankAccountName] = useState('') const [bankAccountNumber, setBankAccountNumber] = useState('') + const [pointsEarnRate, setPointsEarnRate] = useState('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() { + {/* Loyalty Points Settings */} +
+
+
+ ⭐️ +
+

Loyalty Points Configuration

+
+ +
+ +
+ 1 THB spent = + setPointsEarnRate(e.target.value)} + className="admin-input w-32" + /> + Points +
+

For example, if you set 1.0, spending 100 THB gives 100 points. If you set 0.5, spending 100 THB gives 50 points.

+
+
+ {/* Bank Settings */}
diff --git a/apps/backend/src/routes/promotions.ts b/apps/backend/src/routes/promotions.ts index bd27540..9e23ccb 100644 --- a/apps/backend/src/routes/promotions.ts +++ b/apps/backend/src/routes/promotions.ts @@ -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" }; + } }); diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index b66b2d3..c3d3960 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -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() }) }); diff --git a/packages/types/index.ts b/packages/types/index.ts index 0cf0adb..1137c20 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -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