Author SHA1 Message Date
Your Name d930397d47 fix(security): enforce admin auth guards across sensitive backend routes and eliminate BOM data leak 2026-10-19 06:44:26 +07:00
antigravity-bot BotandYour Name b27afb4561 feat(website): implement PDPA cookie consent banner and script manager (#14)
### Overview
Implement PDPA & GDPR compliant Cookie Consent Management Platform (CMP) for pps/website:
- **Zustand Store (cookieConsentStore.ts):** Stores consent record (
ecessary, nalytics, marketing) in localStorage with skipHydration: true for Next.js SSR safety.
- **Cookie Banner (CookieBanner.tsx):** Fixed bottom floating banner with Quiet Luxury styling, providing Accept All, Reject Non-Essential, and Customize buttons.
- **Preferences Modal (CookiePreferencesModal.tsx):** Detailed modal dialog allowing users to granularly toggle Analytics and Marketing cookies (Strictly Necessary is locked).
- **Script Manager (ScriptManager.tsx):** Coordinates Google Consent Mode v2 (nalytics_storage, d_storage), conditionally injects GA4, Meta Pixel, and TikTok Pixel, and dispatches homaroma:consent-change DOM events.
- **Footer Revocation Link:** Added Cookie Preferences link in the Support column of Footer.tsx so users can modify/revoke consent anytime.
- **Multilingual Support:** Added localized text for TH, EN, JA, and ZH.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #14
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 22:51:47 +00:00
Your Name 9a18463bda feat(profitability): set default period to this_month on omnichannel margin page 2026-10-18 22:00:42 +07:00
adminandYour Name dbc6c513db feat(profitability): hybrid actual fees for Shopee/TikTok with conservative 30% fallback (#13)
Co-authored-by: Your Name <you@example.com>
Reviewed-on: #13
2026-10-18 14:53:31 +00:00
39 changed files with 1845 additions and 762 deletions

No files matched your search

@@ -8,6 +8,8 @@ import {
Coins, Coins,
RefreshCw, RefreshCw,
Calendar, Calendar,
Receipt,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
@@ -50,8 +52,9 @@ interface OmnichannelItem {
categoryName: string; categoryName: string;
unitBomCost: number; unitBomCost: number;
totalBomCost: number; totalBomCost: number;
totalFeePercent: number; platformFee: number;
estPlatformFee: number; effectiveFeePercent: number;
feeSource: 'actual' | 'hybrid' | 'fallback_30pct';
netRevenue: number; netRevenue: number;
netProfit: number; netProfit: number;
netMarginPercent: number; netMarginPercent: number;
@@ -62,6 +65,7 @@ interface OmnichannelItem {
interface ChannelStat { interface ChannelStat {
soldQty: number; soldQty: number;
revenue: number; revenue: number;
platformFee: number;
bomCost: number; bomCost: number;
netProfit: number; netProfit: number;
} }
@@ -78,10 +82,16 @@ interface OmnichannelKpis {
totalMappedProducts: number; totalMappedProducts: number;
totalUnitsSold: number; totalUnitsSold: number;
totalGrossRevenue: number; totalGrossRevenue: number;
totalPlatformFee: number;
totalEstNetRevenue: number; totalEstNetRevenue: number;
totalBOMCost: number; totalBOMCost: number;
totalEstNetProfit: number; totalEstNetProfit: number;
overallNetMargin: number; overallNetMargin: number;
effectiveFeeRate: number;
actualFeeAmount: number;
fallbackFeeAmount: number;
actualOrdersCount: number;
fallbackOrdersCount: number;
channelStats: Record<string, ChannelStat>; channelStats: Record<string, ChannelStat>;
} }
@@ -89,6 +99,7 @@ export default function OmnichannelMarginPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [data, setData] = useState<{ const [data, setData] = useState<{
periodInfo: PeriodInfo; periodInfo: PeriodInfo;
feePolicy?: { mode: string; fallbackPercent: number; description: string };
kpis: OmnichannelKpis; kpis: OmnichannelKpis;
items: OmnichannelItem[]; items: OmnichannelItem[];
} | null>(null); } | null>(null);
@@ -96,7 +107,7 @@ export default function OmnichannelMarginPage() {
// Filters // Filters
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all'); const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: 'all' }); const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: 'this_month' });
const [sortBy, setSortBy] = useState('revenue_desc'); const [sortBy, setSortBy] = useState('revenue_desc');
const fetchData = async () => { const fetchData = async () => {
@@ -163,9 +174,11 @@ export default function OmnichannelMarginPage() {
'Units Sold', 'Units Sold',
'Avg Price', 'Avg Price',
'Total Revenue', 'Total Revenue',
'Platform Fee (THB)',
'Effective Fee %',
'Fee Source',
'Unit BOM Cost', 'Unit BOM Cost',
'Total BOM Cost', 'Total BOM Cost',
'Platform Fee %',
'Net Revenue', 'Net Revenue',
'Net Profit', 'Net Profit',
'Net Margin %', 'Net Margin %',
@@ -184,9 +197,11 @@ export default function OmnichannelMarginPage() {
i.totalSoldQty, i.totalSoldQty,
i.avgSellingPrice, i.avgSellingPrice,
i.totalRevenue, i.totalRevenue,
i.platformFee,
`${i.effectiveFeePercent}%`,
i.feeSource,
i.unitBomCost, i.unitBomCost,
i.totalBomCost, i.totalBomCost,
`${i.totalFeePercent}%`,
i.netRevenue, i.netRevenue,
i.netProfit, i.netProfit,
`${i.netMarginPercent}%`, `${i.netMarginPercent}%`,
@@ -288,19 +303,58 @@ export default function OmnichannelMarginPage() {
</div> </div>
</div> </div>
{/* ── Executive KPI Cards ────────────────────────────────────────── */} {/* ── Fee Policy Notice Banner ─────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="flex flex-wrap items-center justify-between gap-3 p-4 rounded-2xl bg-slate-900/90 border border-white/10 shadow-lg backdrop-blur-sm text-xs text-slate-300">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400">
<ShieldCheck className="w-5 h-5 shrink-0" />
</div>
<div>
<span className="font-bold text-white">Hybrid Fee Policy (Actual + 30% Fallback):</span>{' '}
<span className="text-slate-400">
Shopee ดึงค่าธรรมเนียมจริงจากรายงานคำสั่งซื้อ, TikTok คำนวณจากยอดโอนจริง (Settlement)
สำหรับออเดอร์ที่ยังไม่มีข้อมูลค่าธรรมเนียม จะใช้ตัวคูณปลอดภัย{' '}
<strong className="text-amber-400">30% Fallback</strong>
</span>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] font-mono shrink-0">
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-bold">
Actual: {fmtNum(kpis?.actualOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.actualFeeAmount)})
</span>
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-300 border border-amber-500/30 font-bold">
30% Fallback: {fmtNum(kpis?.fallbackOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.fallbackFeeAmount)})
</span>
</div>
</div>
{/* ── Executive KPI Cards (5 Cards) ──────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Total Gross Revenue */} {/* Total Gross Revenue */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm"> <div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue ({periodInfo?.label || 'All'})</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue</span>
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400"> <div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
<Coins className="w-5 h-5" /> <Coins className="w-5 h-5" />
</div> </div>
</div> </div>
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div> <div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono"> <p className="text-[11px] text-slate-400 mt-2 font-mono">
{fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} รายการ SKU) {fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} SKU)
</p>
</div>
{/* Total Platform Fee */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Platform Deductions</span>
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
<Receipt className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-amber-400">{fmtMoney(kpis?.totalPlatformFee)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono">
เฉลี่ยหัก <strong className="text-amber-300 font-bold">~{kpis?.effectiveFeeRate ?? 30}%</strong> ของยอดขาย
</p> </p>
</div> </div>
@@ -314,28 +368,28 @@ export default function OmnichannelMarginPage() {
</div> </div>
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div> <div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
<p className="text-[11px] text-slate-400 mt-2"> <p className="text-[11px] text-slate-400 mt-2">
ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ในช่วงเวลานี้ ต้นทุนวัตถุดิบจริงสะสมของสินค้า
</p> </p>
</div> </div>
{/* Total Est Net Profit */} {/* Total Est Net Profit */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm"> <div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit (หลังหัก Fee)</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit</span>
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400"> <div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
<TrendingUp className="w-5 h-5" /> <TrendingUp className="w-5 h-5" />
</div> </div>
</div> </div>
<div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div> <div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div>
<p className="text-[11px] text-slate-400 mt-2"> <p className="text-[11px] text-slate-400 mt-2">
หักทั้งค่าธรรมเนียม Platform และต้นทุน BOM หักค่า Platform และต้นทุน BOM แล้ว
</p> </p>
</div> </div>
{/* Overall Net Margin % */} {/* Overall Net Margin % */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm"> <div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin % ({periodInfo?.label || 'All'})</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin %</span>
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400"> <div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
<Percent className="w-5 h-5" /> <Percent className="w-5 h-5" />
</div> </div>
@@ -380,6 +434,12 @@ export default function OmnichannelMarginPage() {
</span> </span>
</div> </div>
<div className="flex items-baseline justify-between mt-1"> <div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.shopee?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span> <span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono"> <span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.shopee?.netProfit)} {fmtMoney(kpis.channelStats.shopee?.netProfit)}
@@ -414,6 +474,12 @@ export default function OmnichannelMarginPage() {
</span> </span>
</div> </div>
<div className="flex items-baseline justify-between mt-1"> <div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.lazada?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span> <span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono"> <span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.lazada?.netProfit)} {fmtMoney(kpis.channelStats.lazada?.netProfit)}
@@ -448,6 +514,12 @@ export default function OmnichannelMarginPage() {
</span> </span>
</div> </div>
<div className="flex items-baseline justify-between mt-1"> <div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.tiktok?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span> <span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono"> <span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.tiktok?.netProfit)} {fmtMoney(kpis.channelStats.tiktok?.netProfit)}
@@ -469,7 +541,7 @@ export default function OmnichannelMarginPage() {
<th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th> <th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th>
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th> <th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th> <th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
<th className="py-4 px-4 text-right">หัก Fee (%)</th> <th className="py-4 px-4 text-right">หัก Fee (%) &amp; ที่มา</th>
<th className="py-4 px-4 text-right">Net Rev / Unit</th> <th className="py-4 px-4 text-right">Net Rev / Unit</th>
<th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th> <th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th>
<th className="py-4 px-4 text-right">Net Margin %</th> <th className="py-4 px-4 text-right">Net Margin %</th>
@@ -528,8 +600,25 @@ export default function OmnichannelMarginPage() {
<td className="py-3.5 px-4 text-right font-mono text-rose-300"> <td className="py-3.5 px-4 text-right font-mono text-rose-300">
{fmtMoney(item.unitBomCost)} {fmtMoney(item.unitBomCost)}
</td> </td>
<td className="py-3.5 px-4 text-right font-mono text-slate-400"> <td className="py-3.5 px-4 text-right">
~{item.totalFeePercent}% <div className="font-mono text-slate-200">
~{item.effectiveFeePercent}%
</div>
<div className="text-[10px] mt-0.5">
{item.feeSource === 'actual' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Actual
</span>
) : item.feeSource === 'hybrid' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-indigo-500/20 text-indigo-400 border border-indigo-500/30">
Hybrid
</span>
) : (
<span className="px-1.5 py-0.5 rounded font-medium bg-amber-500/15 text-amber-300 border border-amber-500/30">
30% Fallback
</span>
)}
</div>
</td> </td>
<td className="py-3.5 px-4 text-right font-mono text-slate-200"> <td className="py-3.5 px-4 text-right font-mono text-slate-200">
{fmtMoney(unitNetRevenue)} {fmtMoney(unitNetRevenue)}
+1 -1
View File
@@ -37,9 +37,9 @@ import { categorySectionsRoute } from "./routes/categorySections";
import { llmPromptsRoute } from "./routes/llmPrompts"; import { llmPromptsRoute } from "./routes/llmPrompts";
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences"; import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
import { profitabilityRoute } from "./routes/profitability"; import { profitabilityRoute } from "./routes/profitability";
import { JWT_SECRET } from "./middleware/auth";
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_brand";
const isDevelopment = process.env.NODE_ENV !== "production"; const isDevelopment = process.env.NODE_ENV !== "production";
const isPrivateLanHostname = (hostname: string) => { const isPrivateLanHostname = (hostname: string) => {
+4 -2
View File
@@ -1,6 +1,8 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
export const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
export async function requireAdmin({ jwt, headers, set }: any) { export async function requireAdmin({ jwt, headers, set }: any) {
const auth = headers.authorization; const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) { if (!auth?.startsWith("Bearer ")) {
@@ -26,7 +28,7 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: process.env.JWT_SECRET || "fallback-secret" secret: JWT_SECRET,
}) })
) )
.onBeforeHandle(requireAdmin); .onBeforeHandle({ as: "scoped" }, requireAdmin);
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const articlesRoute = new Elysia({ prefix: "/articles" }) export const articlesRoute = new Elysia({ prefix: "/articles" })
.get("/", async ({ query, set }) => { .get("/", async ({ query, set }) => {
@@ -46,6 +47,7 @@ export const articlesRoute = new Elysia({ prefix: "/articles" })
return { error: "Failed to fetch article" }; return { error: "Failed to fetch article" };
} }
}) })
.use(adminOnly)
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
try { try {
const data = body as any; const data = body as any;
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { productsRoute } from "./products";
import { categoriesRoute } from "./categories";
import { categorySectionsRoute } from "./categorySections";
import { contactsRoute } from "./contacts";
import { newsletterRoute } from "./newsletter";
import { mediaRoute } from "./media";
import { JWT_SECRET } from "../middleware/auth";
import { jwt } from "@elysiajs/jwt";
import { Elysia } from "elysia";
const signer = new Elysia().use(jwt({ name: "jwt", secret: JWT_SECRET }));
async function req(app: any, path: string, method: string = "GET", body?: any, token?: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
return await app.handle(
new Request(`http://localhost${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
);
}
describe("Backend Auth Guard Security Enforcement", () => {
let adminToken: string;
test("generate admin token for testing", async () => {
adminToken = await (signer as any).decorator.jwt.sign({ role: "ADMIN" });
expect(adminToken).toBeDefined();
});
describe("Products routes", () => {
test("GET /products/brands is public", async () => {
const res = await req(productsRoute, "/products/brands");
expect(res.status).toBe(200);
});
test("GET /products/bom-status rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products/bom-status");
expect(res.status).toBe(401);
const json = await res.json();
expect(json.error).toContain("Unauthorized");
});
test("GET /products/bom-status allows authenticated admin", async () => {
const res = await req(productsRoute, "/products/bom-status", "GET", undefined, adminToken);
expect(res.status).toBe(200);
});
test("POST /products rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products", "POST", { name: "Test" });
expect(res.status).toBe(401);
});
test("POST /products/brands rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products/brands", "POST", { name: "TestBrand" });
expect(res.status).toBe(401);
});
});
describe("Categories routes", () => {
test("GET /categories is public", async () => {
const res = await req(categoriesRoute, "/categories");
expect(res.status).toBe(200);
});
test("POST /categories rejects unauthenticated request with 401", async () => {
const res = await req(categoriesRoute, "/categories", "POST", { name: "Test" });
expect(res.status).toBe(401);
});
test("POST /categories/bulk-import rejects unauthenticated request with 401", async () => {
const res = await req(categoriesRoute, "/categories/bulk-import", "POST", []);
expect(res.status).toBe(401);
});
});
describe("Category Sections routes", () => {
test("POST /categories/1/sections/seed-default rejects unauthenticated with 401", async () => {
const res = await req(categorySectionsRoute, "/categories/1/sections/seed-default", "POST");
expect(res.status).toBe(401);
});
});
describe("Contacts routes", () => {
test("GET /contacts rejects unauthenticated request with 401", async () => {
const res = await req(contactsRoute, "/contacts");
expect(res.status).toBe(401);
});
});
describe("Newsletter routes", () => {
test("GET /newsletter/subscribers rejects unauthenticated request with 401", async () => {
const res = await req(newsletterRoute, "/newsletter/subscribers");
expect(res.status).toBe(401);
});
});
describe("Media routes", () => {
test("GET /media rejects unauthenticated request with 401", async () => {
const res = await req(mediaRoute, "/media");
expect(res.status).toBe(401);
});
test("DELETE /media/1 rejects unauthenticated request with 401", async () => {
const res = await req(mediaRoute, "/media/1", "DELETE");
expect(res.status).toBe(401);
});
});
});
+2 -1
View File
@@ -2,12 +2,13 @@ import { Elysia, t } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { JWT_SECRET } from "../middleware/auth";
export const authRoute = new Elysia({ prefix: "/auth" }) export const authRoute = new Elysia({ prefix: "/auth" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: process.env.JWT_SECRET || "homaroma_secret_2026_scent", secret: JWT_SECRET,
exp: "24h", exp: "24h",
}) })
) )
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces";
function extractShopeeAddress(order: any): { province: string; district: string } { function extractShopeeAddress(order: any): { province: string; district: string } {
@@ -53,6 +54,7 @@ function buildStatusFilter(statusFilter: string = "completed") {
} }
export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" }) export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" })
.use(adminOnly)
// ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ────────── // ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ──────────
.get("/sales-map", async ({ query }) => { .get("/sales-map", async ({ query }) => {
const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada'
+116 -114
View File
@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { purgeCategories } from "../lib/catalogMaintenance"; import { purgeCategories } from "../lib/catalogMaintenance";
import { adminOnly } from "../middleware/auth";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const; const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
@@ -118,120 +119,6 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: resolveCatName(cat, lang), name: resolveCatName(cat, lang),
})); }));
}) })
.post("/bulk-import", async ({ body, status }) => {
try {
const raw = body as any;
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
if (list.length === 0) {
return status(400, { message: "No categories provided in array" });
}
const results = [];
let createdCount = 0;
let updatedCount = 0;
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
if (item.id && typeof item.id === 'number') {
category = await prisma.category.findUnique({
where: { id: item.id, deletedAt: null },
});
}
if (!category && baseName) {
category = await prisma.category.findFirst({
where: { name: baseName, deletedAt: null },
});
}
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
if (Object.keys(updateData).length > 0) {
category = await prisma.category.update({
where: { id: category.id },
data: updateData,
});
}
updatedCount++;
} else {
// Create new
category = await prisma.category.create({
data: {
name: baseName,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
? translations[lang]
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
if (transName && typeof transName === 'string' && transName.trim() !== '') {
await prisma.categoryTranslation.upsert({
where: {
categoryId_langCode: {
categoryId: category.id,
langCode: lang,
},
},
update: { name: transName.trim() },
create: {
categoryId: category.id,
langCode: lang,
name: transName.trim(),
},
});
}
}
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
categories: results,
};
} catch (err: any) {
console.error("Bulk import categories error:", err);
return status(500, { message: err?.message || "Failed to bulk import categories" });
}
})
.get("/:id", async ({ params, query, status }) => { .get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
const lang = (query as any).lang || 'all'; const lang = (query as any).lang || 'all';
@@ -387,6 +274,121 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
}, },
}; };
}) })
.use(adminOnly)
.post("/bulk-import", async ({ body, status }) => {
try {
const raw = body as any;
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
if (list.length === 0) {
return status(400, { message: "No categories provided in array" });
}
const results = [];
let createdCount = 0;
let updatedCount = 0;
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
if (item.id && typeof item.id === 'number') {
category = await prisma.category.findUnique({
where: { id: item.id, deletedAt: null },
});
}
if (!category && baseName) {
category = await prisma.category.findFirst({
where: { name: baseName, deletedAt: null },
});
}
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
if (Object.keys(updateData).length > 0) {
category = await prisma.category.update({
where: { id: category.id },
data: updateData,
});
}
updatedCount++;
} else {
// Create new
category = await prisma.category.create({
data: {
name: baseName,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
? translations[lang]
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
if (transName && typeof transName === 'string' && transName.trim() !== '') {
await prisma.categoryTranslation.upsert({
where: {
categoryId_langCode: {
categoryId: category.id,
langCode: lang,
},
},
update: { name: transName.trim() },
create: {
categoryId: category.id,
langCode: lang,
name: transName.trim(),
},
});
}
}
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
categories: results,
};
} catch (err: any) {
console.error("Bulk import categories error:", err);
return status(500, { message: err?.message || "Failed to bulk import categories" });
}
})
.patch("/:id/translations", async ({ params, body, status }) => { .patch("/:id/translations", async ({ params, body, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" }); if (isNaN(id)) return status(400, { message: "Invalid category ID" });
@@ -7,6 +7,7 @@ import {
validateCategorySectionPayload, validateCategorySectionPayload,
} from "../lib/categorySections"; } from "../lib/categorySections";
import { hydrateSectionPayload } from "./pages"; import { hydrateSectionPayload } from "./pages";
import { adminOnly } from "../middleware/auth";
function summarizeValidationIssues(issues: any[]) { function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({ return issues.map((issue) => ({
@@ -43,6 +44,8 @@ export const categorySectionsRoute = new Elysia({ prefix: "/categories" })
); );
}) })
.use(adminOnly)
// Seed default sections // Seed default sections
.post("/:id/sections/seed-default", async ({ params, status }) => { .post("/:id/sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma.js"; import prisma from "../lib/prisma.js";
import { adminOnly } from "../middleware/auth";
export const contactsRoute = new Elysia({ prefix: "/contacts" }) export const contactsRoute = new Elysia({ prefix: "/contacts" })
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
@@ -27,6 +28,7 @@ export const contactsRoute = new Elysia({ prefix: "/contacts" })
message: t.String({ minLength: 1 }) message: t.String({ minLength: 1 })
}) })
}) })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
return await prisma.contactMessage.findMany({ return await prisma.contactMessage.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+107 -101
View File
@@ -1,13 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const couponsRoute = new Elysia({ prefix: "/coupons" }) export const couponsRoute = new Elysia({ prefix: "/coupons" })
// Get all coupons (Admin) // ── Public Storefront Endpoints ───────────────────────────────────────────
.get("/", async () => {
return await prisma.coupon.findMany({
orderBy: { createdAt: 'desc' }
});
})
// Get active coupons (Storefront) // Get active coupons (Storefront)
.get("/active", async () => { .get("/active", async () => {
const now = new Date(); const now = new Date();
@@ -22,98 +18,7 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' }
}); });
}) })
// Create new coupon (Admin)
.post("/", async ({ body, set }) => {
try {
const data = body as any;
// Check if code already exists
const existing = await prisma.coupon.findUnique({
where: { code: data.code.toUpperCase() }
});
if (existing) {
set.status = 400;
return { error: "Coupon code already exists" };
}
const coupon = await prisma.coupon.create({
data: {
code: data.code.toUpperCase(),
type: data.type,
value: data.value,
minPurchase: data.minPurchase || 0,
maxDiscount: data.maxDiscount || null,
usageLimit: data.usageLimit || null,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive !== undefined ? data.isActive : true
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Create Error:", err);
set.status = 500;
return { error: "Failed to create coupon" };
}
}, {
body: t.Object({
code: t.String(),
type: t.String(),
value: t.Number(),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Update coupon (Admin)
.patch("/:id", async ({ params: { id }, body, set }) => {
try {
const data = body as any;
const coupon = await prisma.coupon.update({
where: { id: Number(id) },
data: {
...(data.code && { code: data.code.toUpperCase() }),
...(data.type && { type: data.type }),
...(data.value !== undefined && { value: data.value }),
...(data.minPurchase !== undefined && { minPurchase: data.minPurchase }),
...(data.maxDiscount !== undefined && { maxDiscount: data.maxDiscount }),
...(data.usageLimit !== undefined && { usageLimit: data.usageLimit }),
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : null }),
...(data.isActive !== undefined && { isActive: data.isActive })
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Update Error:", err);
set.status = 500;
return { error: "Failed to update coupon" };
}
}, {
body: t.Object({
code: t.Optional(t.String()),
type: t.Optional(t.String()),
value: t.Optional(t.Number()),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Delete coupon (Admin)
.delete("/:id", async ({ params: { id }, set }) => {
try {
await prisma.coupon.delete({
where: { id: Number(id) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete coupon" };
}
})
// Validate coupon (Storefront) // Validate coupon (Storefront)
.post("/validate", async ({ body, set }) => { .post("/validate", async ({ body, set }) => {
try { try {
@@ -159,7 +64,6 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
} else if (coupon.type === "FIXED_AMOUNT") { } else if (coupon.type === "FIXED_AMOUNT") {
discountAmount = coupon.value; discountAmount = coupon.value;
} else if (coupon.type === "FREE_SHIPPING") { } else if (coupon.type === "FREE_SHIPPING") {
// We handle free shipping separately, returning discountAmount = 0 but a flag
return { return {
valid: true, valid: true,
coupon, coupon,
@@ -168,7 +72,6 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
}; };
} }
// Ensure discount doesn't exceed cart total
if (discountAmount > cartTotal) { if (discountAmount > cartTotal) {
discountAmount = cartTotal; discountAmount = cartTotal;
} }
@@ -190,4 +93,107 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
code: t.String(), code: t.String(),
cartTotal: t.Number() cartTotal: t.Number()
}) })
}); })
// ── Protected Admin Endpoints ─────────────────────────────────────────────
.guard((app) =>
app
.use(adminOnly)
// Get all coupons (Admin)
.get("/", async () => {
return await prisma.coupon.findMany({
orderBy: { createdAt: 'desc' }
});
})
// Create new coupon (Admin)
.post("/", async ({ body, set }) => {
try {
const data = body as any;
const existing = await prisma.coupon.findUnique({
where: { code: data.code.toUpperCase() }
});
if (existing) {
set.status = 400;
return { error: "Coupon code already exists" };
}
const coupon = await prisma.coupon.create({
data: {
code: data.code.toUpperCase(),
type: data.type,
value: data.value,
minPurchase: data.minPurchase || 0,
maxDiscount: data.maxDiscount || null,
usageLimit: data.usageLimit || null,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive !== undefined ? data.isActive : true
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Create Error:", err);
set.status = 500;
return { error: "Failed to create coupon" };
}
}, {
body: t.Object({
code: t.String(),
type: t.String(),
value: t.Number(),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Update coupon (Admin)
.patch("/:id", async ({ params: { id }, body, set }) => {
try {
const data = body as any;
const coupon = await prisma.coupon.update({
where: { id: Number(id) },
data: {
...(data.code && { code: data.code.toUpperCase() }),
...(data.type && { type: data.type }),
...(data.value !== undefined && { value: data.value }),
...(data.minPurchase !== undefined && { minPurchase: data.minPurchase }),
...(data.maxDiscount !== undefined && { maxDiscount: data.maxDiscount }),
...(data.usageLimit !== undefined && { usageLimit: data.usageLimit }),
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : null }),
...(data.isActive !== undefined && { isActive: data.isActive })
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Update Error:", err);
set.status = 500;
return { error: "Failed to update coupon" };
}
}, {
body: t.Object({
code: t.Optional(t.String()),
type: t.Optional(t.String()),
value: t.Optional(t.Number()),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Delete coupon (Admin)
.delete("/:id", async ({ params: { id }, set }) => {
try {
await prisma.coupon.delete({
where: { id: Number(id) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete coupon" };
}
})
);
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const crmRoute = new Elysia({ prefix: "/crm" }) export const crmRoute = new Elysia({ prefix: "/crm" })
.use(adminOnly)
// ── 1. Import Single / Multiple Customers ───────────────────────────────── // ── 1. Import Single / Multiple Customers ─────────────────────────────────
.post( .post(
"/import", "/import",
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const customersRoute = new Elysia({ prefix: "/customers" }) export const customersRoute = new Elysia({ prefix: "/customers" })
.use(adminOnly)
.get("/", async ({ query }) => { .get("/", async ({ query }) => {
const search = query.search as string | undefined; const search = query.search as string | undefined;
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
const toNum = (val: any): number => { const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0; if (val === null || val === undefined || val === "") return 0;
@@ -26,6 +27,7 @@ const parseLazadaDate = (val: any): string => {
}; };
export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" }) export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" })
.use(adminOnly)
// ── Import (Excel rows as JSON) ─────────────────────────────────────────── // ── Import (Excel rows as JSON) ───────────────────────────────────────────
.post( .post(
+2
View File
@@ -1,7 +1,9 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const mediaRoute = new Elysia({ prefix: "/media" }) export const mediaRoute = new Elysia({ prefix: "/media" })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
const media = await prisma.media.findMany({ const media = await prisma.media.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const newsletterRoute = new Elysia({ prefix: "/newsletter" }) export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
.post("/subscribe", async ({ body, set }) => { .post("/subscribe", async ({ body, set }) => {
@@ -29,6 +30,7 @@ export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
email: t.String({ format: "email" }) email: t.String({ format: "email" })
}) })
}) })
.use(adminOnly)
.get("/subscribers", async () => { .get("/subscribers", async () => {
return await prisma.newsletterSubscriber.findMany({ return await prisma.newsletterSubscriber.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+88 -84
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { import {
sendOrderCreatedNotification, sendOrderCreatedNotification,
sendPaymentConfirmedNotification, sendPaymentConfirmedNotification,
@@ -9,12 +10,6 @@ import {
} from "../lib/notifications"; } from "../lib/notifications";
export const ordersRoute = new Elysia({ prefix: "/orders" }) export const ordersRoute = new Elysia({ prefix: "/orders" })
.get("/", async () => {
return await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
include: { items: true, customer: true }
});
})
.get("/:id", async ({ params: { id }, query, set }) => { .get("/:id", async ({ params: { id }, query, set }) => {
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: Number(id) }, where: { id: Number(id) },
@@ -245,54 +240,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
})) }))
}) })
}) })
.patch("/:id/status", async ({ params: { id }, body, set }) => {
try {
const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
if (status === 'PAID') {
await markOrderAsPaid(order.id);
} else {
await prisma.order.update({
where: { id: order.id },
data: { status }
});
// If status becomes CANCELLED, refund the points used and notify
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
if (order.customerId && order.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
}
// If status becomes SHIPPED, notify
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
sendOrderShippedNotification(order.id).catch(e => console.error(e));
}
}
return await prisma.order.findUnique({
where: { id: order.id },
include: { items: true, customer: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update order status" };
}
}, {
body: t.Object({
status: t.String()
})
})
.patch("/:id/slip", async ({ params: { id }, body, set }) => { .patch("/:id/slip", async ({ params: { id }, body, set }) => {
try { try {
const { paymentSlipUrl } = body as any; const { paymentSlipUrl } = body as any;
@@ -310,35 +257,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
paymentSlipUrl: t.String() paymentSlipUrl: t.String()
}) })
}) })
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
const order = await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo, status: "SHIPPED" },
include: { items: true }
});
// Trigger notification asynchronously
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
orderBy: { createdAt: 'desc' },
include: { items: true }
});
})
.post("/track", async ({ body, set }) => { .post("/track", async ({ body, set }) => {
try { try {
const { orderId, phone } = body as any; const { orderId, phone } = body as any;
@@ -366,4 +284,90 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
orderId: t.Number(), orderId: t.Number(),
phone: t.String() phone: t.String()
}) })
}); })
// ── Protected Admin Endpoints ─────────────────────────────────────────────
.guard((app) =>
app
.use(adminOnly)
.get("/", async () => {
return await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
include: { items: true, customer: true }
});
})
.patch("/:id/status", async ({ params: { id }, body, set }) => {
try {
const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
if (status === 'PAID') {
await markOrderAsPaid(order.id);
} else {
await prisma.order.update({
where: { id: order.id },
data: { status }
});
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
if (order.customerId && order.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
}
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
sendOrderShippedNotification(order.id).catch(e => console.error(e));
}
}
return await prisma.order.findUnique({
where: { id: order.id },
include: { items: true, customer: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update order status" };
}
}, {
body: t.Object({
status: t.String()
})
})
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
const order = await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo, status: "SHIPPED" },
include: { items: true }
});
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
orderBy: { createdAt: 'desc' },
include: { items: true }
});
})
);
-3
View File
@@ -1485,9 +1485,6 @@ async function resolveProductsForSection(payload: any, lang: string) {
brand: true, brand: true,
translations: true, translations: true,
variants: { variants: {
include: {
bomItems: { include: { rawMaterial: true } },
},
orderBy: { price: "asc" as const }, orderBy: { price: "asc" as const },
}, },
images: { images: {
+27 -29
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { import {
SUPPORTED_LANGS, SUPPORTED_LANGS,
localizeAttributeDefinition, localizeAttributeDefinition,
@@ -154,10 +155,36 @@ export const productAttributesRoute = new Elysia()
if (!definition) return status(404, { message: "Attribute definition not found" }); if (!definition) return status(404, { message: "Attribute definition not found" });
return localizeAttributeDefinition(definition, lang); return localizeAttributeDefinition(definition, lang);
.get("/products/:id/attributes", async ({ params, query, status }) => {
try {
const productId = parseId(params.id, "product ID");
const lang = (query as any).lang || 'th';
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const attributes = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
} catch (err: any) { } catch (err: any) {
return status(400, { message: err.message }); return status(400, { message: err.message });
} }
}) })
.use(adminOnly)
.post("/product-attributes/definitions/bulk-import", async ({ body, status }) => { .post("/product-attributes/definitions/bulk-import", async ({ body, status }) => {
try { try {
const payload = body as any; const payload = body as any;
@@ -351,35 +378,6 @@ export const productAttributesRoute = new Elysia()
}, { }, {
body: t.Any(), body: t.Any(),
}) })
.get("/products/:id/attributes", async ({ params, query, status }) => {
try {
const productId = parseId(params.id, "product ID");
const lang = (query as any).lang || 'th';
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const attributes = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
} catch (err: any) {
return status(400, { message: err.message });
}
})
.post("/products/:id/attributes", async ({ params, body, status }) => { .post("/products/:id/attributes", async ({ params, body, status }) => {
try { try {
const productId = parseId(params.id, "product ID"); const productId = parseId(params.id, "product ID");
+199 -203
View File
@@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { purgeProducts } from "../lib/catalogMaintenance"; import { purgeProducts } from "../lib/catalogMaintenance";
import { localizeProductAttribute } from "../lib/productAttributes"; import { localizeProductAttribute } from "../lib/productAttributes";
import { adminOnly } from "../middleware/auth";
import { import {
createCustomProductDetailSection, createCustomProductDetailSection,
createProductMainDetailSection, createProductMainDetailSection,
@@ -224,6 +225,204 @@ export const productsRoute = new Elysia({ prefix: "/products" })
} }
}); });
}) })
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
{ variants: { some: { OR: [
{ barcode: { contains: search, mode: "insensitive" as const } },
{ sku: { contains: search, mode: "insensitive" as const } }
] } } },
],
}),
} as const;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
// Map translations
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...p,
name: nameObj,
description: descObj,
details: detailsObj,
category: { ...p.category, name: p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0];
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: mappedProducts,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
})
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
});
const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0];
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
.use(adminOnly)
.post("/brands", async ({ body, status }) => { .post("/brands", async ({ body, status }) => {
const payload = body as any; const payload = body as any;
const name = payload.name?.trim(); const name = payload.name?.trim();
@@ -315,122 +514,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { success: true, translations: results, brand: updated }; return { success: true, translations: results, brand: updated };
}) })
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
{ variants: { some: { OR: [
{ barcode: { contains: search, mode: "insensitive" as const } },
{ sku: { contains: search, mode: "insensitive" as const } }
] } } },
],
}),
} as const;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
// Map translations
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...p,
name: nameObj,
description: descObj,
details: detailsObj,
category: { ...p.category, name: p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0];
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: mappedProducts,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
})
.get("/bom-status", async ({ query }) => { .get("/bom-status", async ({ query }) => {
const search = query.search?.trim(); const search = query.search?.trim();
const filterStatus = query.status?.trim() || "all"; const filterStatus = query.status?.trim() || "all";
@@ -520,74 +603,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
products: filtered, products: filtered,
}; };
}) })
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
});
const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0];
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
.get("/:id/bom", async ({ params, status }) => { .get("/:id/bom", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
@@ -845,25 +860,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined, unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined,
}; };
}) })
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.post("/:id/detail-sections/seed-default", async ({ params, status }) => { .post("/:id/detail-sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
+272 -138
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
// ── Date Range Helper ──────────────────────────────────────────────────────── // ── Date Range Helper ────────────────────────────────────────────────────────
interface DateRangeInfo { interface DateRangeInfo {
@@ -60,7 +61,17 @@ function parseOrderDate(dateStr: string | Date | null | undefined): Date | null
return isNaN(d.getTime()) ? null : d; return isNaN(d.getTime()) ? null : d;
} }
const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0;
const s = String(val).replace(/[^0-9.\-]/g, "");
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
};
const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback
export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
.use(adminOnly)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 1. Cost & Profitability Matrix (Catalog-Level Baseline) // 1. Cost & Profitability Matrix (Catalog-Level Baseline)
@@ -286,7 +297,7 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
.get("/omnichannel", async ({ query }) => { .get("/omnichannel", async ({ query }) => {
const search = (query.search || "").trim().toLowerCase(); const search = (query.search || "").trim().toLowerCase();
const channel = (query.channel || "all").trim().toLowerCase(); const channel = (query.channel || "all").trim().toLowerCase();
const dateRange = resolveDateRange(query.period, query.startDate, query.endDate); const dateRange = resolveDateRange(query.period || "this_month", query.startDate, query.endDate);
// 1. Fetch mapped marketplace products with master variants & BOM // 1. Fetch mapped marketplace products with master variants & BOM
const mpProducts = await prisma.marketplaceProduct.findMany({ const mpProducts = await prisma.marketplaceProduct.findMany({
@@ -313,152 +324,226 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
orderBy: { totalRevenue: "desc" }, orderBy: { totalRevenue: "desc" },
}); });
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = { // Look up helper: map marketplace item keys to marketplaceProduct.id
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21% const mpLookup = new Map<string, number>();
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21% for (const mp of mpProducts) {
tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5% const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
}; mpLookup.set(key, mp.id);
if (mp.marketplaceSku) {
// 2. If a specific Date Range is selected, calculate dynamic sales from Orders mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
let dynamicSalesMap: Map<number, { soldQty: number; revenue: number }> | null = null;
if (dateRange.start) {
dynamicSalesMap = new Map();
const start = dateRange.start;
const end = dateRange.end || new Date();
// Look up helper: map marketplace item keys to marketplaceProduct.id
const mpLookup = new Map<string, number>();
for (const mp of mpProducts) {
const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
mpLookup.set(key, mp.id);
if (mp.marketplaceSku) {
mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
}
} }
}
// A. TikTok Orders in Range // 2. Dynamic Order Aggregator (Actual Fee Extraction with 30% Fallback)
if (channel === "all" || channel === "tiktok") { interface DynamicSalesEntry {
const ttOrders = await prisma.tiktokOrder.findMany({ soldQty: number;
where: { createdTime: { not: null } }, revenue: number;
select: { actualFeeSum: number;
createdTime: true, fallbackFeeSum: number;
shop: true, actualCount: number;
items: { fallbackCount: number;
select: { }
sellerSku: true,
skuId: true, const dynamicSalesMap = new Map<number, DynamicSalesEntry>();
productName: true, const start = dateRange.start;
variation: true, const end = dateRange.end || new Date();
quantity: true,
subtotalAfterDiscount: true, // A. TikTok Orders
unitOriginalPrice: true, if (channel === "all" || channel === "tiktok") {
}, const ttOrders = await prisma.tiktokOrder.findMany({
where: { createdTime: { not: null } },
select: {
orderId: true,
createdTime: true,
shop: true,
orderAmount: true,
commissionFee: true,
affiliateFee: true,
netSettlement: true,
isSettled: true,
items: {
select: {
sellerSku: true,
skuId: true,
productName: true,
variation: true,
quantity: true,
subtotalAfterDiscount: true,
unitOriginalPrice: true,
}, },
}, },
}); },
});
for (const order of ttOrders) { for (const order of ttOrders) {
const orderDate = parseOrderDate(order.createdTime); const orderDate = parseOrderDate(order.createdTime);
if (!orderDate || orderDate < start || orderDate > end) continue; if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
for (const item of order.items) { const orderAmount = order.orderAmount || 0;
const sku = item.sellerSku || item.skuId || ""; // Check actual deduction from settlement or commission
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`; const hasSettlement = order.isSettled && order.netSettlement > 0;
const key2 = `tiktok:::${sku}`; const actualOrderFee = hasSettlement
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null); ? Math.max(0, orderAmount - order.netSettlement)
if (!mpId) continue; : ((order.commissionFee || 0) + (order.affiliateFee || 0));
const isActual = actualOrderFee > 0;
const qty = item.quantity || 1; for (const item of order.items) {
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty); const sku = item.sellerSku || item.skuId || "";
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 }; const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
cur.soldQty += qty; const key2 = `tiktok:::${sku}`;
cur.revenue += rev; const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
dynamicSalesMap.set(mpId, cur); if (!mpId) continue;
const qty = item.quantity || 1;
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty;
cur.revenue += rev;
if (isActual) {
// Proportional share of actual order deduction
const share = orderAmount > 0 ? (rev / orderAmount) : 1;
cur.actualFeeSum += actualOrderFee * share;
cur.actualCount += 1;
} else {
// Fallback 30%
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
cur.fallbackCount += 1;
} }
dynamicSalesMap.set(mpId, cur);
} }
} }
}
// B. Shopee Orders in Range // B. Shopee Orders
if (channel === "all" || channel === "shopee") { if (channel === "all" || channel === "shopee") {
const spOrders = await prisma.shopeeOrder.findMany({ const spOrders = await prisma.shopeeOrder.findMany({
where: { orderCreatedAt: { not: null } }, where: { orderCreatedAt: { not: null } },
select: { select: {
orderCreatedAt: true, orderId: true,
shop: true, orderCreatedAt: true,
items: { shop: true,
select: { totalAmount: true,
parentSku: true, items: {
skuRef: true, select: {
productName: true, parentSku: true,
variantName: true, skuRef: true,
quantity: true, productName: true,
totalPayment: true, variantName: true,
dealPrice: true, quantity: true,
}, totalPayment: true,
dealPrice: true,
rawData: true,
}, },
}, },
}); },
});
for (const order of spOrders) { for (const order of spOrders) {
const orderDate = parseOrderDate(order.orderCreatedAt); const orderDate = parseOrderDate(order.orderCreatedAt);
if (!orderDate || orderDate < start || orderDate > end) continue; if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
for (const item of order.items) { // Shopee Order-level actual fee from rawData (first item has order-level fees)
const sku = item.skuRef || item.parentSku || ""; const firstRaw = (order.items[0]?.rawData as any) || {};
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`; const comm = toNum(firstRaw["ค่าคอมมิชชั่น"]);
const key2 = `shopee:::${sku}`; const service = toNum(firstRaw["ค่าบริการ"]);
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null); const tx = toNum(firstRaw["Transaction Fee"]);
if (!mpId) continue; const voucher = toNum(firstRaw["โค้ดส่วนลดชำระโดยผู้ขาย"]);
const orderActualFee = comm + service + tx + voucher;
const isActual = orderActualFee > 0;
const orderTotalRev = order.totalAmount || order.items.reduce((sum, it) => sum + (it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * it.quantity)), 0);
const qty = item.quantity || 1; for (const item of order.items) {
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty); const sku = item.skuRef || item.parentSku || "";
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 }; const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
cur.soldQty += qty; const key2 = `shopee:::${sku}`;
cur.revenue += rev; const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
dynamicSalesMap.set(mpId, cur); if (!mpId) continue;
const qty = item.quantity || 1;
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty;
cur.revenue += rev;
if (isActual) {
const share = orderTotalRev > 0 ? (rev / orderTotalRev) : 1;
cur.actualFeeSum += orderActualFee * share;
cur.actualCount += 1;
} else {
// Fallback 30%
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
cur.fallbackCount += 1;
} }
dynamicSalesMap.set(mpId, cur);
} }
} }
}
// C. Lazada Orders in Range // C. Lazada Orders
if (channel === "all" || channel === "lazada") { if (channel === "all" || channel === "lazada") {
const lzOrders = await prisma.lazadaOrder.findMany({ const lzOrders = await prisma.lazadaOrder.findMany({
where: { createTime: { not: null } }, where: { createTime: { not: null } },
select: { select: {
createTime: true, createTime: true,
shop: true, shop: true,
items: { items: {
select: { select: {
sellerSku: true, sellerSku: true,
lazadaSku: true, lazadaSku: true,
itemName: true, itemName: true,
variation: true, variation: true,
paidPrice: true, paidPrice: true,
unitPrice: true, unitPrice: true,
},
}, },
}, },
}); },
});
for (const order of lzOrders) { for (const order of lzOrders) {
const orderDate = parseOrderDate(order.createTime); const orderDate = parseOrderDate(order.createTime);
if (!orderDate || orderDate < start || orderDate > end) continue; if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
for (const item of order.items) { for (const item of order.items) {
const sku = item.sellerSku || item.lazadaSku || ""; const sku = item.sellerSku || item.lazadaSku || "";
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`; const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
const key2 = `lazada:::${sku}`; const key2 = `lazada:::${sku}`;
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null); const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
if (!mpId) continue; if (!mpId) continue;
const qty = 1; const qty = 1;
const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice; const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice;
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
cur.soldQty += qty; const cur = dynamicSalesMap.get(mpId) || {
cur.revenue += rev; soldQty: 0,
dynamicSalesMap.set(mpId, cur); revenue: 0,
} actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty;
cur.revenue += rev;
// Lazada has no settlement import yet -> 30% fallback
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
cur.fallbackCount += 1;
dynamicSalesMap.set(mpId, cur);
} }
} }
} }
@@ -469,25 +554,58 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
let totalBOMCost = 0; let totalBOMCost = 0;
let totalEstNetProfit = 0; let totalEstNetProfit = 0;
let totalUnitsSold = 0; let totalUnitsSold = 0;
let totalPlatformFee = 0;
let totalActualFeeSum = 0;
let totalFallbackFeeSum = 0;
let totalActualOrdersCount = 0;
let totalFallbackOrdersCount = 0;
const channelStats: Record<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = { const channelStats: Record<string, { soldQty: number; revenue: number; platformFee: number; bomCost: number; netProfit: number }> = {
shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, shopee: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
lazada: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, lazada: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, tiktok: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
}; };
const mappedItems: any[] = []; const mappedItems: any[] = [];
for (const item of mpProducts) { for (const item of mpProducts) {
let soldQty = item.totalSoldQty; let soldQty = 0;
let rev = item.totalRevenue; let rev = 0;
let platformFee = 0;
let feeSource: "actual" | "hybrid" | "fallback_30pct" = "fallback_30pct";
let actualFeePart = 0;
let fallbackFeePart = 0;
// If dynamic date range active, use filtered order numbers const dyn = dynamicSalesMap.get(item.id);
if (dynamicSalesMap) {
const dyn = dynamicSalesMap.get(item.id); if (dyn && dyn.soldQty > 0) {
if (!dyn || dyn.soldQty === 0) continue; // Skip items with no sales in this date range
soldQty = dyn.soldQty; soldQty = dyn.soldQty;
rev = dyn.revenue; rev = dyn.revenue;
actualFeePart = dyn.actualFeeSum;
fallbackFeePart = dyn.fallbackFeeSum;
platformFee = Math.round((dyn.actualFeeSum + dyn.fallbackFeeSum) * 100) / 100;
if (dyn.actualCount > 0 && dyn.fallbackCount === 0) {
feeSource = "actual";
} else if (dyn.actualCount > 0 && dyn.fallbackCount > 0) {
feeSource = "hybrid";
} else {
feeSource = "fallback_30pct";
}
totalActualOrdersCount += dyn.actualCount;
totalFallbackOrdersCount += dyn.fallbackCount;
} else if (!start) {
// All-time fallback from MarketplaceProduct aggregate if no order details
if (item.totalSoldQty === 0 && item.totalRevenue === 0) continue;
soldQty = item.totalSoldQty;
rev = item.totalRevenue;
platformFee = Math.round(rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100) * 100) / 100;
fallbackFeePart = platformFee;
feeSource = "fallback_30pct";
totalFallbackOrdersCount += 1;
} else {
// Date range filtered and no sales in this period
continue;
} }
const v = item.masterVariant!; const v = item.masterVariant!;
@@ -498,13 +616,11 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100; const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100;
const ch = item.channel.toLowerCase(); const ch = item.channel.toLowerCase();
const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 };
const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent;
const estPlatformFee = Math.round(rev * (totalFeePercent / 100) * 100) / 100; const netRevenue = Math.round((rev - platformFee) * 100) / 100;
const netRevenue = Math.round((rev - estPlatformFee) * 100) / 100;
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100; const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0; const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0;
const effectiveFeePercent = rev > 0 ? Math.round((platformFee / rev) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
const avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice; const avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice;
const directPrice = v.price || avgSellingPrice || 0; const directPrice = v.price || avgSellingPrice || 0;
@@ -512,14 +628,18 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0; const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
totalGrossRevenue += rev; totalGrossRevenue += rev;
totalPlatformFee += platformFee;
totalEstNetRevenue += netRevenue; totalEstNetRevenue += netRevenue;
totalBOMCost += itemBomCost; totalBOMCost += itemBomCost;
totalEstNetProfit += netProfit; totalEstNetProfit += netProfit;
totalUnitsSold += soldQty; totalUnitsSold += soldQty;
totalActualFeeSum += actualFeePart;
totalFallbackFeeSum += fallbackFeePart;
if (channelStats[ch]) { if (channelStats[ch]) {
channelStats[ch].soldQty += soldQty; channelStats[ch].soldQty += soldQty;
channelStats[ch].revenue += rev; channelStats[ch].revenue += rev;
channelStats[ch].platformFee += platformFee;
channelStats[ch].bomCost += itemBomCost; channelStats[ch].bomCost += itemBomCost;
channelStats[ch].netProfit += netProfit; channelStats[ch].netProfit += netProfit;
} }
@@ -540,8 +660,9 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
categoryName: v.product?.category?.name || "Uncategorized", categoryName: v.product?.category?.name || "Uncategorized",
unitBomCost: Math.round(unitBomCost * 100) / 100, unitBomCost: Math.round(unitBomCost * 100) / 100,
totalBomCost: itemBomCost, totalBomCost: itemBomCost,
totalFeePercent, platformFee,
estPlatformFee, effectiveFeePercent,
feeSource, // "actual" | "hybrid" | "fallback_30pct"
netRevenue, netRevenue,
netProfit, netProfit,
netMarginPercent, netMarginPercent,
@@ -563,6 +684,8 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
const overallNetMargin = const overallNetMargin =
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0; totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
const effectiveFeeRate =
totalGrossRevenue > 0 ? Math.round((totalPlatformFee / totalGrossRevenue) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
return { return {
periodInfo: { periodInfo: {
@@ -572,14 +695,25 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null, endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
daysCount: dateRange.daysCount, daysCount: dateRange.daysCount,
}, },
feePolicy: {
mode: "hybrid_actual_with_30pct_fallback",
fallbackPercent: DEFAULT_FALLBACK_FEE_PERCENT,
description: "Shopee uses actual fees from report rawData. TikTok uses actual net settlement & fees. Any unmapped or non-settled order falls back to 30% platform deduction.",
},
kpis: { kpis: {
totalMappedProducts: mappedItems.length, totalMappedProducts: mappedItems.length,
totalUnitsSold, totalUnitsSold,
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100, totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
totalPlatformFee: Math.round(totalPlatformFee * 100) / 100,
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100, totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
totalBOMCost: Math.round(totalBOMCost * 100) / 100, totalBOMCost: Math.round(totalBOMCost * 100) / 100,
totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100, totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100,
overallNetMargin, overallNetMargin,
effectiveFeeRate,
actualFeeAmount: Math.round(totalActualFeeSum * 100) / 100,
fallbackFeeAmount: Math.round(totalFallbackFeeSum * 100) / 100,
actualOrdersCount: totalActualOrdersCount,
fallbackOrdersCount: totalFallbackOrdersCount,
channelStats, channelStats,
}, },
items: filtered, items: filtered,
+68 -60
View File
@@ -1,9 +1,11 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const promotionsRoute = new Elysia({ prefix: "/promotions" }) export const promotionsRoute = new Elysia({ prefix: "/promotions" })
// ── Public Storefront Endpoints ───────────────────────────────────────────
.get("/active", async () => { .get("/active", async () => {
const now = new Date() const now = new Date();
return await prisma.promotion.findMany({ return await prisma.promotion.findMany({
where: { where: {
isActive: true, isActive: true,
@@ -18,62 +20,68 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
orderBy: { level: 'asc' } orderBy: { level: 'asc' }
}); });
}) })
.get("/", async () => {
return await prisma.promotion.findMany({ // ── Protected Admin Endpoints ─────────────────────────────────────────────
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }] .guard((app) =>
}); app
}) .use(adminOnly)
.post("/", async ({ body, set }) => { .get("/", async () => {
try { return await prisma.promotion.findMany({
const data = body as any; orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
return await prisma.promotion.create({ data }); });
} catch (err) { })
set.status = 500; .post("/", async ({ body, set }) => {
return { error: "Failed to create promotion" }; try {
} const data = body as any;
}, { return await prisma.promotion.create({ data });
body: t.Object({ } catch (err) {
name: t.String(), set.status = 500;
level: t.Number(), return { error: "Failed to create promotion" };
type: t.String(), }
value: t.Number(), }, {
minPurchase: t.Number(), body: t.Object({
isActive: t.Boolean(), name: t.String(),
startDate: t.Optional(t.Nullable(t.String())), level: t.Number(),
endDate: t.Optional(t.Nullable(t.String())) type: t.String(),
}) value: t.Number(),
}) minPurchase: t.Number(),
.put("/:id", async ({ params: { id }, body, set }) => { isActive: t.Boolean(),
try { startDate: t.Optional(t.Nullable(t.String())),
const data = body as any; endDate: t.Optional(t.Nullable(t.String()))
return await prisma.promotion.update({ })
where: { id: Number(id) }, })
data .put("/:id", async ({ params: { id }, body, set }) => {
}); try {
} catch (err) { const data = body as any;
set.status = 500; return await prisma.promotion.update({
return { error: "Failed to update promotion" }; where: { id: Number(id) },
} data
}, { });
body: t.Object({ } catch (err) {
name: t.String(), set.status = 500;
level: t.Number(), return { error: "Failed to update promotion" };
type: t.String(), }
value: t.Number(), }, {
minPurchase: t.Number(), body: t.Object({
isActive: t.Boolean(), name: t.String(),
startDate: t.Optional(t.Nullable(t.String())), level: t.Number(),
endDate: t.Optional(t.Nullable(t.String())) type: t.String(),
}) value: t.Number(),
}) minPurchase: t.Number(),
.delete("/:id", async ({ params: { id }, set }) => { isActive: t.Boolean(),
try { startDate: t.Optional(t.Nullable(t.String())),
await prisma.promotion.delete({ endDate: t.Optional(t.Nullable(t.String()))
where: { id: Number(id) } })
}); })
return { success: true }; .delete("/:id", async ({ params: { id }, set }) => {
} catch (err) { try {
set.status = 500; await prisma.promotion.delete({
return { error: "Failed to delete promotion" }; where: { id: Number(id) }
} });
}); return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete promotion" };
}
})
);
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" }) export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
const rawMaterials = await prisma.rawMaterial.findMany({ const rawMaterials = await prisma.rawMaterial.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -1,21 +1,41 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { settingsRoute } from './settings' import { settingsRoute } from './settings'
import { JWT_SECRET } from '../middleware/auth'
import { jwt } from '@elysiajs/jwt'
import { Elysia } from 'elysia'
function jsonRequest(path: string, method: string, body: unknown) { const signer = new Elysia().use(jwt({ name: 'jwt', secret: JWT_SECRET }))
function jsonRequest(path: string, method: string, body: unknown, token?: string) {
return new Request(`http://localhost${path}`, { return new Request(`http://localhost${path}`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
describe('footer layout route boundaries', () => { describe('footer layout route boundaries', () => {
for (const method of ['PUT', 'POST']) { for (const method of ['PUT', 'POST']) {
test(`${method} /settings rejects footerLayout writes`, async () => { test(`${method} /settings requires admin authentication`, async () => {
const response = await settingsRoute.handle(jsonRequest('/settings', method, { const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] }, footerLayout: { columns: ['brand'] },
})) }))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
})
test(`${method} /settings with admin token rejects footerLayout writes`, async () => {
const adminToken = await (signer as any).decorator.jwt.sign({ role: 'ADMIN' })
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] },
}, adminToken))
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
error: 'Use PATCH /settings/footer-layout to update footer layout', error: 'Use PATCH /settings/footer-layout to update footer layout',
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
@@ -17,6 +18,7 @@ function extractShopeeAddress(order: any): { province: string; district: string
} }
export const shopeeRoute = new Elysia({ prefix: "/shopee" }) export const shopeeRoute = new Elysia({ prefix: "/shopee" })
.use(adminOnly)
// ── Import Orders ───────────────────────────────────────────────────────── // ── Import Orders ─────────────────────────────────────────────────────────
.post( .post(
"/import/orders", "/import/orders",
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const tiktokRoute = new Elysia({ prefix: "/tiktok" }) export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
.use(adminOnly)
.post( .post(
"/import/income", "/import/income",
async ({ body, set }) => { async ({ body, set }) => {
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
@@ -234,6 +235,7 @@ async function enrichOrdersWithBOM(orders: any[], shop: string) {
} }
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
.use(adminOnly)
// ── Import Orders (CSV rows as JSON) ─────────────────────────────────────── // ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
.post( .post(
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const toolsRoute = new Elysia({ prefix: "/tools" }) export const toolsRoute = new Elysia({ prefix: "/tools" })
.use(adminOnly)
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ──────────── // ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
.get("/marketplace-products/master-variants", async () => { .get("/marketplace-products/master-variants", async () => {
@@ -7,7 +7,11 @@ import i18n from '../lib/i18n';
import { useCartStore } from '../store/cartStore'; import { useCartStore } from '../store/cartStore';
import { useWishlistStore } from '../store/wishlistStore'; import { useWishlistStore } from '../store/wishlistStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useCookieConsentStore } from '../store/cookieConsentStore';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../lib/locales'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../lib/locales';
import CookieBanner from './cookie/CookieBanner';
import CookiePreferencesModal from './cookie/CookiePreferencesModal';
import ScriptManager from './cookie/ScriptManager';
export default function ClientProviders({ children }: { children: ReactNode }) { export default function ClientProviders({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
@@ -25,13 +29,17 @@ export default function ClientProviders({ children }: { children: ReactNode }) {
void useCartStore.persist.rehydrate() void useCartStore.persist.rehydrate()
void useWishlistStore.persist.rehydrate() void useWishlistStore.persist.rehydrate()
void useAuthStore.persist.rehydrate() void useAuthStore.persist.rehydrate()
void useCookieConsentStore.persist.rehydrate()
}, []) }, [])
return ( return (
<> <>
<ScriptManager />
<ScrollToTop /> <ScrollToTop />
{children} {children}
<CartDrawer /> <CartDrawer />
<CookieBanner />
<CookiePreferencesModal />
</> </>
); );
} }
+8
View File
@@ -15,6 +15,7 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization' import { resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
import { useCookieConsentStore } from '../store/cookieConsentStore'
interface FooterProps { interface FooterProps {
categories?: Category[] categories?: Category[]
@@ -154,6 +155,13 @@ export default function Footer({
</h4> </h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-muted-text"> <div className="site-footer-column-links flex flex-col space-y-2 text-muted-text">
{supportMenu.map((item) => <Link key={item.id} href={item.url} className="ui-footer-link">{translateMenuLabel(item)}</Link>)} {supportMenu.map((item) => <Link key={item.id} href={item.url} className="ui-footer-link">{translateMenuLabel(item)}</Link>)}
<button
type="button"
onClick={() => useCookieConsentStore.getState().openPreferences()}
className="text-left ui-footer-link hover:underline transition-colors cursor-pointer text-xs"
>
{t('cookie.settings', 'การตั้งค่าคุกกี้')}
</button>
</div> </div>
<div className="pt-4"> <div className="pt-4">
<p className="text-[10px] uppercase tracking-widest text-muted-text"> <p className="text-[10px] uppercase tracking-widest text-muted-text">
@@ -0,0 +1,78 @@
"use client";
import { useCookieConsentStore } from '../../store/cookieConsentStore';
import { useTranslation } from 'react-i18next';
import { Cookie, Settings2, ShieldCheck } from 'lucide-react';
export default function CookieBanner() {
const { t } = useTranslation();
const { record, isHydrated, acceptAll, rejectAll, openPreferences } = useCookieConsentStore();
// Do not render on server or before rehydration, or if user has already made a choice
if (!isHydrated || record.hasResponded) {
return null;
}
return (
<aside
aria-label={t('cookie.bannerTitle', 'การตั้งค่าคุกกี้และความเป็นส่วนตัว')}
className="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6 pointer-events-none animate-in fade-in slide-in-from-bottom-6 duration-500"
>
<div className="max-w-5xl mx-auto pointer-events-auto bg-surface-card/95 backdrop-blur-md border border-border-color shadow-2xl rounded-2xl p-5 sm:p-6 text-text-main transition-all">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-5">
{/* Icon & Description */}
<div className="flex items-start gap-3.5 flex-1 pr-0 md:pr-4">
<div className="p-2.5 rounded-xl bg-brand-cool/60 text-brand-primary shrink-0 mt-0.5">
<Cookie className="w-5 h-5" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm sm:text-base text-text-main tracking-wide">
{t('cookie.bannerTitle', 'เราให้ความสำคัญกับความเป็นส่วนตัวของคุณ')}
</h3>
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-brand-cool/50 text-muted-text">
<ShieldCheck className="w-3 h-3 text-brand-primary" />
PDPA Compliant
</span>
</div>
<p className="text-xs sm:text-sm text-muted-text leading-relaxed">
{t(
'cookie.bannerDesc',
'เราใช้คุกกี้เพื่อเพิ่มประสิทธิภาพ ประสบการณ์ที่ดีในการใช้งานเว็บไซต์ และเพื่อการวิเคราะห์ปรับปรุงบริการ ท่านสามารถเลือกตั้งค่าความยินยอมตามประเภทคุกกี้ได้'
)}
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2.5 w-full md:w-auto shrink-0">
<button
type="button"
onClick={openPreferences}
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs sm:text-sm font-medium rounded-lg text-muted-text hover:text-text-main hover:bg-surface-muted transition-colors border border-transparent"
>
<Settings2 className="w-3.5 h-3.5" />
<span>{t('cookie.btnCustomize', 'ตั้งค่าคุกกี้')}</span>
</button>
<button
type="button"
onClick={rejectAll}
className="flex-1 sm:flex-none px-4 py-2 text-xs sm:text-sm font-medium rounded-lg border border-border-color text-text-main hover:bg-surface-muted hover:border-text-main/20 transition-all text-center"
>
{t('cookie.btnRejectAll', 'ปฏิเสธทั้งหมด')}
</button>
<button
type="button"
onClick={acceptAll}
className="w-full sm:w-auto px-5 py-2 text-xs sm:text-sm font-semibold rounded-lg bg-brand-primary text-white hover:bg-brand-primary-hover shadow-sm hover:shadow transition-all text-center tracking-wide"
>
{t('cookie.btnAcceptAll', 'ยอมรับทั้งหมด')}
</button>
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,225 @@
"use client";
import { useState, useEffect } from 'react';
import { useCookieConsentStore, type CookiePreferences } from '../../store/cookieConsentStore';
import { useTranslation } from 'react-i18next';
import { X, ShieldCheck, BarChart3, Target, Lock, Check } from 'lucide-react';
export default function CookiePreferencesModal() {
const { t } = useTranslation();
const {
record,
isPreferencesOpen,
closePreferences,
savePreferences,
acceptAll,
rejectAll,
} = useCookieConsentStore();
// Local state for interactive toggling inside the modal
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>({
necessary: true,
analytics: false,
marketing: false,
});
// Sync local state when modal opens
useEffect(() => {
if (isPreferencesOpen) {
setLocalPrefs({
necessary: true,
analytics: Boolean(record.preferences.analytics),
marketing: Boolean(record.preferences.marketing),
});
}
}, [isPreferencesOpen, record.preferences]);
// Handle ESC key to close
useEffect(() => {
if (!isPreferencesOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closePreferences();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isPreferencesOpen, closePreferences]);
if (!isPreferencesOpen) return null;
const handleSave = () => {
savePreferences(localPrefs);
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="cookie-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
>
<div
className="w-full max-w-2xl bg-surface-card rounded-2xl shadow-2xl border border-border-color overflow-hidden flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-color bg-surface-nav-rest">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-brand-cool/70 text-brand-primary">
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<h2 id="cookie-modal-title" className="text-base sm:text-lg font-bold text-text-main">
{t('cookie.prefTitle', 'ศูนย์การตั้งค่าความเป็นส่วนตัว (Cookie Preferences)')}
</h2>
<p className="text-xs text-muted-text">
{t('cookie.prefSubtitle', 'จัดการความยินยอมในการจัดเก็บและใช้งานคุกกี้ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล (PDPA)')}
</p>
</div>
</div>
<button
type="button"
onClick={closePreferences}
className="p-2 text-muted-text hover:text-text-main rounded-lg hover:bg-surface-muted transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body / Cookie Categories */}
<div className="p-6 space-y-4 overflow-y-auto divide-y divide-border-color/60">
{/* 1. Necessary Cookies */}
<div className="pt-2 first:pt-0">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 rounded-md bg-surface-muted text-muted-text mt-0.5">
<Lock className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-text-main">
{t('cookie.catNecessaryTitle', 'คุกกี้ที่จำเป็นอย่างยิ่ง (Strictly Necessary)')}
</h4>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
{t('cookie.alwaysActive', 'เปิดใช้งานเสมอ')}
</span>
</div>
<p className="text-xs text-muted-text mt-1 leading-relaxed">
{t(
'cookie.catNecessaryDesc',
'คุกกี้เหล่านี้มีความจำเป็นต่อการทำงานพื้นฐานของเว็บไซต์ เช่น การเข้าสู่ระบบ ความปลอดภัย การจดจำสินค้าในตะกร้า และการตั้งค่าภาษา เว็บไซต์ไม่สามารถทำงานได้อย่างถูกต้องหากไม่มีคุกกี้เหล่านี้'
)}
</p>
</div>
</div>
<div className="relative inline-flex items-center cursor-not-allowed opacity-80 shrink-0">
<input
type="checkbox"
checked={true}
disabled
className="sr-only peer"
/>
<div className="w-11 h-6 bg-brand-primary rounded-full peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
</div>
</div>
</div>
{/* 2. Analytics Cookies */}
<div className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 rounded-md bg-brand-cool/50 text-brand-primary mt-0.5">
<BarChart3 className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-semibold text-text-main">
{t('cookie.catAnalyticsTitle', 'คุกกี้เพื่อการวิเคราะห์และวัดผล (Analytics & Performance)')}
</h4>
<p className="text-xs text-muted-text mt-1 leading-relaxed">
{t(
'cookie.catAnalyticsDesc',
'ช่วยให้เราทราบสถิติการเข้าชม พฤติกรรมหน้าเว็บยอดนิยม และเส้นทางการใช้งานของผู้ใช้ เพื่อนำข้อมูลไปปรับปรุงประสิทธิภาพและบริการ (เช่น Google Analytics 4) ข้อมูลจะถูกจัดเก็บแบบไม่ระบุตัวตนโดยตรง'
)}
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
checked={localPrefs.analytics}
onChange={(e) =>
setLocalPrefs((prev) => ({ ...prev, analytics: e.target.checked }))
}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-border-color peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-primary"></div>
</label>
</div>
</div>
{/* 3. Marketing Cookies */}
<div className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 rounded-md bg-brand-cool/50 text-brand-primary mt-0.5">
<Target className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-semibold text-text-main">
{t('cookie.catMarketingTitle', 'คุกกี้เพื่อการตลาดและโฆษณา (Marketing & Targeting)')}
</h4>
<p className="text-xs text-muted-text mt-1 leading-relaxed">
{t(
'cookie.catMarketingDesc',
'ใช้เพื่อนำเสนอโปรโมชัน โฆษณา และเนื้อหาที่สอดคล้องกับความสนใจของท่านผ่านแพลตฟอร์มพันธมิตร (เช่น Meta Pixel, TikTok Pixel, Google Ads) รวมถึงวัดประสิทธิผลของแคมเปญ'
)}
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
checked={localPrefs.marketing}
onChange={(e) =>
setLocalPrefs((prev) => ({ ...prev, marketing: e.target.checked }))
}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-border-color peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-primary"></div>
</label>
</div>
</div>
</div>
{/* Footer / Buttons */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-3 p-4 sm:p-5 border-t border-border-color bg-surface-nav-rest">
<button
type="button"
onClick={rejectAll}
className="w-full sm:w-auto px-4 py-2 text-xs sm:text-sm font-medium rounded-lg text-muted-text hover:text-text-main hover:bg-surface-muted transition-colors text-center"
>
{t('cookie.btnRejectAll', 'ปฏิเสธทั้งหมด')}
</button>
<div className="flex items-center gap-2.5 w-full sm:w-auto">
<button
type="button"
onClick={handleSave}
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs sm:text-sm font-medium rounded-lg border border-border-color text-text-main hover:bg-surface-muted hover:border-text-main/20 transition-all text-center"
>
<Check className="w-4 h-4 text-brand-primary" />
<span>{t('cookie.btnSavePreferences', 'บันทึกการตั้งค่า')}</span>
</button>
<button
type="button"
onClick={acceptAll}
className="flex-1 sm:flex-none px-5 py-2 text-xs sm:text-sm font-semibold rounded-lg bg-brand-primary text-white hover:bg-brand-primary-hover shadow-sm transition-all text-center"
>
{t('cookie.btnAcceptAll', 'ยอมรับทั้งหมด')}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useRef } from 'react';
import { useCookieConsentStore } from '../../store/cookieConsentStore';
declare global {
interface Window {
dataLayer?: any[];
gtag?: (...args: any[]) => void;
fbq?: any;
_fbq?: any;
ttq?: any;
}
}
export default function ScriptManager() {
const { record, isHydrated } = useCookieConsentStore();
const consentInitializedRef = useRef(false);
const gaInjectedRef = useRef(false);
const metaInjectedRef = useRef(false);
const tiktokInjectedRef = useRef(false);
// 1. Initialize Google Consent Mode v2 Defaults
useEffect(() => {
if (typeof window === 'undefined' || consentInitializedRef.current) return;
consentInitializedRef.current = true;
window.dataLayer = window.dataLayer || [];
if (!window.gtag) {
window.gtag = function () {
window.dataLayer?.push(arguments);
};
}
// Default to 'denied' as required by GDPR/PDPA prior to user choice
window.gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
}, []);
// 2. Sync consent updates whenever state changes
useEffect(() => {
if (!isHydrated || typeof window === 'undefined') return;
const { preferences, hasResponded } = record;
if (window.gtag) {
window.gtag('consent', 'update', {
analytics_storage: hasResponded && preferences.analytics ? 'granted' : 'denied',
ad_storage: hasResponded && preferences.marketing ? 'granted' : 'denied',
ad_user_data: hasResponded && preferences.marketing ? 'granted' : 'denied',
ad_personalization: hasResponded && preferences.marketing ? 'granted' : 'denied',
});
}
// Dispatch custom browser event for any other custom tags/scripts
window.dispatchEvent(
new CustomEvent('homaroma:consent-change', {
detail: {
hasResponded,
preferences,
},
})
);
// 3. Inject GA4 dynamically if enabled and configured
const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
if (gaId && hasResponded && preferences.analytics && !gaInjectedRef.current) {
gaInjectedRef.current = true;
const script = document.createElement('script');
script.src = `https://www.googletagmanager.com/gtag/js?id=${gaId}`;
script.async = true;
document.head.appendChild(script);
window.gtag?.('js', new Date());
window.gtag?.('config', gaId, {
anonymize_ip: true,
});
}
// 4. Inject Meta Pixel dynamically if enabled and configured
const metaPixelId = process.env.NEXT_PUBLIC_META_PIXEL_ID;
if (metaPixelId && hasResponded && preferences.marketing && !metaInjectedRef.current) {
metaInjectedRef.current = true;
(function (f: any, b: any, e: any, v: any, n?: any, t?: any, s?: any) {
if (f.fbq) return;
n = f.fbq = function () {
n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
};
if (!f._fbq) f._fbq = n;
n.push = n;
n.loaded = true;
n.version = '2.0';
n.queue = [];
t = b.createElement(e);
t.async = true;
t.src = v;
s = b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t, s);
})(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');
window.fbq('init', metaPixelId);
window.fbq('track', 'PageView');
}
// 5. Inject TikTok Pixel dynamically if enabled and configured
const tiktokPixelId = process.env.NEXT_PUBLIC_TIKTOK_PIXEL_ID;
if (tiktokPixelId && hasResponded && preferences.marketing && !tiktokInjectedRef.current) {
tiktokInjectedRef.current = true;
(function (w: any, d: any, t: any) {
w.TiktokAnalyticsObject = t;
const ttq = (w[t] = w[t] || []);
ttq.methods = [
'page',
'track',
'identify',
'instances',
'debug',
'on',
'off',
'once',
'ready',
'alias',
'group',
'enableCookie',
'disableCookie',
];
ttq.setAndDefer = function (t: any, e: any) {
t[e] = function () {
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
};
};
for (let i = 0; i < ttq.methods.length; i++) {
ttq.setAndDefer(ttq, ttq.methods[i]);
}
ttq.instance = function (t: any) {
const e = ttq._i[t] || [];
for (let n = 0; n < ttq.methods.length; n++) {
ttq.setAndDefer(e, ttq.methods[n]);
}
return e;
};
ttq.load = function (e: any, n: any) {
const i = 'https://analytics.tiktok.com/i18n/pixel/events.js';
ttq._i = ttq._i || {};
ttq._i[e] = [];
ttq._i[e]._u = i;
ttq._t = ttq._t || {};
ttq._t[e] = +new Date();
ttq._o = ttq._o || {};
ttq._o[e] = n || {};
const o = d.createElement('script');
o.type = 'text/javascript';
o.async = true;
o.src = `${i}?sdkid=${e}&lib=${t}`;
const a = d.getElementsByTagName('script')[0];
a.parentNode.insertBefore(o, a);
};
})(window, document, 'ttq');
window.ttq?.load(tiktokPixelId);
window.ttq?.page();
}
}, [record, isHydrated]);
return null;
}
+7 -7
View File
@@ -11,13 +11,13 @@ export type TranslationSchema = typeof thTranslation
const translations: Record<string, TranslationSchema> = { const translations: Record<string, TranslationSchema> = {
th: thTranslation, th: thTranslation,
en: enTranslation as TranslationSchema, en: enTranslation as unknown as TranslationSchema,
ja: jaTranslation as TranslationSchema, ja: jaTranslation as unknown as TranslationSchema,
zh: zhTranslation as TranslationSchema, zh: zhTranslation as unknown as TranslationSchema,
vi: viTranslation as TranslationSchema, vi: viTranslation as unknown as TranslationSchema,
ko: koTranslation as TranslationSchema, ko: koTranslation as unknown as TranslationSchema,
lo: loTranslation as TranslationSchema, lo: loTranslation as unknown as TranslationSchema,
it: itTranslation as TranslationSchema, it: itTranslation as unknown as TranslationSchema,
} }
export function getServerTranslations(lang = 'th'): TranslationSchema { export function getServerTranslations(lang = 'th'): TranslationSchema {
@@ -216,6 +216,24 @@
"notFound": "Unable to verify order details", "notFound": "Unable to verify order details",
"networkError": "Connection error. Please try again." "networkError": "Connection error. Please try again."
} }
},
"cookie": {
"settings": "Cookie Preferences",
"bannerTitle": "We value your privacy",
"bannerDesc": "We use cookies to improve your browsing experience, provide personalized content, and analyze our website traffic in accordance with privacy laws. You can choose to accept all cookies or manage your preferences.",
"btnCustomize": "Preferences",
"btnRejectAll": "Reject Non-Essential",
"btnAcceptAll": "Accept All",
"btnSavePreferences": "Save Preferences",
"prefTitle": "Privacy & Cookie Preferences",
"prefSubtitle": "Manage your consent for cookie storage and data processing under privacy regulations (PDPA & GDPR)",
"alwaysActive": "Always Active",
"catNecessaryTitle": "Strictly Necessary Cookies",
"catNecessaryDesc": "These cookies are essential for the website to function properly, enabling basic functions like navigation, cart checkout, and access to secure areas. The website cannot function properly without these cookies.",
"catAnalyticsTitle": "Analytics & Performance Cookies",
"catAnalyticsDesc": "These cookies allow us to count visits and traffic sources so we can measure and improve the performance of our site (e.g., Google Analytics 4). All information is aggregated and anonymous.",
"catMarketingTitle": "Marketing & Targeting Cookies",
"catMarketingDesc": "These cookies may be set through our site by our advertising partners (e.g., Meta Pixel, TikTok Pixel, Google Ads) to build a profile of your interests and show you relevant adverts on other sites."
} }
} }
@@ -237,6 +237,24 @@
"notFound": "注文情報を確認できませんでした", "notFound": "注文情報を確認できませんでした",
"networkError": "接続エラーが発生しました。もう一度お試しください。" "networkError": "接続エラーが発生しました。もう一度お試しください。"
} }
},
"cookie": {
"settings": "クッキー設定",
"bannerTitle": "お客様のプライバシーを尊重します",
"bannerDesc": "当ウェブサイトでは、利便性の向上、利用状況の分析、パーソナライズされた広告の提供のためにクッキーを使用しています。同意設定はいつでも変更できます。",
"btnCustomize": "設定",
"btnRejectAll": "必須以外を拒否",
"btnAcceptAll": "すべて同意",
"btnSavePreferences": "設定を保存",
"prefTitle": "プライバシーおよびクッキー設定",
"prefSubtitle": "個人情報保護法(PDPA/GDPR)に基づくクッキーの保存およびデータ利用の同意管理",
"alwaysActive": "常に有効",
"catNecessaryTitle": "必須クッキー (Strictly Necessary)",
"catNecessaryDesc": "ウェブサイトの基本的な機能(ログイン、セキュリティ、カート、言語設定など)に不可欠なクッキーです。このクッキーを無効にすることはできません。",
"catAnalyticsTitle": "分析・パフォーマンスクッキー (Analytics)",
"catAnalyticsDesc": "訪問者数やトラフィックソースを計測し、サイトのパフォーマンスを向上させるためのクッキーです(Google Analyticsなど)。",
"catMarketingTitle": "マーケティング・ターゲティングクッキー (Marketing)",
"catMarketingDesc": "お客様の興味関心に基づいた関連性の高い広告やプロモーションを配信するために使用されます(Meta Pixel、TikTok Pixelなど)。"
} }
} }
@@ -216,6 +216,24 @@
"notFound": "ไม่สามารถยืนยันข้อมูลคำสั่งซื้อได้", "notFound": "ไม่สามารถยืนยันข้อมูลคำสั่งซื้อได้",
"networkError": "เกิดข้อผิดพลาดในการเชื่อมต่อ กรุณาลองใหม่อีกครั้ง" "networkError": "เกิดข้อผิดพลาดในการเชื่อมต่อ กรุณาลองใหม่อีกครั้ง"
} }
},
"cookie": {
"settings": "การตั้งค่าคุกกี้",
"bannerTitle": "เราให้ความสำคัญกับความเป็นส่วนตัวของคุณ",
"bannerDesc": "เราใช้คุกกี้เพื่อเพิ่มประสิทธิภาพ ประสบการณ์ที่ดีในการใช้งานเว็บไซต์ และเพื่อการวิเคราะห์ปรับปรุงบริการ ท่านสามารถเลือกตั้งค่าความยินยอมตามประเภทคุกกี้ได้",
"btnCustomize": "ตั้งค่าคุกกี้",
"btnRejectAll": "ปฏิเสธทั้งหมด",
"btnAcceptAll": "ยอมรับทั้งหมด",
"btnSavePreferences": "บันทึกการตั้งค่า",
"prefTitle": "ศูนย์การตั้งค่าความเป็นส่วนตัว",
"prefSubtitle": "จัดการความยินยอมในการจัดเก็บและใช้งานคุกกี้ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล (PDPA)",
"alwaysActive": "เปิดใช้งานเสมอ",
"catNecessaryTitle": "คุกกี้ที่จำเป็นอย่างยิ่ง (Strictly Necessary)",
"catNecessaryDesc": "คุกกี้เหล่านี้มีความจำเป็นต่อการทำงานพื้นฐานของเว็บไซต์ เช่น การเข้าสู่ระบบ ความปลอดภัย การจดจำสินค้าในตะกร้า และการตั้งค่าภาษา เว็บไซต์ไม่สามารถทำงานได้อย่างถูกต้องหากไม่มีคุกกี้เหล่านี้",
"catAnalyticsTitle": "คุกกี้เพื่อการวิเคราะห์และวัดผล (Analytics & Performance)",
"catAnalyticsDesc": "ช่วยให้เราทราบสถิติการเข้าชม พฤติกรรมหน้าเว็บยอดนิยม และเส้นทางการใช้งานของผู้ใช้ เพื่อนำข้อมูลไปปรับปรุงประสิทธิภาพและบริการ (เช่น Google Analytics 4) ข้อมูลจะถูกจัดเก็บแบบไม่ระบุตัวตนโดยตรง",
"catMarketingTitle": "คุกกี้เพื่อการตลาดและโฆษณา (Marketing & Targeting)",
"catMarketingDesc": "ใช้เพื่อนำเสนอโปรโมชัน โฆษณา และเนื้อหาที่สอดคล้องกับความสนใจของท่านผ่านแพลตฟอร์มพันธมิตร (เช่น Meta Pixel, TikTok Pixel, Google Ads) รวมถึงวัดประสิทธิผลของแคมเปญ"
} }
} }
@@ -237,6 +237,24 @@
"notFound": "无法验证订单信息", "notFound": "无法验证订单信息",
"networkError": "网络连接错误,请稍后重试。" "networkError": "网络连接错误,请稍后重试。"
} }
},
"cookie": {
"settings": "Cookie 设置",
"bannerTitle": "我们重视您的隐私",
"bannerDesc": "我们使用 Cookie 来改善您的浏览体验、提供个性化内容以及根据隐私法规分析网站流量。您可以选择接受所有 Cookie 或自定义您的偏好设置。",
"btnCustomize": "偏好设置",
"btnRejectAll": "拒绝非必要",
"btnAcceptAll": "接受全部",
"btnSavePreferences": "保存设置",
"prefTitle": "隐私与 Cookie 偏好中心",
"prefSubtitle": "根据个人数据保护法规 (PDPA/GDPR) 管理 Cookie 存储及数据使用授权",
"alwaysActive": "始终启用",
"catNecessaryTitle": "必要 Cookie (Strictly Necessary)",
"catNecessaryDesc": "这些 Cookie 是网站运行所必需的,用于支持登录、安全性、购物车和语言设置等基本功能。网站无法在缺少此类 Cookie 的情况下正常运行。",
"catAnalyticsTitle": "分析与性能 Cookie (Analytics)",
"catAnalyticsDesc": "帮助我们统计访问量和流量来源,以评估并改善网站性能(例如 Google Analytics 4)。数据为匿名统计。",
"catMarketingTitle": "营销与广告 Cookie (Marketing)",
"catMarketingDesc": "由广告合作伙伴设置(例如 Meta Pixel、TikTok Pixel、Google Ads),用于构建您的兴趣画像并向您推送相关广告。"
} }
} }
@@ -0,0 +1,124 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const CURRENT_CONSENT_VERSION = '1.0';
export interface CookiePreferences {
necessary: boolean;
analytics: boolean;
marketing: boolean;
}
export interface CookieConsentRecord {
hasResponded: boolean;
version: string;
timestamp: string | null;
preferences: CookiePreferences;
}
interface CookieConsentState {
record: CookieConsentRecord;
isPreferencesOpen: boolean;
isHydrated: boolean;
// Actions
setHydrated: (hydrated: boolean) => void;
openPreferences: () => void;
closePreferences: () => void;
acceptAll: () => void;
rejectAll: () => void;
savePreferences: (preferences: Partial<CookiePreferences>) => void;
resetConsent: () => void;
}
const DEFAULT_RECORD: CookieConsentRecord = {
hasResponded: false,
version: CURRENT_CONSENT_VERSION,
timestamp: null,
preferences: {
necessary: true,
analytics: false,
marketing: false,
},
};
export const useCookieConsentStore = create<CookieConsentState>()(
persist(
(set) => ({
record: DEFAULT_RECORD,
isPreferencesOpen: false,
isHydrated: false,
setHydrated: (hydrated: boolean) => set({ isHydrated: hydrated }),
openPreferences: () => set({ isPreferencesOpen: true }),
closePreferences: () => set({ isPreferencesOpen: false }),
acceptAll: () => {
const now = new Date().toISOString();
set({
record: {
hasResponded: true,
version: CURRENT_CONSENT_VERSION,
timestamp: now,
preferences: {
necessary: true,
analytics: true,
marketing: true,
},
},
isPreferencesOpen: false,
});
},
rejectAll: () => {
const now = new Date().toISOString();
set({
record: {
hasResponded: true,
version: CURRENT_CONSENT_VERSION,
timestamp: now,
preferences: {
necessary: true,
analytics: false,
marketing: false,
},
},
isPreferencesOpen: false,
});
},
savePreferences: (userPrefs: Partial<CookiePreferences>) => {
const now = new Date().toISOString();
set((state) => ({
record: {
hasResponded: true,
version: CURRENT_CONSENT_VERSION,
timestamp: now,
preferences: {
necessary: true,
analytics: Boolean(userPrefs.analytics),
marketing: Boolean(userPrefs.marketing),
},
},
isPreferencesOpen: false,
}));
},
resetConsent: () => {
set({
record: DEFAULT_RECORD,
isPreferencesOpen: false,
});
},
}),
{
name: 'homaroma-cookie-consent',
partialize: (state) => ({ record: state.record }),
skipHydration: true,
onRehydrateStorage: () => (state) => {
state?.setHydrated(true);
},
}
)
);