Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99907308d3 | ||
|
|
296adb849c | ||
|
|
5686b3ea9c | ||
|
|
e7fc0e2848 | ||
|
|
1f7bc0e01c | ||
|
|
44d9f1f198 | ||
|
|
9107a64637 | ||
|
|
b27afb4561 |
No files matched your search
@@ -37,9 +37,9 @@ import { categorySectionsRoute } from "./routes/categorySections";
|
||||
import { llmPromptsRoute } from "./routes/llmPrompts";
|
||||
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
|
||||
import { profitabilityRoute } from "./routes/profitability";
|
||||
import { JWT_SECRET } from "./middleware/auth";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
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 isPrivateLanHostname = (hostname: string) => {
|
||||
|
||||
@@ -44,7 +44,7 @@ export function resolveDefinitionLocalizedField<T extends { langCode: string; la
|
||||
export function getPrimaryOptionValue(value: any): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value.th || value.en || Object.values(value)[0] || '';
|
||||
return value.value || value.key || value.id || value.th || value.en || Object.values(value)[0] || '';
|
||||
}
|
||||
return String(value ?? '');
|
||||
}
|
||||
@@ -52,7 +52,17 @@ export function getPrimaryOptionValue(value: any): string {
|
||||
export function getLocalizedOptionLabel(value: any, lang: string): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value[lang] || value[lang.slice(0, 2)] || value.th || value.en || Object.values(value)[0] || '';
|
||||
return (
|
||||
value[`label_${lang}`] ||
|
||||
value[`label_${lang.slice(0, 2)}`] ||
|
||||
value[lang] ||
|
||||
value[lang.slice(0, 2)] ||
|
||||
value.label ||
|
||||
value.th ||
|
||||
value.en ||
|
||||
Object.values(value)[0] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Elysia } from "elysia";
|
||||
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) {
|
||||
const auth = headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) {
|
||||
@@ -26,7 +28,7 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
secret: process.env.JWT_SECRET || "fallback-secret"
|
||||
secret: JWT_SECRET,
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(requireAdmin);
|
||||
.onBeforeHandle({ as: "scoped" }, requireAdmin);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const articlesRoute = new Elysia({ prefix: "/articles" })
|
||||
.get("/", async ({ query, set }) => {
|
||||
@@ -46,6 +47,7 @@ export const articlesRoute = new Elysia({ prefix: "/articles" })
|
||||
return { error: "Failed to fetch article" };
|
||||
}
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
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,12 +2,13 @@ import { Elysia, t } from "elysia";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "../lib/prisma";
|
||||
import { JWT_SECRET } from "../middleware/auth";
|
||||
|
||||
export const authRoute = new Elysia({ prefix: "/auth" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
secret: process.env.JWT_SECRET || "homaroma_secret_2026_scent",
|
||||
secret: JWT_SECRET,
|
||||
exp: "24h",
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces";
|
||||
|
||||
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" })
|
||||
.use(adminOnly)
|
||||
// ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ──────────
|
||||
.get("/sales-map", async ({ query }) => {
|
||||
const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { purgeCategories } from "../lib/catalogMaintenance";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
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;
|
||||
@@ -118,120 +119,6 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
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 }) => {
|
||||
const id = parseInt(params.id);
|
||||
const lang = (query as any).lang || 'all';
|
||||
@@ -361,6 +248,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
if (!nameObj.th && p.name) nameObj.th = p.name;
|
||||
if (!descObj.th && p.description) descObj.th = p.description;
|
||||
if (!detailsObj.th && p.details) detailsObj.th = p.details;
|
||||
return {
|
||||
...p,
|
||||
name: nameObj,
|
||||
@@ -372,9 +262,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
|
||||
return {
|
||||
...p,
|
||||
name: trans?.name || p.brand?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
name: trans?.name || p.name || p.brand?.name || '',
|
||||
description: trans?.description || p.description,
|
||||
details: trans?.details || p.details,
|
||||
category: { ...p.category, name: resolveCatName(p.category, lang) },
|
||||
};
|
||||
}
|
||||
@@ -387,6 +277,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 }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
validateCategorySectionPayload,
|
||||
} from "../lib/categorySections";
|
||||
import { hydrateSectionPayload } from "./pages";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
function summarizeValidationIssues(issues: any[]) {
|
||||
return issues.map((issue) => ({
|
||||
@@ -43,6 +44,8 @@ export const categorySectionsRoute = new Elysia({ prefix: "/categories" })
|
||||
);
|
||||
})
|
||||
|
||||
.use(adminOnly)
|
||||
|
||||
// Seed default sections
|
||||
.post("/:id/sections/seed-default", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const contactsRoute = new Elysia({ prefix: "/contacts" })
|
||||
.post("/", async ({ body, set }) => {
|
||||
@@ -27,6 +28,7 @@ export const contactsRoute = new Elysia({ prefix: "/contacts" })
|
||||
message: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.use(adminOnly)
|
||||
.get("/", async () => {
|
||||
return await prisma.contactMessage.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
|
||||
+106
-100
@@ -1,13 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
// Get all coupons (Admin)
|
||||
.get("/", async () => {
|
||||
return await prisma.coupon.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
})
|
||||
// ── Public Storefront Endpoints ───────────────────────────────────────────
|
||||
// Get active coupons (Storefront)
|
||||
.get("/active", async () => {
|
||||
const now = new Date();
|
||||
@@ -22,98 +18,7 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
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)
|
||||
.post("/validate", async ({ body, set }) => {
|
||||
try {
|
||||
@@ -159,7 +64,6 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
} else if (coupon.type === "FIXED_AMOUNT") {
|
||||
discountAmount = coupon.value;
|
||||
} else if (coupon.type === "FREE_SHIPPING") {
|
||||
// We handle free shipping separately, returning discountAmount = 0 but a flag
|
||||
return {
|
||||
valid: true,
|
||||
coupon,
|
||||
@@ -168,7 +72,6 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure discount doesn't exceed cart total
|
||||
if (discountAmount > cartTotal) {
|
||||
discountAmount = cartTotal;
|
||||
}
|
||||
@@ -190,4 +93,107 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
code: t.String(),
|
||||
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" };
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const crmRoute = new Elysia({ prefix: "/crm" })
|
||||
.use(adminOnly)
|
||||
// ── 1. Import Single / Multiple Customers ─────────────────────────────────
|
||||
.post(
|
||||
"/import",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const customersRoute = new Elysia({ prefix: "/customers" })
|
||||
.use(adminOnly)
|
||||
.get("/", async ({ query }) => {
|
||||
const search = query.search as string | undefined;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
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" })
|
||||
.use(adminOnly)
|
||||
|
||||
// ── Import (Excel rows as JSON) ───────────────────────────────────────────
|
||||
.post(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Elysia } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const mediaRoute = new Elysia({ prefix: "/media" })
|
||||
.use(adminOnly)
|
||||
.get("/", async () => {
|
||||
const media = await prisma.media.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
|
||||
.post("/subscribe", async ({ body, set }) => {
|
||||
@@ -29,6 +30,7 @@ export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
|
||||
email: t.String({ format: "email" })
|
||||
})
|
||||
})
|
||||
.use(adminOnly)
|
||||
.get("/subscribers", async () => {
|
||||
return await prisma.newsletterSubscriber.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import {
|
||||
sendOrderCreatedNotification,
|
||||
sendPaymentConfirmedNotification,
|
||||
@@ -9,12 +10,6 @@ import {
|
||||
} from "../lib/notifications";
|
||||
|
||||
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 }) => {
|
||||
const order = await prisma.order.findUnique({
|
||||
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 }) => {
|
||||
try {
|
||||
const { paymentSlipUrl } = body as any;
|
||||
@@ -310,35 +257,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
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 }) => {
|
||||
try {
|
||||
const { orderId, phone } = body as any;
|
||||
@@ -366,4 +284,90 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
orderId: t.Number(),
|
||||
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 }
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -1485,9 +1485,6 @@ async function resolveProductsForSection(payload: any, lang: string) {
|
||||
brand: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } },
|
||||
},
|
||||
orderBy: { price: "asc" as const },
|
||||
},
|
||||
images: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import {
|
||||
SUPPORTED_LANGS,
|
||||
localizeAttributeDefinition,
|
||||
@@ -158,6 +159,36 @@ export const productAttributesRoute = new Elysia()
|
||||
return status(400, { message: err.message });
|
||||
}
|
||||
})
|
||||
.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 });
|
||||
}
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/product-attributes/definitions/bulk-import", async ({ body, status }) => {
|
||||
try {
|
||||
const payload = body as any;
|
||||
@@ -351,35 +382,6 @@ export const productAttributesRoute = new Elysia()
|
||||
}, {
|
||||
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 }) => {
|
||||
try {
|
||||
const productId = parseId(params.id, "product ID");
|
||||
|
||||
+199
-203
@@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { purgeProducts } from "../lib/catalogMaintenance";
|
||||
import { localizeProductAttribute } from "../lib/productAttributes";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import {
|
||||
createCustomProductDetailSection,
|
||||
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 }) => {
|
||||
const payload = body as any;
|
||||
const name = payload.name?.trim();
|
||||
@@ -315,122 +514,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
|
||||
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 }) => {
|
||||
const search = query.search?.trim();
|
||||
const filterStatus = query.status?.trim() || "all";
|
||||
@@ -520,74 +603,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
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 }) => {
|
||||
const id = parseInt(params.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,
|
||||
};
|
||||
})
|
||||
.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 }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
// ── Date Range Helper ────────────────────────────────────────────────────────
|
||||
interface DateRangeInfo {
|
||||
@@ -70,6 +71,7 @@ const toNum = (val: any): number => {
|
||||
const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback
|
||||
|
||||
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
.use(adminOnly)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Cost & Profitability Matrix (Catalog-Level Baseline)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const promotionsRoute = new Elysia({ prefix: "/promotions" })
|
||||
// ── Public Storefront Endpoints ───────────────────────────────────────────
|
||||
.get("/active", async () => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
return await prisma.promotion.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
@@ -18,62 +20,68 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
|
||||
orderBy: { level: 'asc' }
|
||||
});
|
||||
})
|
||||
.get("/", async () => {
|
||||
return await prisma.promotion.findMany({
|
||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
return await prisma.promotion.create({ data });
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to create promotion" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
level: t.Number(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Number(),
|
||||
isActive: t.Boolean(),
|
||||
startDate: t.Optional(t.Nullable(t.String())),
|
||||
endDate: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
return await prisma.promotion.update({
|
||||
where: { id: Number(id) },
|
||||
data
|
||||
});
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update promotion" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
level: t.Number(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Number(),
|
||||
isActive: t.Boolean(),
|
||||
startDate: t.Optional(t.Nullable(t.String())),
|
||||
endDate: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.promotion.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete promotion" };
|
||||
}
|
||||
});
|
||||
|
||||
// ── Protected Admin Endpoints ─────────────────────────────────────────────
|
||||
.guard((app) =>
|
||||
app
|
||||
.use(adminOnly)
|
||||
.get("/", async () => {
|
||||
return await prisma.promotion.findMany({
|
||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
return await prisma.promotion.create({ data });
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to create promotion" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
level: t.Number(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Number(),
|
||||
isActive: t.Boolean(),
|
||||
startDate: t.Optional(t.Nullable(t.String())),
|
||||
endDate: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
return await prisma.promotion.update({
|
||||
where: { id: Number(id) },
|
||||
data
|
||||
});
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update promotion" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
level: t.Number(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Number(),
|
||||
isActive: t.Boolean(),
|
||||
startDate: t.Optional(t.Nullable(t.String())),
|
||||
endDate: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.promotion.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete promotion" };
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
|
||||
.use(adminOnly)
|
||||
.get("/", async () => {
|
||||
const rawMaterials = await prisma.rawMaterial.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
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}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('footer layout route boundaries', () => {
|
||||
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, {
|
||||
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(await response.json()).toEqual({
|
||||
error: 'Use PATCH /settings/footer-layout to update footer layout',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
@@ -17,6 +18,7 @@ function extractShopeeAddress(order: any): { province: string; district: string
|
||||
}
|
||||
|
||||
export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
||||
.use(adminOnly)
|
||||
// ── Import Orders ─────────────────────────────────────────────────────────
|
||||
.post(
|
||||
"/import/orders",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
|
||||
.use(adminOnly)
|
||||
.post(
|
||||
"/import/income",
|
||||
async ({ body, set }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
|
||||
|
||||
const toNum = (val: any): number => {
|
||||
@@ -234,6 +235,7 @@ async function enrichOrdersWithBOM(orders: any[], shop: string) {
|
||||
}
|
||||
|
||||
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
.use(adminOnly)
|
||||
|
||||
// ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
|
||||
.post(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const toolsRoute = new Elysia({ prefix: "/tools" })
|
||||
.use(adminOnly)
|
||||
|
||||
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
|
||||
.get("/marketplace-products/master-variants", async () => {
|
||||
|
||||
@@ -7,7 +7,11 @@ import i18n from '../lib/i18n';
|
||||
import { useCartStore } from '../store/cartStore';
|
||||
import { useWishlistStore } from '../store/wishlistStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useCookieConsentStore } from '../store/cookieConsentStore';
|
||||
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 }) {
|
||||
useEffect(() => {
|
||||
@@ -25,13 +29,17 @@ export default function ClientProviders({ children }: { children: ReactNode }) {
|
||||
void useCartStore.persist.rehydrate()
|
||||
void useWishlistStore.persist.rehydrate()
|
||||
void useAuthStore.persist.rehydrate()
|
||||
void useCookieConsentStore.persist.rehydrate()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScriptManager />
|
||||
<ScrollToTop />
|
||||
{children}
|
||||
<CartDrawer />
|
||||
<CookieBanner />
|
||||
<CookiePreferencesModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
import { useCookieConsentStore } from '../store/cookieConsentStore'
|
||||
|
||||
interface FooterProps {
|
||||
categories?: Category[]
|
||||
@@ -154,6 +155,13 @@ export default function Footer({
|
||||
</h4>
|
||||
<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>)}
|
||||
<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 className="pt-4">
|
||||
<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;
|
||||
}
|
||||
@@ -11,13 +11,13 @@ export type TranslationSchema = typeof thTranslation
|
||||
|
||||
const translations: Record<string, TranslationSchema> = {
|
||||
th: thTranslation,
|
||||
en: enTranslation as TranslationSchema,
|
||||
ja: jaTranslation as TranslationSchema,
|
||||
zh: zhTranslation as TranslationSchema,
|
||||
vi: viTranslation as TranslationSchema,
|
||||
ko: koTranslation as TranslationSchema,
|
||||
lo: loTranslation as TranslationSchema,
|
||||
it: itTranslation as TranslationSchema,
|
||||
en: enTranslation as unknown as TranslationSchema,
|
||||
ja: jaTranslation as unknown as TranslationSchema,
|
||||
zh: zhTranslation as unknown as TranslationSchema,
|
||||
vi: viTranslation as unknown as TranslationSchema,
|
||||
ko: koTranslation as unknown as TranslationSchema,
|
||||
lo: loTranslation as unknown as TranslationSchema,
|
||||
it: itTranslation as unknown as TranslationSchema,
|
||||
}
|
||||
|
||||
export function getServerTranslations(lang = 'th'): TranslationSchema {
|
||||
|
||||
@@ -216,6 +216,24 @@
|
||||
"notFound": "Unable to verify order details",
|
||||
"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": "注文情報を確認できませんでした",
|
||||
"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": "ไม่สามารถยืนยันข้อมูลคำสั่งซื้อได้",
|
||||
"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": "无法验证订单信息",
|
||||
"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);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,324 @@
|
||||
[
|
||||
{
|
||||
"code": "scent_family",
|
||||
"label": "กลุ่มโทนกลิ่น",
|
||||
"description": "การจัดกลุ่มโทนกลิ่นหลักของเครื่องหอม เพื่อให้ค้นหาตามสไตล์กลิ่นที่ชอบได้ง่าย",
|
||||
"type": "SELECT",
|
||||
"unit": null,
|
||||
"options": [
|
||||
{
|
||||
"value": "floral",
|
||||
"label": "Floral (ดอกไม้)",
|
||||
"label_th": "กลิ่นแนวดอกไม้",
|
||||
"label_en": "Floral"
|
||||
},
|
||||
{
|
||||
"value": "fresh_herbal",
|
||||
"label": "Fresh & Herbal (สดชื่น/สมุนไพร)",
|
||||
"label_th": "กลิ่นสดชื่นและสมุนไพร",
|
||||
"label_en": "Fresh & Herbal"
|
||||
},
|
||||
{
|
||||
"value": "fruity",
|
||||
"label": "Fruity (ผลไม้)",
|
||||
"label_th": "กลิ่นแนวผลไม้",
|
||||
"label_en": "Fruity"
|
||||
},
|
||||
{
|
||||
"value": "spa_relaxing",
|
||||
"label": "Spa & Relaxing (สปา/ผ่อนคลาย)",
|
||||
"label_th": "กลิ่นแนวสปาและผ่อนคลาย",
|
||||
"label_en": "Spa & Relaxing"
|
||||
},
|
||||
{
|
||||
"value": "sweet_warm",
|
||||
"label": "Sweet & Warm (หวานละมุน/อบอุ่น)",
|
||||
"label_th": "กลิ่นแนวหวานละมุนและอบอุ่น",
|
||||
"label_en": "Sweet & Warm"
|
||||
},
|
||||
{
|
||||
"value": "aquatic_ozone",
|
||||
"label": "Aquatic & Ozone (อากาศ/ทะเล)",
|
||||
"label_th": "กลิ่นแนวปลอดโปร่งและทะเล",
|
||||
"label_en": "Aquatic & Ozone"
|
||||
}
|
||||
],
|
||||
"isRequired": false,
|
||||
"isFilterable": true,
|
||||
"sortOrder": 1,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "กลุ่มโทนกลิ่น",
|
||||
"description": "การจัดกลุ่มโทนกลิ่นหลักของเครื่องหอม"
|
||||
},
|
||||
"en": {
|
||||
"label": "Fragrance Family",
|
||||
"description": "Primary olfactory classification for fragrances"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "mood_benefit",
|
||||
"label": "อารมณ์และคุณประโยชน์",
|
||||
"description": "ความรู้สึกหรือบรรยากาศที่ได้รับจากกลิ่นหอมนี้",
|
||||
"type": "SELECT",
|
||||
"unit": null,
|
||||
"options": [
|
||||
{
|
||||
"value": "relax_sleep",
|
||||
"label": "Relax & Sleep (ผ่อนคลาย หลับสบาย)",
|
||||
"label_th": "ผ่อนคลาย หลับสบาย",
|
||||
"label_en": "Relax & Sleep"
|
||||
},
|
||||
{
|
||||
"value": "refresh_energize",
|
||||
"label": "Refresh & Energize (สดชื่น เติมพลัง)",
|
||||
"label_th": "สดชื่น เติมพลัง",
|
||||
"label_en": "Refresh & Energize"
|
||||
},
|
||||
{
|
||||
"value": "focus_mind",
|
||||
"label": "Focus & Mind (สร้างสมาธิ กระตุ้นไอเดีย)",
|
||||
"label_th": "สร้างสมาธิ กระตุ้นไอเดีย",
|
||||
"label_en": "Focus & Mind"
|
||||
},
|
||||
{
|
||||
"value": "odor_eliminator",
|
||||
"label": "Odor Eliminator (ดับกลิ่นอับ ปรับอากาศ)",
|
||||
"label_th": "ดับกลิ่นอับ ปรับอากาศ",
|
||||
"label_en": "Odor Eliminator"
|
||||
},
|
||||
{
|
||||
"value": "romantic_cozy",
|
||||
"label": "Romantic & Cozy (อบอุ่น โรแมนติก)",
|
||||
"label_th": "อบอุ่น โรแมนติก",
|
||||
"label_en": "Romantic & Cozy"
|
||||
}
|
||||
],
|
||||
"isRequired": false,
|
||||
"isFilterable": true,
|
||||
"sortOrder": 2,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "อารมณ์และคุณประโยชน์",
|
||||
"description": "บรรยากาศหรือความรู้สึกที่ได้จากกลิ่น"
|
||||
},
|
||||
"en": {
|
||||
"label": "Mood & Benefit",
|
||||
"description": "Atmosphere and emotional benefits"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "scent_intensity",
|
||||
"label": "ระดับความเข้มข้นของกลิ่น",
|
||||
"description": "ระดับการกระจายตัวและความชัดของกลิ่นหอม",
|
||||
"type": "SELECT",
|
||||
"unit": null,
|
||||
"options": [
|
||||
{
|
||||
"value": "light",
|
||||
"label": "Light / Subtle (หอมละมุน บางเบา)",
|
||||
"label_th": "หอมละมุน บางเบา",
|
||||
"label_en": "Light / Subtle"
|
||||
},
|
||||
{
|
||||
"value": "medium",
|
||||
"label": "Medium (หอมปานกลาง กำลังพอดี)",
|
||||
"label_th": "หอมปานกลาง กำลังพอดี",
|
||||
"label_en": "Medium"
|
||||
},
|
||||
{
|
||||
"value": "strong",
|
||||
"label": "Strong / Rich (หอมชัดเจน เข้มข้น)",
|
||||
"label_th": "หอมชัดเจน เข้มข้น",
|
||||
"label_en": "Strong / Rich"
|
||||
}
|
||||
],
|
||||
"isRequired": false,
|
||||
"isFilterable": true,
|
||||
"sortOrder": 3,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "ระดับความเข้มข้นของกลิ่น",
|
||||
"description": "ระดับการกระจายตัวและความชัดของกลิ่น"
|
||||
},
|
||||
"en": {
|
||||
"label": "Scent Intensity",
|
||||
"description": "Diffusion strength and fragrance throw"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "room_placement",
|
||||
"label": "พื้นที่ใช้งานที่แนะนำ",
|
||||
"description": "ห้องหรือบริเวณที่เหมาะสมที่สุดสำหรับการกระจายกลิ่น",
|
||||
"type": "SELECT",
|
||||
"unit": null,
|
||||
"options": [
|
||||
{
|
||||
"value": "bedroom",
|
||||
"label": "ห้องนอน (Bedroom)",
|
||||
"label_th": "ห้องนอน",
|
||||
"label_en": "Bedroom"
|
||||
},
|
||||
{
|
||||
"value": "living_room",
|
||||
"label": "ห้องนั่งเล่น / โถงกลาง (Living Room)",
|
||||
"label_th": "ห้องนั่งเล่น / โถงกลาง",
|
||||
"label_en": "Living Room"
|
||||
},
|
||||
{
|
||||
"value": "workspace",
|
||||
"label": "ห้องทำงาน / โต๊ะทำงาน (Workspace)",
|
||||
"label_th": "ห้องทำงาน / โต๊ะทำงาน",
|
||||
"label_en": "Workspace"
|
||||
},
|
||||
{
|
||||
"value": "bathroom",
|
||||
"label": "ห้องน้ำ (Bathroom)",
|
||||
"label_th": "ห้องน้ำ",
|
||||
"label_en": "Bathroom"
|
||||
},
|
||||
{
|
||||
"value": "car",
|
||||
"label": "ห้องโดยสารรถยนต์ (Car Interior)",
|
||||
"label_th": "ห้องโดยสารรถยนต์",
|
||||
"label_en": "Car Interior"
|
||||
},
|
||||
{
|
||||
"value": "commercial_spa",
|
||||
"label": "ร้านสปา / โรงแรม / คาเฟ่ (Commercial / Spa)",
|
||||
"label_th": "ร้านสปา / โรงแรม / คาเฟ่",
|
||||
"label_en": "Commercial / Spa"
|
||||
}
|
||||
],
|
||||
"isRequired": false,
|
||||
"isFilterable": true,
|
||||
"sortOrder": 4,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "พื้นที่ใช้งานที่แนะนำ",
|
||||
"description": "ห้องหรือบริเวณที่เหมาะสมสำหรับการกระจายกลิ่น"
|
||||
},
|
||||
"en": {
|
||||
"label": "Recommended Space",
|
||||
"description": "Ideal placement areas for optimal aroma diffusion"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "compatible_device",
|
||||
"label": "อุปกรณ์ที่รองรับ",
|
||||
"description": "ประเภทเครื่องพ่นหรืออุปกรณ์กระจายกลิ่นที่สามารถใช้ร่วมกันได้",
|
||||
"type": "TEXT",
|
||||
"unit": null,
|
||||
"options": null,
|
||||
"isRequired": false,
|
||||
"isFilterable": false,
|
||||
"sortOrder": 5,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "อุปกรณ์ที่รองรับ",
|
||||
"description": "อุปกรณ์หรือเครื่องพ่นที่สามารถใช้ร่วมกันได้"
|
||||
},
|
||||
"en": {
|
||||
"label": "Compatible Devices",
|
||||
"description": "Supported diffusers and diffusion equipment"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "coverage_area",
|
||||
"label": "ขนาดพื้นที่ครอบคลุม",
|
||||
"description": "ขนาดพื้นที่ห้องที่กลิ่นสามารถกระจายตัวได้อย่างมีประสิทธิภาพ",
|
||||
"type": "TEXT",
|
||||
"unit": "ตร.ม.",
|
||||
"options": null,
|
||||
"isRequired": false,
|
||||
"isFilterable": false,
|
||||
"sortOrder": 6,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "ขนาดพื้นที่ครอบคลุม",
|
||||
"description": "ขนาดพื้นที่ห้องที่กลิ่นกระจายตัวได้ดี"
|
||||
},
|
||||
"en": {
|
||||
"label": "Coverage Area",
|
||||
"description": "Effective room coverage area in square meters"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "scent_duration",
|
||||
"label": "ระยะเวลาการใช้งาน",
|
||||
"description": "อายุการใช้งานหรือระยะเวลาที่กลิ่นหอมกระจายตัวต่อเนื่อง",
|
||||
"type": "TEXT",
|
||||
"unit": null,
|
||||
"options": null,
|
||||
"isRequired": false,
|
||||
"isFilterable": false,
|
||||
"sortOrder": 7,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "ระยะเวลาการใช้งาน",
|
||||
"description": "ระยะเวลาที่กลิ่นกระจายตัวต่อเนื่อง"
|
||||
},
|
||||
"en": {
|
||||
"label": "Estimated Duration",
|
||||
"description": "Approximate lifespan and continuous scent duration"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "formula_base",
|
||||
"label": "ประเภทเบสและส่วนผสม",
|
||||
"description": "ชนิดของเบสน้ำมันหรือส่วนประกอบในการกระจายกลิ่น",
|
||||
"type": "TEXT",
|
||||
"unit": null,
|
||||
"options": null,
|
||||
"isRequired": false,
|
||||
"isFilterable": false,
|
||||
"sortOrder": 8,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "ประเภทเบสและส่วนผสม",
|
||||
"description": "ชนิดของเบสที่ใช้ในการผสมและกระจายกลิ่น"
|
||||
},
|
||||
"en": {
|
||||
"label": "Formula Base",
|
||||
"description": "Base formula and carrier type"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "usage_instructions",
|
||||
"label": "วิธีใช้งานและคำแนะนำ",
|
||||
"description": "ขั้นตอนการใช้งานที่ถูกต้องเพื่อประสิทธิภาพสูงสุดในการกระจายกลิ่น",
|
||||
"type": "TEXT",
|
||||
"unit": null,
|
||||
"options": null,
|
||||
"isRequired": false,
|
||||
"isFilterable": false,
|
||||
"sortOrder": 9,
|
||||
"categoryId": null,
|
||||
"translations": {
|
||||
"th": {
|
||||
"label": "วิธีใช้งานและคำแนะนำ",
|
||||
"description": "ขั้นตอนการใช้งานเพื่อประสิทธิภาพสูงสุด"
|
||||
},
|
||||
"en": {
|
||||
"label": "Usage Instructions",
|
||||
"description": "Recommended steps for best diffusion experience"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,677 @@
|
||||
[
|
||||
{
|
||||
"productId": 3,
|
||||
"scentName": "ตะไคร้",
|
||||
"scentFamily": "spa_relaxing",
|
||||
"moodBenefit": "odor_eliminator",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bathroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 63,
|
||||
"scentName": "ลีลาวดี",
|
||||
"scentFamily": "spa_relaxing",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "commercial_spa",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 138,
|
||||
"scentName": "ดอกบัว",
|
||||
"scentFamily": "spa_relaxing",
|
||||
"moodBenefit": "focus_mind",
|
||||
"scentIntensity": "light",
|
||||
"roomPlacement": "commercial_spa",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 148,
|
||||
"scentName": "ไทยสปา",
|
||||
"scentFamily": "spa_relaxing",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "commercial_spa",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 8,
|
||||
"scentName": "ลาเวนเดอร์",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 28,
|
||||
"scentName": "ยูคาลิปตัส",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "workspace",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 33,
|
||||
"scentName": "เฟรชลาเวนเดอร์",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 38,
|
||||
"scentName": "เปเปอร์มิ้น",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "workspace",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 73,
|
||||
"scentName": "มะนาว",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "odor_eliminator",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bathroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 98,
|
||||
"scentName": "โรสเมรี่",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "focus_mind",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "workspace",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 123,
|
||||
"scentName": "มะกรูด",
|
||||
"scentFamily": "fresh_herbal",
|
||||
"moodBenefit": "odor_eliminator",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bathroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 18,
|
||||
"scentName": "ซากุระ",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "light",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 23,
|
||||
"scentName": "มะลิ",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 53,
|
||||
"scentName": "ดอกแก้ว",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 58,
|
||||
"scentName": "ดอกโมก",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 93,
|
||||
"scentName": "ดอกลิลลี่",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 103,
|
||||
"scentName": "อัญชัน",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "light",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 108,
|
||||
"scentName": "กุหลาบแดง",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 133,
|
||||
"scentName": "ดอกปีบ",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 163,
|
||||
"scentName": "ดอกชมนาด",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 173,
|
||||
"scentName": "ดอกการะเวก",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 178,
|
||||
"scentName": "ดอกจำปา",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 183,
|
||||
"scentName": "ดอกพิกุล",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 188,
|
||||
"scentName": "ดอกพุด",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 193,
|
||||
"scentName": "ดอกราตรี",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 198,
|
||||
"scentName": "ดอกลำดวน",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 213,
|
||||
"scentName": "ดอกราชาวดี",
|
||||
"scentFamily": "floral",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 68,
|
||||
"scentName": "ส้ม",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 78,
|
||||
"scentName": "แคนตาลูป",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 83,
|
||||
"scentName": "แอปเปิ้ลแดง",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 88,
|
||||
"scentName": "แอปเปิ้ลเขียว",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "workspace",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 118,
|
||||
"scentName": "ผลไม้รวม",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 153,
|
||||
"scentName": "กล้วย",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 203,
|
||||
"scentName": "ทับทิม",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 208,
|
||||
"scentName": "มะละกอสุก",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 218,
|
||||
"scentName": "กลิ่นลูกพีช",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 223,
|
||||
"scentName": "กลิ่นองุ่น",
|
||||
"scentFamily": "fruity",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 13,
|
||||
"scentName": "วานิลลา",
|
||||
"scentFamily": "sweet_warm",
|
||||
"moodBenefit": "romantic_cozy",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 113,
|
||||
"scentName": "หมากฝรั่ง",
|
||||
"scentFamily": "sweet_warm",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 128,
|
||||
"scentName": "แซนด้าวูส",
|
||||
"scentFamily": "sweet_warm",
|
||||
"moodBenefit": "focus_mind",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "workspace",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 158,
|
||||
"scentName": "กาแฟ",
|
||||
"scentFamily": "sweet_warm",
|
||||
"moodBenefit": "focus_mind",
|
||||
"scentIntensity": "strong",
|
||||
"roomPlacement": "workspace",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 168,
|
||||
"scentName": "กลิ่นน้ำนม",
|
||||
"scentFamily": "sweet_warm",
|
||||
"moodBenefit": "relax_sleep",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "bedroom",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 43,
|
||||
"scentName": "โอเชี่ยน",
|
||||
"scentFamily": "aquatic_ozone",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 48,
|
||||
"scentName": "เซเว่นซี",
|
||||
"scentFamily": "aquatic_ozone",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productId": 143,
|
||||
"scentName": "โอโซน",
|
||||
"scentFamily": "aquatic_ozone",
|
||||
"moodBenefit": "refresh_energize",
|
||||
"scentIntensity": "medium",
|
||||
"roomPlacement": "living_room",
|
||||
"specs": {
|
||||
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
"coverage_area": "15 - 25",
|
||||
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const API_BASE = process.env.API_BASE || "https://api.homaroma.shop/api";
|
||||
const ADMIN_USER = process.env.ADMIN_USER || "admin";
|
||||
const ADMIN_PASS = process.env.ADMIN_PASS || "homaroma123";
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Importing Attribute Definitions via API (${API_BASE}) ===`);
|
||||
|
||||
// 1. Read JSON definitions
|
||||
const jsonPath = path.resolve(__dirname, "../data/homaroma_attribute_definitions.json");
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
throw new Error(`File not found: ${jsonPath}`);
|
||||
}
|
||||
const definitions = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||
console.log(`✓ Loaded ${definitions.length} definitions from data/homaroma_attribute_definitions.json`);
|
||||
|
||||
// 2. Login to get JWT Token
|
||||
console.log(`2. Logging in as '${ADMIN_USER}' to obtain JWT token...`);
|
||||
const loginRes = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS }),
|
||||
});
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const errorText = await loginRes.text();
|
||||
throw new Error(`Login failed (${loginRes.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const loginData = (await loginRes.json()) as { success: boolean; token: string };
|
||||
if (!loginData.token) {
|
||||
throw new Error("No token returned from login API");
|
||||
}
|
||||
const token = loginData.token;
|
||||
console.log("✓ Successfully authenticated. JWT token acquired.");
|
||||
|
||||
// 3. Post bulk-import
|
||||
console.log("3. Sending bulk-import payload to /product-attributes/definitions/bulk-import...");
|
||||
const importRes = await fetch(`${API_BASE}/product-attributes/definitions/bulk-import`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(definitions),
|
||||
});
|
||||
|
||||
if (!importRes.ok) {
|
||||
const errorText = await importRes.text();
|
||||
throw new Error(`Bulk import failed (${importRes.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const importResult = await importRes.json();
|
||||
console.log("✓ Bulk import response:", JSON.stringify(importResult, null, 2));
|
||||
|
||||
// 4. Fetch all definitions to verify
|
||||
console.log("4. Verifying active attribute definitions from API...");
|
||||
const listRes = await fetch(`${API_BASE}/product-attributes/definitions?lang=th`);
|
||||
const listData = await listRes.json();
|
||||
console.log(`✓ Total active attribute definitions on API: ${Array.isArray(listData) ? listData.length : 'N/A'}`);
|
||||
|
||||
return importResult;
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.log("\n✅ Completed successfully.");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("\n❌ Error:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
let prisma: any;
|
||||
try {
|
||||
const mod = await import("../apps/backend/src/lib/prisma");
|
||||
prisma = mod.default;
|
||||
} catch {
|
||||
const mod = await import("./src/lib/prisma");
|
||||
prisma = mod.default;
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
deleteOldBrands?: boolean;
|
||||
}
|
||||
|
||||
export async function reassignProductsToHomAroma(options: RunOptions = {}) {
|
||||
console.log("=== Reassign Products to 'Hom Aroma' Brand ===");
|
||||
|
||||
// 1. Find or create 'Hom Aroma' brand
|
||||
let homAroma = await prisma.productBrand.findFirst({
|
||||
where: { name: { equals: "Hom Aroma", mode: "insensitive" } },
|
||||
});
|
||||
|
||||
if (!homAroma) {
|
||||
homAroma = await prisma.productBrand.create({
|
||||
data: { name: "Hom Aroma" },
|
||||
});
|
||||
console.log(`✓ Created 'Hom Aroma' brand with ID: ${homAroma.id}`);
|
||||
} else {
|
||||
console.log(`✓ Found existing 'Hom Aroma' brand with ID: ${homAroma.id}`);
|
||||
}
|
||||
|
||||
// 2. Ensure translations for Hom Aroma
|
||||
const translations = [
|
||||
{ langCode: "th", name: "Hom Aroma" },
|
||||
{ langCode: "en", name: "Hom Aroma" },
|
||||
];
|
||||
|
||||
for (const tr of translations) {
|
||||
await prisma.productBrandTranslation.upsert({
|
||||
where: {
|
||||
brandId_langCode: {
|
||||
brandId: homAroma.id,
|
||||
langCode: tr.langCode,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
brandId: homAroma.id,
|
||||
langCode: tr.langCode,
|
||||
name: tr.name,
|
||||
},
|
||||
update: {
|
||||
name: tr.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log("✓ Brand translations ensured for 'th' and 'en'.");
|
||||
|
||||
// 3. Reassign all products to Hom Aroma
|
||||
const totalProducts = await prisma.product.count();
|
||||
const updateResult = await prisma.product.updateMany({
|
||||
data: {
|
||||
brandId: homAroma.id,
|
||||
},
|
||||
});
|
||||
console.log(`✓ Reassigned ${updateResult.count} of ${totalProducts} products to Brand '${homAroma.name}' (ID: ${homAroma.id}).`);
|
||||
|
||||
// 4. Handle old brands
|
||||
const otherBrands = await prisma.productBrand.findMany({
|
||||
where: { id: { not: homAroma.id } },
|
||||
include: { _count: { select: { products: true } } },
|
||||
});
|
||||
|
||||
console.log(`Found ${otherBrands.length} other brands in database.`);
|
||||
|
||||
if (options.deleteOldBrands && otherBrands.length > 0) {
|
||||
console.log(`Deleting ${otherBrands.length} old scent brand records...`);
|
||||
const delResult = await prisma.productBrand.deleteMany({
|
||||
where: { id: { not: homAroma.id } },
|
||||
});
|
||||
console.log(`✓ Deleted ${delResult.count} old scent brands successfully.`);
|
||||
} else if (!options.deleteOldBrands) {
|
||||
console.log("Old brands preserved (deleteOldBrands is false).");
|
||||
}
|
||||
|
||||
// 5. Final summary verification
|
||||
const finalHomAromaProducts = await prisma.product.count({
|
||||
where: { brandId: homAroma.id },
|
||||
});
|
||||
const remainingBrands = await prisma.productBrand.count();
|
||||
|
||||
console.log("\n=== Final Summary ===");
|
||||
console.log(`Total Products linked to 'Hom Aroma': ${finalHomAromaProducts} / ${totalProducts}`);
|
||||
console.log(`Total Brands remaining in database: ${remainingBrands}`);
|
||||
|
||||
return {
|
||||
homAromaId: homAroma.id,
|
||||
totalProducts,
|
||||
reassignedCount: updateResult.count,
|
||||
otherBrandsCount: otherBrands.length,
|
||||
remainingBrandsCount: remainingBrands,
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-run if executed directly
|
||||
if (import.meta.main) {
|
||||
const shouldDelete = process.argv.includes("--delete-old");
|
||||
reassignProductsToHomAroma({ deleteOldBrands: shouldDelete })
|
||||
.then((res) => {
|
||||
console.log("\nExecution completed:", res);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("\nExecution failed:", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import prisma from "../apps/backend/src/lib/prisma";
|
||||
|
||||
const PROD_API = process.env.API_BASE || "https://api.homaroma.shop/api";
|
||||
const ADMIN_USER = process.env.ADMIN_USER || "admin";
|
||||
const ADMIN_PASS = process.env.ADMIN_PASS || "homaroma123";
|
||||
|
||||
interface ScentMapping {
|
||||
productId: number;
|
||||
scentName: string;
|
||||
scentFamily: "floral" | "fresh_herbal" | "fruity" | "spa_relaxing" | "sweet_warm" | "aquatic_ozone";
|
||||
moodBenefit: "relax_sleep" | "refresh_energize" | "focus_mind" | "odor_eliminator" | "romantic_cozy";
|
||||
scentIntensity: "light" | "medium" | "strong";
|
||||
roomPlacement: "bedroom" | "living_room" | "workspace" | "bathroom" | "car" | "commercial_spa";
|
||||
}
|
||||
|
||||
// 45 Reed Diffuser products mapping
|
||||
const REED_DIFFUSER_MAPPINGS: ScentMapping[] = [
|
||||
// Spa & Relaxing
|
||||
{ productId: 3, scentName: "ตะไคร้", scentFamily: "spa_relaxing", moodBenefit: "odor_eliminator", scentIntensity: "strong", roomPlacement: "bathroom" },
|
||||
{ productId: 63, scentName: "ลีลาวดี", scentFamily: "spa_relaxing", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "commercial_spa" },
|
||||
{ productId: 138, scentName: "ดอกบัว", scentFamily: "spa_relaxing", moodBenefit: "focus_mind", scentIntensity: "light", roomPlacement: "commercial_spa" },
|
||||
{ productId: 148, scentName: "ไทยสปา", scentFamily: "spa_relaxing", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "commercial_spa" },
|
||||
|
||||
// Fresh & Herbal
|
||||
{ productId: 8, scentName: "ลาเวนเดอร์", scentFamily: "fresh_herbal", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 28, scentName: "ยูคาลิปตัส", scentFamily: "fresh_herbal", moodBenefit: "refresh_energize", scentIntensity: "strong", roomPlacement: "workspace" },
|
||||
{ productId: 33, scentName: "เฟรชลาเวนเดอร์", scentFamily: "fresh_herbal", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 38, scentName: "เปเปอร์มิ้น", scentFamily: "fresh_herbal", moodBenefit: "refresh_energize", scentIntensity: "strong", roomPlacement: "workspace" },
|
||||
{ productId: 73, scentName: "มะนาว", scentFamily: "fresh_herbal", moodBenefit: "odor_eliminator", scentIntensity: "strong", roomPlacement: "bathroom" },
|
||||
{ productId: 98, scentName: "โรสเมรี่", scentFamily: "fresh_herbal", moodBenefit: "focus_mind", scentIntensity: "medium", roomPlacement: "workspace" },
|
||||
{ productId: 123, scentName: "มะกรูด", scentFamily: "fresh_herbal", moodBenefit: "odor_eliminator", scentIntensity: "strong", roomPlacement: "bathroom" },
|
||||
|
||||
// Floral
|
||||
{ productId: 18, scentName: "ซากุระ", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "light", roomPlacement: "bedroom" },
|
||||
{ productId: 23, scentName: "มะลิ", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "living_room" },
|
||||
{ productId: 53, scentName: "ดอกแก้ว", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "bedroom" },
|
||||
{ productId: 58, scentName: "ดอกโมก", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 93, scentName: "ดอกลิลลี่", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 103, scentName: "อัญชัน", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "light", roomPlacement: "bedroom" },
|
||||
{ productId: 108, scentName: "กุหลาบแดง", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "bedroom" },
|
||||
{ productId: 133, scentName: "ดอกปีบ", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 163, scentName: "ดอกชมนาด", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "living_room" },
|
||||
{ productId: 173, scentName: "ดอกการะเวก", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "living_room" },
|
||||
{ productId: 178, scentName: "ดอกจำปา", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "living_room" },
|
||||
{ productId: 183, scentName: "ดอกพิกุล", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 188, scentName: "ดอกพุด", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "bedroom" },
|
||||
{ productId: 193, scentName: "ดอกราตรี", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "bedroom" },
|
||||
{ productId: 198, scentName: "ดอกลำดวน", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
{ productId: 213, scentName: "ดอกราชาวดี", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
|
||||
// Fruity
|
||||
{ productId: 68, scentName: "ส้ม", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 78, scentName: "แคนตาลูป", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 83, scentName: "แอปเปิ้ลแดง", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 88, scentName: "แอปเปิ้ลเขียว", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "workspace" },
|
||||
{ productId: 118, scentName: "ผลไม้รวม", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 153, scentName: "กล้วย", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 203, scentName: "ทับทิม", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 208, scentName: "มะละกอสุก", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 218, scentName: "กลิ่นลูกพีช", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 223, scentName: "กลิ่นองุ่น", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
|
||||
// Sweet & Warm
|
||||
{ productId: 13, scentName: "วานิลลา", scentFamily: "sweet_warm", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "living_room" },
|
||||
{ productId: 113, scentName: "หมากฝรั่ง", scentFamily: "sweet_warm", moodBenefit: "refresh_energize", scentIntensity: "strong", roomPlacement: "living_room" },
|
||||
{ productId: 128, scentName: "แซนด้าวูส", scentFamily: "sweet_warm", moodBenefit: "focus_mind", scentIntensity: "medium", roomPlacement: "workspace" },
|
||||
{ productId: 158, scentName: "กาแฟ", scentFamily: "sweet_warm", moodBenefit: "focus_mind", scentIntensity: "strong", roomPlacement: "workspace" },
|
||||
{ productId: 168, scentName: "กลิ่นน้ำนม", scentFamily: "sweet_warm", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
|
||||
|
||||
// Aquatic & Ozone
|
||||
{ productId: 43, scentName: "โอเชี่ยน", scentFamily: "aquatic_ozone", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 48, scentName: "เซเว่นซี", scentFamily: "aquatic_ozone", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
{ productId: 143, scentName: "โอโซน", scentFamily: "aquatic_ozone", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
|
||||
];
|
||||
|
||||
// Standard Reed Diffuser specifications common across all items
|
||||
const REED_DIFFUSER_SPECS = {
|
||||
compatible_device: "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
|
||||
coverage_area: "15 - 25",
|
||||
scent_duration: "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
|
||||
formula_base: "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
|
||||
usage_instructions: "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง",
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main() {
|
||||
console.log("=== Seeding Product Attributes for Reed Diffuser Products ===");
|
||||
console.log(`Targeting ${REED_DIFFUSER_MAPPINGS.length} products on API: ${PROD_API}`);
|
||||
|
||||
// 1. Authenticate to get JWT token
|
||||
console.log(`1. Logging in as '${ADMIN_USER}' to obtain JWT token...`);
|
||||
const loginRes = await fetch(`${PROD_API}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS }),
|
||||
});
|
||||
|
||||
if (!loginRes.ok) {
|
||||
throw new Error(`Login failed (${loginRes.status}): ${await loginRes.text()}`);
|
||||
}
|
||||
|
||||
const { token } = (await loginRes.json()) as { token: string };
|
||||
console.log("✓ Authenticated successfully. JWT token acquired.\n");
|
||||
|
||||
// 2. Fetch active attribute definitions from API to map codes to IDs
|
||||
console.log("2. Fetching active attribute definitions from API...");
|
||||
const defsRes = await fetch(`${PROD_API}/product-attributes/definitions?lang=th`);
|
||||
const definitions = (await defsRes.json()) as Array<{ id: number; code: string; type: string }>;
|
||||
const codeToDef = new Map<string, { id: number; code: string; type: string }>();
|
||||
for (const def of definitions) {
|
||||
codeToDef.set(def.code, def);
|
||||
}
|
||||
|
||||
const requiredCodes = [
|
||||
"scent_family",
|
||||
"mood_benefit",
|
||||
"scent_intensity",
|
||||
"room_placement",
|
||||
"compatible_device",
|
||||
"coverage_area",
|
||||
"scent_duration",
|
||||
"formula_base",
|
||||
"usage_instructions",
|
||||
];
|
||||
|
||||
for (const code of requiredCodes) {
|
||||
if (!codeToDef.has(code)) {
|
||||
throw new Error(`Attribute definition code '${code}' not found in API!`);
|
||||
}
|
||||
}
|
||||
console.log("✓ All 9 attribute definitions verified on API.\n");
|
||||
|
||||
// Save the full matrix as JSON artifact
|
||||
const exportPath = path.resolve(__dirname, "../data/reed_diffuser_attributes_matrix.json");
|
||||
fs.writeFileSync(
|
||||
exportPath,
|
||||
JSON.stringify(
|
||||
REED_DIFFUSER_MAPPINGS.map((m) => ({
|
||||
...m,
|
||||
specs: REED_DIFFUSER_SPECS,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf-8"
|
||||
);
|
||||
console.log(`✓ Saved mapping matrix to ${exportPath}\n`);
|
||||
|
||||
// 3. Process each product via API
|
||||
console.log(`3. Updating attributes for ${REED_DIFFUSER_MAPPINGS.length} Reed Diffuser products via API...`);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let i = 0; i < REED_DIFFUSER_MAPPINGS.length; i++) {
|
||||
const item = REED_DIFFUSER_MAPPINGS[i];
|
||||
const attributesPayload = [
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("scent_family")!.id,
|
||||
valueText: item.scentFamily,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("mood_benefit")!.id,
|
||||
valueText: item.moodBenefit,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("scent_intensity")!.id,
|
||||
valueText: item.scentIntensity,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("room_placement")!.id,
|
||||
valueText: item.roomPlacement,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("compatible_device")!.id,
|
||||
valueText: REED_DIFFUSER_SPECS.compatible_device,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("coverage_area")!.id,
|
||||
valueText: REED_DIFFUSER_SPECS.coverage_area,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("scent_duration")!.id,
|
||||
valueText: REED_DIFFUSER_SPECS.scent_duration,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("formula_base")!.id,
|
||||
valueText: REED_DIFFUSER_SPECS.formula_base,
|
||||
},
|
||||
{
|
||||
attributeDefinitionId: codeToDef.get("usage_instructions")!.id,
|
||||
valueText: REED_DIFFUSER_SPECS.usage_instructions,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const updateRes = await fetch(`${PROD_API}/products/${item.productId}/attributes`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ attributes: attributesPayload }),
|
||||
});
|
||||
|
||||
if (!updateRes.ok) {
|
||||
const errText = await updateRes.text();
|
||||
console.error(`❌ [${i + 1}/${REED_DIFFUSER_MAPPINGS.length}] Product #${item.productId} (${item.scentName}) failed: ${updateRes.status} ${errText}`);
|
||||
failCount++;
|
||||
} else {
|
||||
console.log(`✓ [${i + 1}/${REED_DIFFUSER_MAPPINGS.length}] Product #${item.productId} (${item.scentName}) linked with 9 attributes.`);
|
||||
successCount++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ [${i + 1}/${REED_DIFFUSER_MAPPINGS.length}] Product #${item.productId} error: ${err.message}`);
|
||||
failCount++;
|
||||
}
|
||||
|
||||
await sleep(80);
|
||||
}
|
||||
|
||||
console.log(`\n=== API Seeding Completed: ${successCount} succeeded, ${failCount} failed ===\n`);
|
||||
|
||||
// 4. Also sync to Local Database if connected
|
||||
try {
|
||||
console.log("4. Syncing attributes to Local Database via Prisma...");
|
||||
const localDefs = await prisma.attributeDefinition.findMany();
|
||||
const localDefMap = new Map<string, number>();
|
||||
for (const d of localDefs) {
|
||||
localDefMap.set(d.code, d.id);
|
||||
}
|
||||
|
||||
let localSuccess = 0;
|
||||
for (const item of REED_DIFFUSER_MAPPINGS) {
|
||||
// Find local product by name
|
||||
const expectedName = `${item.scentName} Reed Diffuser`;
|
||||
const localProd = await prisma.product.findFirst({
|
||||
where: {
|
||||
name: { contains: item.scentName },
|
||||
category: { name: { contains: "ก้านไม้" } },
|
||||
},
|
||||
});
|
||||
if (!localProd) continue;
|
||||
|
||||
const attrs = [
|
||||
{ code: "scent_family", val: item.scentFamily, isText: true },
|
||||
{ code: "mood_benefit", val: item.moodBenefit, isText: true },
|
||||
{ code: "scent_intensity", val: item.scentIntensity, isText: true },
|
||||
{ code: "room_placement", val: item.roomPlacement, isText: true },
|
||||
{ code: "compatible_device", val: REED_DIFFUSER_SPECS.compatible_device, isText: true },
|
||||
{ code: "coverage_area", val: REED_DIFFUSER_SPECS.coverage_area, isText: true },
|
||||
{ code: "scent_duration", val: REED_DIFFUSER_SPECS.scent_duration, isText: true },
|
||||
{ code: "formula_base", val: REED_DIFFUSER_SPECS.formula_base, isText: true },
|
||||
{ code: "usage_instructions", val: REED_DIFFUSER_SPECS.usage_instructions, isText: true },
|
||||
];
|
||||
|
||||
for (const attr of attrs) {
|
||||
const defId = localDefMap.get(attr.code);
|
||||
if (!defId) continue;
|
||||
|
||||
await prisma.productAttribute.upsert({
|
||||
where: {
|
||||
productId_attributeDefinitionId: {
|
||||
productId: localProd.id,
|
||||
attributeDefinitionId: defId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
productId: localProd.id,
|
||||
attributeDefinitionId: defId,
|
||||
valueText: attr.val,
|
||||
},
|
||||
update: {
|
||||
valueText: attr.val,
|
||||
},
|
||||
});
|
||||
}
|
||||
localSuccess++;
|
||||
}
|
||||
console.log(`✓ Synced ${localSuccess} products into Local Database.\n`);
|
||||
} catch (err: any) {
|
||||
console.warn(`Local DB sync skipped or encountered an error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.log("✅ All Reed Diffuser Product Attributes successfully linked!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("❌ Process failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user