diff --git a/apps/backend/prisma/migrations/20260628033659_add_product_attributes/migration.sql b/apps/backend/prisma/migrations/20260628033659_add_product_attributes/migration.sql
new file mode 100644
index 0000000..ada4dbe
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260628033659_add_product_attributes/migration.sql
@@ -0,0 +1,79 @@
+-- CreateTable
+CREATE TABLE "AttributeDefinition" (
+ "id" SERIAL NOT NULL,
+ "code" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "description" TEXT,
+ "type" TEXT NOT NULL,
+ "unit" TEXT,
+ "options" JSONB,
+ "isRequired" BOOLEAN NOT NULL DEFAULT false,
+ "isFilterable" BOOLEAN NOT NULL DEFAULT false,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "categoryId" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AttributeDefinition_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AttributeDefinitionTranslation" (
+ "id" SERIAL NOT NULL,
+ "attributeDefinitionId" INTEGER NOT NULL,
+ "langCode" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AttributeDefinitionTranslation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ProductAttribute" (
+ "id" SERIAL NOT NULL,
+ "productId" INTEGER NOT NULL,
+ "attributeDefinitionId" INTEGER NOT NULL,
+ "valueText" TEXT,
+ "valueNumber" DOUBLE PRECISION,
+ "valueBoolean" BOOLEAN,
+ "valueJson" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProductAttribute_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AttributeDefinition_code_key" ON "AttributeDefinition"("code");
+
+-- CreateIndex
+CREATE INDEX "AttributeDefinition_categoryId_idx" ON "AttributeDefinition"("categoryId");
+
+-- CreateIndex
+CREATE INDEX "AttributeDefinition_sortOrder_idx" ON "AttributeDefinition"("sortOrder");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AttributeDefinitionTranslation_attributeDefinitionId_langCo_key" ON "AttributeDefinitionTranslation"("attributeDefinitionId", "langCode");
+
+-- CreateIndex
+CREATE INDEX "ProductAttribute_productId_idx" ON "ProductAttribute"("productId");
+
+-- CreateIndex
+CREATE INDEX "ProductAttribute_attributeDefinitionId_idx" ON "ProductAttribute"("attributeDefinitionId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ProductAttribute_productId_attributeDefinitionId_key" ON "ProductAttribute"("productId", "attributeDefinitionId");
+
+-- AddForeignKey
+ALTER TABLE "AttributeDefinition" ADD CONSTRAINT "AttributeDefinition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AttributeDefinitionTranslation" ADD CONSTRAINT "AttributeDefinitionTranslation_attributeDefinitionId_fkey" FOREIGN KEY ("attributeDefinitionId") REFERENCES "AttributeDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProductAttribute" ADD CONSTRAINT "ProductAttribute_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProductAttribute" ADD CONSTRAINT "ProductAttribute_attributeDefinitionId_fkey" FOREIGN KEY ("attributeDefinitionId") REFERENCES "AttributeDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/backend/prisma/migrations/20260628035508_add_product_attribute_translations/migration.sql b/apps/backend/prisma/migrations/20260628035508_add_product_attribute_translations/migration.sql
new file mode 100644
index 0000000..dd93845
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260628035508_add_product_attribute_translations/migration.sql
@@ -0,0 +1,20 @@
+-- CreateTable
+CREATE TABLE "ProductAttributeTranslation" (
+ "id" SERIAL NOT NULL,
+ "productAttributeId" INTEGER NOT NULL,
+ "langCode" TEXT NOT NULL,
+ "valueText" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProductAttributeTranslation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "ProductAttributeTranslation_productAttributeId_idx" ON "ProductAttributeTranslation"("productAttributeId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ProductAttributeTranslation_productAttributeId_langCode_key" ON "ProductAttributeTranslation"("productAttributeId", "langCode");
+
+-- AddForeignKey
+ALTER TABLE "ProductAttributeTranslation" ADD CONSTRAINT "ProductAttributeTranslation_productAttributeId_fkey" FOREIGN KEY ("productAttributeId") REFERENCES "ProductAttribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma
index d12604c..2f626dc 100644
--- a/apps/backend/prisma/schema.prisma
+++ b/apps/backend/prisma/schema.prisma
@@ -35,6 +35,7 @@ model Category {
name String @unique
imageUrl String?
products Product[]
+ attributes AttributeDefinition[]
translations CategoryTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -65,6 +66,7 @@ model Product {
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
variants ProductVariant[]
images ProductImage[]
+ attributes ProductAttribute[]
reviews Review[]
translations ProductTranslation[]
createdAt DateTime @default(now())
@@ -113,6 +115,73 @@ model ProductVariant {
@@unique([productId, option1, option2, option3])
}
+model AttributeDefinition {
+ id Int @id @default(autoincrement())
+ code String @unique
+ label String
+ description String?
+ type String // TEXT, NUMBER, BOOLEAN, SELECT, JSON
+ unit String?
+ options Json? // Expected shape: [{ value: "floral", label: { th: "ดอกไม้", en: "Floral" } }]
+ isRequired Boolean @default(false)
+ isFilterable Boolean @default(false)
+ sortOrder Int @default(0)
+ categoryId Int?
+ category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
+ products ProductAttribute[]
+ translations AttributeDefinitionTranslation[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([categoryId])
+ @@index([sortOrder])
+}
+
+model AttributeDefinitionTranslation {
+ id Int @id @default(autoincrement())
+ attributeDefinitionId Int
+ attributeDefinition AttributeDefinition @relation(fields: [attributeDefinitionId], references: [id], onDelete: Cascade)
+ langCode String
+ label String
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([attributeDefinitionId, langCode])
+}
+
+model ProductAttribute {
+ id Int @id @default(autoincrement())
+ productId Int
+ product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
+ attributeDefinitionId Int
+ attributeDefinition AttributeDefinition @relation(fields: [attributeDefinitionId], references: [id], onDelete: Cascade)
+ valueText String?
+ valueNumber Float?
+ valueBoolean Boolean?
+ valueJson Json?
+ translations ProductAttributeTranslation[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([productId, attributeDefinitionId])
+ @@index([productId])
+ @@index([attributeDefinitionId])
+}
+
+model ProductAttributeTranslation {
+ id Int @id @default(autoincrement())
+ productAttributeId Int
+ productAttribute ProductAttribute @relation(fields: [productAttributeId], references: [id], onDelete: Cascade)
+ langCode String
+ valueText String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([productAttributeId, langCode])
+ @@index([productAttributeId])
+}
+
model User {
id Int @id @default(autoincrement())
username String @unique
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index 1770690..41181b9 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -27,6 +27,7 @@ import { menusRoute } from "./routes/menus";
import { newsletterRoute } from "./routes/newsletter";
import { mediaRoute } from "./routes/media";
import { contactsRoute } from "./routes/contacts";
+import { productAttributesRoute } from "./routes/product-attributes";
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";
@@ -134,7 +135,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
- app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
+ app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
diff --git a/apps/backend/src/lib/productAttributes.ts b/apps/backend/src/lib/productAttributes.ts
new file mode 100644
index 0000000..6174d9c
--- /dev/null
+++ b/apps/backend/src/lib/productAttributes.ts
@@ -0,0 +1,171 @@
+const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] as const;
+
+export const SUPPORTED_LANGS = LANGS;
+
+export type AttributeType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'JSON';
+
+const ATTRIBUTE_TYPES: AttributeType[] = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'];
+
+export function normalizeAttributeType(type: unknown): AttributeType {
+ const normalized = String(type || '').toUpperCase();
+ if (!ATTRIBUTE_TYPES.includes(normalized as AttributeType)) {
+ throw new Error(`Unsupported attribute type '${type}'. Allowed types: ${ATTRIBUTE_TYPES.join(', ')}`);
+ }
+ return normalized as AttributeType;
+}
+
+export function resolveLocalizedText(
+ baseValue: string | null | undefined,
+ translations: Array<{ langCode: string; valueText?: string | null }> | undefined,
+ lang: string
+) {
+ if (lang === 'all') return baseValue ?? '';
+ const exact = translations?.find((item) => item.langCode === lang)?.valueText;
+ if (exact) return exact;
+ const thai = translations?.find((item) => item.langCode === 'th')?.valueText;
+ if (thai) return thai;
+ return baseValue ?? '';
+}
+
+export function resolveDefinitionLocalizedField(
+ baseValue: string | null | undefined,
+ translations: T[] | undefined,
+ lang: string,
+ field: 'label' | 'description'
+) {
+ if (lang === 'all') return baseValue ?? null;
+ const exact = translations?.find((item) => item.langCode === lang)?.[field];
+ if (exact !== undefined && exact !== null && exact !== '') return exact;
+ const thai = translations?.find((item) => item.langCode === 'th')?.[field];
+ if (thai !== undefined && thai !== null && thai !== '') return thai;
+ return baseValue ?? null;
+}
+
+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 String(value ?? '');
+}
+
+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 String(value ?? '');
+}
+
+export function getSelectOptionDisplay(options: any, storedValue: string, lang: string): string {
+ if (!Array.isArray(options)) return storedValue;
+ for (const option of options) {
+ const primary = getPrimaryOptionValue(option);
+ if (primary === storedValue) {
+ return getLocalizedOptionLabel(option, lang);
+ }
+ }
+ return storedValue;
+}
+
+export function validateAttributeValue(type: AttributeType, payload: Record) {
+ const populated = [
+ payload.valueText !== undefined && payload.valueText !== null && payload.valueText !== '',
+ payload.valueNumber !== undefined && payload.valueNumber !== null,
+ payload.valueBoolean !== undefined && payload.valueBoolean !== null,
+ payload.valueJson !== undefined && payload.valueJson !== null,
+ ].filter(Boolean).length;
+
+ if (populated !== 1) {
+ throw new Error('Exactly one of valueText, valueNumber, valueBoolean, or valueJson must be provided.');
+ }
+
+ switch (type) {
+ case 'TEXT':
+ if (typeof payload.valueText !== 'string') throw new Error('TEXT attributes require valueText as a string.');
+ return;
+ case 'NUMBER':
+ if (typeof payload.valueNumber !== 'number' || Number.isNaN(payload.valueNumber)) {
+ throw new Error('NUMBER attributes require valueNumber as a valid number.');
+ }
+ return;
+ case 'BOOLEAN':
+ if (typeof payload.valueBoolean !== 'boolean') {
+ throw new Error('BOOLEAN attributes require valueBoolean as a boolean.');
+ }
+ return;
+ case 'JSON':
+ if (payload.valueJson === undefined || payload.valueJson === null) {
+ throw new Error('JSON attributes require valueJson.');
+ }
+ return;
+ case 'SELECT':
+ if (typeof payload.valueText !== 'string') throw new Error('SELECT attributes require valueText as the stored option key.');
+ return;
+ }
+}
+
+export function validateSelectOption(options: any, storedValue: string) {
+ if (!Array.isArray(options)) {
+ throw new Error('SELECT attribute definition must provide options.');
+ }
+ const allowed = options.map(getPrimaryOptionValue);
+ if (!allowed.includes(storedValue)) {
+ throw new Error(`Invalid SELECT value '${storedValue}'. Allowed values: [${allowed.join(', ')}]`);
+ }
+}
+
+export function localizeAttributeDefinition(definition: any, lang: string) {
+ if (lang === 'all') {
+ const label: Record = {};
+ const description: Record = {};
+ definition.translations?.forEach((translation: any) => {
+ label[translation.langCode] = translation.label;
+ if (translation.description) description[translation.langCode] = translation.description;
+ });
+
+ return {
+ ...definition,
+ label,
+ description,
+ };
+ }
+
+ return {
+ ...definition,
+ label: resolveDefinitionLocalizedField(definition.label, definition.translations, lang, 'label') || definition.label,
+ description: resolveDefinitionLocalizedField(definition.description, definition.translations, lang, 'description'),
+ };
+}
+
+export function localizeProductAttribute(attribute: any, lang: string) {
+ const localizedDefinition = localizeAttributeDefinition(attribute.attributeDefinition, lang);
+ const type = normalizeAttributeType(attribute.attributeDefinition.type);
+
+ const value =
+ type === 'TEXT'
+ ? resolveLocalizedText(attribute.valueText, attribute.translations, lang)
+ : type === 'SELECT'
+ ? getSelectOptionDisplay(attribute.attributeDefinition.options, attribute.valueText || '', lang)
+ : attribute.valueBoolean ?? attribute.valueNumber ?? attribute.valueJson ?? attribute.valueText ?? null;
+
+ if (lang === 'all') {
+ const valueTranslations: Record = {};
+ attribute.translations?.forEach((translation: any) => {
+ valueTranslations[translation.langCode] = translation.valueText;
+ });
+
+ return {
+ ...attribute,
+ attributeDefinition: localizedDefinition,
+ value,
+ valueTranslations,
+ };
+ }
+
+ return {
+ ...attribute,
+ attributeDefinition: localizedDefinition,
+ value,
+ };
+}
diff --git a/apps/backend/src/routes/product-attributes.ts b/apps/backend/src/routes/product-attributes.ts
new file mode 100644
index 0000000..0bc5025
--- /dev/null
+++ b/apps/backend/src/routes/product-attributes.ts
@@ -0,0 +1,439 @@
+import { Elysia, t } from "elysia";
+import prisma from "../lib/prisma";
+import {
+ SUPPORTED_LANGS,
+ localizeAttributeDefinition,
+ localizeProductAttribute,
+ normalizeAttributeType,
+ validateAttributeValue,
+ validateSelectOption,
+} from "../lib/productAttributes";
+
+function parseId(value: string, label: string) {
+ const parsed = parseInt(value);
+ if (Number.isNaN(parsed)) {
+ throw new Error(`Invalid ${label}`);
+ }
+ return parsed;
+}
+
+function buildDefinitionPayload(payload: any) {
+ return {
+ code: payload.code,
+ label: payload.label,
+ description: payload.description ?? null,
+ type: normalizeAttributeType(payload.type),
+ unit: payload.unit ?? null,
+ options: payload.options ?? null,
+ isRequired: Boolean(payload.isRequired),
+ isFilterable: Boolean(payload.isFilterable),
+ sortOrder: typeof payload.sortOrder === 'number' ? payload.sortOrder : 0,
+ categoryId: payload.categoryId ?? null,
+ };
+}
+
+async function assertProductAndDefinitionMatch(productId: number, attributeDefinitionId: number) {
+ const [product, definition] = await Promise.all([
+ prisma.product.findUnique({ where: { id: productId } }),
+ prisma.attributeDefinition.findUnique({ where: { id: attributeDefinitionId } }),
+ ]);
+
+ if (!product) throw new Error("Product not found");
+ if (!definition) throw new Error("Attribute definition not found");
+ if (definition.categoryId && definition.categoryId !== product.categoryId) {
+ throw new Error("Attribute definition category does not match product category");
+ }
+
+ return { product, definition };
+}
+
+export const productAttributesRoute = new Elysia()
+ .get("/product-attributes/definitions", async ({ query }) => {
+ const lang = (query as any).lang || 'th';
+ const categoryId = (query as any).categoryId ? parseInt((query as any).categoryId) : undefined;
+
+ const definitions = await prisma.attributeDefinition.findMany({
+ where: categoryId ? { OR: [{ categoryId }, { categoryId: null }] } : undefined,
+ include: {
+ category: true,
+ translations: true,
+ },
+ orderBy: [
+ { sortOrder: 'asc' },
+ { code: 'asc' },
+ ],
+ });
+
+ return definitions.map((definition: any) => localizeAttributeDefinition(definition, lang));
+ })
+ .get("/product-attributes/definitions/:id", async ({ params, query, status }) => {
+ try {
+ const id = parseId(params.id, "attribute definition ID");
+ const lang = (query as any).lang || 'th';
+ const definition = await prisma.attributeDefinition.findUnique({
+ where: { id },
+ include: {
+ category: true,
+ translations: true,
+ },
+ });
+
+ if (!definition) return status(404, { message: "Attribute definition not found" });
+ return localizeAttributeDefinition(definition, lang);
+ } catch (err: any) {
+ return status(400, { message: err.message });
+ }
+ })
+ .post("/product-attributes/definitions", async ({ body, status }) => {
+ try {
+ const payload = body as any;
+ const definition = await prisma.attributeDefinition.create({
+ data: {
+ ...buildDefinitionPayload(payload),
+ translations: payload.translations && typeof payload.translations === 'object'
+ ? {
+ create: Object.entries(payload.translations)
+ .filter(([lang, value]) => SUPPORTED_LANGS.includes(lang as any) && value && typeof value === 'object' && (value as any).label)
+ .map(([lang, value]) => ({
+ langCode: lang,
+ label: (value as any).label,
+ description: (value as any).description ?? null,
+ })),
+ }
+ : undefined,
+ },
+ include: {
+ category: true,
+ translations: true,
+ },
+ });
+
+ return { success: true, definition };
+ } catch (err: any) {
+ if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
+ return status(500, { message: err?.message || "Failed to create attribute definition" });
+ }
+ }, {
+ body: t.Any(),
+ })
+ .put("/product-attributes/definitions/:id", async ({ params, body, status }) => {
+ try {
+ const id = parseId(params.id, "attribute definition ID");
+ const payload = body as any;
+ const definition = await prisma.attributeDefinition.update({
+ where: { id },
+ data: buildDefinitionPayload(payload),
+ include: {
+ category: true,
+ translations: true,
+ },
+ });
+
+ return { success: true, definition };
+ } catch (err: any) {
+ if (err.code === 'P2025') return status(404, { message: "Attribute definition not found" });
+ if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
+ return status(500, { message: err?.message || "Failed to update attribute definition" });
+ }
+ }, {
+ body: t.Any(),
+ })
+ .patch("/product-attributes/definitions/:id/translations", async ({ params, body, status }) => {
+ try {
+ const id = parseId(params.id, "attribute definition ID");
+ const definition = await prisma.attributeDefinition.findUnique({ where: { id } });
+ if (!definition) return status(404, { message: "Attribute definition not found" });
+
+ const payload = body as any;
+ const results: any[] = [];
+
+ for (const lang of SUPPORTED_LANGS) {
+ const item = payload.translations?.[lang]
+ || ((payload.label?.[lang] || payload.description?.[lang]) ? { label: payload.label?.[lang], description: payload.description?.[lang] } : null);
+
+ if (!item?.label) continue;
+
+ const existing = await prisma.attributeDefinitionTranslation.findUnique({
+ where: { attributeDefinitionId_langCode: { attributeDefinitionId: id, langCode: lang } },
+ });
+
+ const data = {
+ label: item.label,
+ description: item.description ?? null,
+ };
+
+ if (existing) {
+ results.push(await prisma.attributeDefinitionTranslation.update({
+ where: { id: existing.id },
+ data,
+ }));
+ } else {
+ results.push(await prisma.attributeDefinitionTranslation.create({
+ data: {
+ attributeDefinitionId: id,
+ langCode: lang,
+ ...data,
+ },
+ }));
+ }
+ }
+
+ if (results.length === 0) return status(400, { message: "No valid translations provided" });
+
+ const updated = await prisma.attributeDefinition.findUnique({
+ where: { id },
+ include: { category: true, translations: true },
+ });
+
+ return { success: true, definition: updated, translations: results };
+ } catch (err: any) {
+ return status(500, { message: err?.message || "Failed to update attribute definition translations" });
+ }
+ }, {
+ 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");
+ const payload = body as any;
+ const attributeDefinitionId = Number(payload.attributeDefinitionId);
+ if (!attributeDefinitionId) return status(400, { message: "attributeDefinitionId is required" });
+
+ const { definition } = await assertProductAndDefinitionMatch(productId, attributeDefinitionId);
+ const type = normalizeAttributeType(definition.type);
+ validateAttributeValue(type, payload);
+ if (type === 'SELECT') validateSelectOption(definition.options, payload.valueText);
+
+ const attribute = await prisma.productAttribute.upsert({
+ where: { productId_attributeDefinitionId: { productId, attributeDefinitionId } },
+ update: {
+ valueText: payload.valueText ?? null,
+ valueNumber: payload.valueNumber ?? null,
+ valueBoolean: payload.valueBoolean ?? null,
+ valueJson: payload.valueJson ?? null,
+ },
+ create: {
+ productId,
+ attributeDefinitionId,
+ valueText: payload.valueText ?? null,
+ valueNumber: payload.valueNumber ?? null,
+ valueBoolean: payload.valueBoolean ?? null,
+ valueJson: payload.valueJson ?? null,
+ },
+ include: {
+ translations: true,
+ attributeDefinition: { include: { category: true, translations: true } },
+ },
+ });
+
+ return { success: true, attribute };
+ } catch (err: any) {
+ const code = err.message === "Product not found" || err.message === "Attribute definition not found" ? 404 : 400;
+ return status(code, { message: err.message });
+ }
+ }, {
+ body: t.Any(),
+ })
+ .put("/products/:id/attributes", async ({ params, body, status }) => {
+ try {
+ const productId = parseId(params.id, "product ID");
+ const payload = body as any;
+ if (!Array.isArray(payload.attributes)) return status(400, { message: "attributes array is required" });
+
+ const product = await prisma.product.findUnique({ where: { id: productId } });
+ if (!product) return status(404, { message: "Product not found" });
+
+ const existing = await prisma.productAttribute.findMany({
+ where: { productId },
+ select: { id: true, attributeDefinitionId: true },
+ });
+ const incomingDefinitionIds = payload.attributes.map((item: any) => Number(item.attributeDefinitionId)).filter(Boolean);
+ const toDelete = existing.filter((item: any) => !incomingDefinitionIds.includes(item.attributeDefinitionId));
+
+ if (toDelete.length > 0) {
+ await prisma.productAttribute.deleteMany({
+ where: { id: { in: toDelete.map((item: any) => item.id) } },
+ });
+ }
+
+ const results = [];
+ for (const item of payload.attributes) {
+ const attributeDefinitionId = Number(item.attributeDefinitionId);
+ const { definition } = await assertProductAndDefinitionMatch(productId, attributeDefinitionId);
+ const type = normalizeAttributeType(definition.type);
+ validateAttributeValue(type, item);
+ if (type === 'SELECT') validateSelectOption(definition.options, item.valueText);
+
+ const attribute = await prisma.productAttribute.upsert({
+ where: { productId_attributeDefinitionId: { productId, attributeDefinitionId } },
+ update: {
+ valueText: item.valueText ?? null,
+ valueNumber: item.valueNumber ?? null,
+ valueBoolean: item.valueBoolean ?? null,
+ valueJson: item.valueJson ?? null,
+ },
+ create: {
+ productId,
+ attributeDefinitionId,
+ valueText: item.valueText ?? null,
+ valueNumber: item.valueNumber ?? null,
+ valueBoolean: item.valueBoolean ?? null,
+ valueJson: item.valueJson ?? null,
+ },
+ include: {
+ translations: true,
+ attributeDefinition: { include: { category: true, translations: true } },
+ },
+ });
+
+ if (type === 'TEXT' && item.translations && typeof item.translations === 'object') {
+ for (const lang of SUPPORTED_LANGS) {
+ const translatedValue = item.translations[lang];
+ if (typeof translatedValue !== 'string' || !translatedValue.trim()) continue;
+ await prisma.productAttributeTranslation.upsert({
+ where: { productAttributeId_langCode: { productAttributeId: attribute.id, langCode: lang } },
+ update: { valueText: translatedValue },
+ create: { productAttributeId: attribute.id, langCode: lang, valueText: translatedValue },
+ });
+ }
+ }
+
+ results.push(attribute);
+ }
+
+ const updated = await prisma.productAttribute.findMany({
+ where: { productId },
+ include: {
+ translations: true,
+ attributeDefinition: { include: { category: true, translations: true } },
+ },
+ orderBy: [
+ { attributeDefinition: { sortOrder: 'asc' } },
+ { id: 'asc' },
+ ],
+ });
+
+ return { success: true, attributes: updated };
+ } catch (err: any) {
+ const code = err.message === "Product not found" || err.message === "Attribute definition not found" ? 404 : 400;
+ return status(code, { message: err.message });
+ }
+ }, {
+ body: t.Any(),
+ })
+ .put("/products/:id/attributes/:productAttributeId", async ({ params, body, status }) => {
+ try {
+ const productId = parseId(params.id, "product ID");
+ const productAttributeId = parseId(params.productAttributeId, "product attribute ID");
+ const attribute = await prisma.productAttribute.findUnique({
+ where: { id: productAttributeId },
+ include: { attributeDefinition: true },
+ });
+
+ if (!attribute || attribute.productId !== productId) {
+ return status(404, { message: "Product attribute not found" });
+ }
+
+ const type = normalizeAttributeType(attribute.attributeDefinition.type);
+ const payload = body as any;
+ validateAttributeValue(type, payload);
+ if (type === 'SELECT') validateSelectOption(attribute.attributeDefinition.options, payload.valueText);
+
+ const updated = await prisma.productAttribute.update({
+ where: { id: productAttributeId },
+ data: {
+ valueText: payload.valueText ?? null,
+ valueNumber: payload.valueNumber ?? null,
+ valueBoolean: payload.valueBoolean ?? null,
+ valueJson: payload.valueJson ?? null,
+ },
+ include: {
+ translations: true,
+ attributeDefinition: { include: { category: true, translations: true } },
+ },
+ });
+
+ return { success: true, attribute: updated };
+ } catch (err: any) {
+ return status(400, { message: err.message });
+ }
+ }, {
+ body: t.Any(),
+ })
+ .patch("/products/:id/attributes/:productAttributeId/translations", async ({ params, body, status }) => {
+ try {
+ const productId = parseId(params.id, "product ID");
+ const productAttributeId = parseId(params.productAttributeId, "product attribute ID");
+ const attribute = await prisma.productAttribute.findUnique({
+ where: { id: productAttributeId },
+ include: { attributeDefinition: true, translations: true },
+ });
+
+ if (!attribute || attribute.productId !== productId) {
+ return status(404, { message: "Product attribute not found" });
+ }
+
+ const type = normalizeAttributeType(attribute.attributeDefinition.type);
+ if (type !== 'TEXT') {
+ return status(400, { message: "Only TEXT product attributes support translations in this phase" });
+ }
+
+ const payload = body as any;
+ const results: any[] = [];
+ for (const lang of SUPPORTED_LANGS) {
+ const valueText = payload.translations?.[lang] ?? payload.valueText?.[lang];
+ if (typeof valueText !== 'string' || !valueText.trim()) continue;
+
+ results.push(await prisma.productAttributeTranslation.upsert({
+ where: { productAttributeId_langCode: { productAttributeId, langCode: lang } },
+ update: { valueText },
+ create: { productAttributeId, langCode: lang, valueText },
+ }));
+ }
+
+ if (results.length === 0) return status(400, { message: "No valid translations provided" });
+
+ const updated = await prisma.productAttribute.findUnique({
+ where: { id: productAttributeId },
+ include: {
+ translations: true,
+ attributeDefinition: { include: { category: true, translations: true } },
+ },
+ });
+
+ return { success: true, attribute: updated, translations: results };
+ } catch (err: any) {
+ return status(400, { message: err.message });
+ }
+ }, {
+ body: t.Any(),
+ });
diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts
index 2231551..d6fd9bb 100644
--- a/apps/backend/src/routes/products.ts
+++ b/apps/backend/src/routes/products.ts
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
+import { localizeProductAttribute } from "../lib/productAttributes";
export const productsRoute = new Elysia({ prefix: "/products" })
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
@@ -109,6 +110,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
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 } }
@@ -124,26 +140,34 @@ export const productsRoute = new Elysia({ prefix: "/products" })
]);
// Map translations
- const mappedProducts = products.map(p => {
+ const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
- p.translations.forEach(t => {
+ 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 } }; // Actually for 'all', keep as is, but wait, category name needs resolving if we want it? Let's just resolve it simply below.
+ 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 => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0];
+ 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 }
+ category: { ...p.category, name: catTrans?.name || p.category.name },
+ attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
@@ -175,6 +199,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
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 } }
@@ -191,21 +230,28 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
- product.translations.forEach(t => {
+ 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 };
+ return {
+ ...product,
+ name: nameObj,
+ description: descObj,
+ details: detailsObj,
+ attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
+ };
} else {
- const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0];
+ 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 }
+ category: { ...product.category, name: catTrans?.name || product.category.name },
+ attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts
index a3c6ecb..77437d4 100644
--- a/apps/mcp-server/index.ts
+++ b/apps/mcp-server/index.ts
@@ -486,6 +486,148 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id", "lang"]
}
},
+ {
+ name: "list_attribute_definitions",
+ description: "List attribute definitions, optionally filtered by category. Returns localized labels unless lang=all.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ categoryId: { type: "number" },
+ lang: { type: "string", description: "Language code (e.g. th, en, all)" }
+ }
+ }
+ },
+ {
+ name: "get_attribute_definition_detail",
+ description: "Get full detail for one attribute definition, including translations and localized option labels.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ id: { type: "number" },
+ lang: { type: "string", description: "Language code (e.g. th, en, all)" }
+ },
+ required: ["id"]
+ }
+ },
+ {
+ name: "create_attribute_definition",
+ description: "Create a reusable product attribute definition. For SELECT, provide localized options as arrays of language maps.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ code: { type: "string" },
+ type: { type: "string", enum: ["TEXT", "NUMBER", "BOOLEAN", "SELECT", "JSON"] },
+ label: { type: "string", description: "Base Thai or fallback label" },
+ description: { type: "string" },
+ unit: { type: "string" },
+ isRequired: { type: "boolean" },
+ isFilterable: { type: "boolean" },
+ sortOrder: { type: "number" },
+ categoryId: { type: "number" },
+ translations: {
+ type: "object",
+ additionalProperties: {
+ type: "object",
+ properties: {
+ label: { type: "string" },
+ description: { type: "string" }
+ }
+ }
+ },
+ options: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: { type: "string" }
+ }
+ }
+ },
+ required: ["code", "type", "label"]
+ }
+ },
+ {
+ name: "update_attribute_definition",
+ description: "Update base metadata for an attribute definition.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ id: { type: "number" },
+ code: { type: "string" },
+ type: { type: "string", enum: ["TEXT", "NUMBER", "BOOLEAN", "SELECT", "JSON"] },
+ label: { type: "string" },
+ description: { type: "string" },
+ unit: { type: "string" },
+ isRequired: { type: "boolean" },
+ isFilterable: { type: "boolean" },
+ sortOrder: { type: "number" },
+ categoryId: { type: "number" },
+ options: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: { type: "string" }
+ }
+ }
+ },
+ required: ["id", "code", "type", "label"]
+ }
+ },
+ {
+ name: "update_attribute_definition_translation",
+ description: "Upsert translated label/description for an attribute definition.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ id: { type: "number" },
+ lang: { type: "string" },
+ label: { type: "string" },
+ description: { type: "string" }
+ },
+ required: ["id", "lang", "label"]
+ }
+ },
+ {
+ name: "list_product_attributes",
+ description: "List localized product attributes for a product.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ productId: { type: "number" },
+ lang: { type: "string", description: "Language code (e.g. th, en, all)" }
+ },
+ required: ["productId"]
+ }
+ },
+ {
+ name: "set_product_attribute",
+ description: "Create or update a product attribute base value. Only TEXT attributes support translations separately. SELECT values must match the base Thai option value.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ productId: { type: "number" },
+ attributeDefinitionId: { type: "number" },
+ valueText: { type: "string" },
+ valueNumber: { type: "number" },
+ valueBoolean: { type: "boolean" },
+ valueJson: { type: "object" }
+ },
+ required: ["productId", "attributeDefinitionId"]
+ }
+ },
+ {
+ name: "update_product_attribute_translation",
+ description: "Upsert translated valueText for a TEXT product attribute only. Fallback is requested lang -> th -> base valueText.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ productId: { type: "number" },
+ productAttributeId: { type: "number" },
+ lang: { type: "string" },
+ valueText: { type: "string" }
+ },
+ required: ["productId", "productAttributeId", "lang", "valueText"]
+ }
+ },
{
name: "upload_product_image_from_url",
description: "Download image from URL, upload to backend, and append to product images",
@@ -883,6 +1025,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
};
}
+ case "list_attribute_definitions": {
+ const categoryId = params.categoryId ? `categoryId=${params.categoryId}&` : "";
+ const lang = params.lang ? `lang=${params.lang}` : "lang=th";
+ const res = await api.get(`/product-attributes/definitions?${categoryId}${lang}`);
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "get_attribute_definition_detail": {
+ const lang = params.lang ? `?lang=${params.lang}` : "";
+ const res = await api.get(`/product-attributes/definitions/${params.id}${lang}`);
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "create_attribute_definition": {
+ const res = await api.post(`/product-attributes/definitions`, params);
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "update_attribute_definition": {
+ const { id, ...payload } = params as any;
+ const res = await api.put(`/product-attributes/definitions/${id}`, payload);
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "update_attribute_definition_translation": {
+ const { id, lang, label, description } = params as any;
+ const res = await api.patch(`/product-attributes/definitions/${id}/translations`, {
+ translations: {
+ [lang]: { label, description }
+ }
+ });
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "list_product_attributes": {
+ const lang = params.lang ? `?lang=${params.lang}` : "";
+ const res = await api.get(`/products/${params.productId}/attributes${lang}`);
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "set_product_attribute": {
+ const { productId, ...payload } = params as any;
+ const res = await api.post(`/products/${productId}/attributes`, payload);
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
+ case "update_product_attribute_translation": {
+ const { productId, productAttributeId, lang, valueText } = params as any;
+ const res = await api.patch(`/products/${productId}/attributes/${productAttributeId}/translations`, {
+ translations: {
+ [lang]: valueText
+ }
+ });
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+ }
case "upload_product_image_from_url": {
// 1. Download image
const imgRes = await axios.get(params.imageUrl, { responseType: 'arraybuffer' });
diff --git a/apps/spa/src/pages/ProductDetailPage.tsx b/apps/spa/src/pages/ProductDetailPage.tsx
index d13278c..3d1468c 100644
--- a/apps/spa/src/pages/ProductDetailPage.tsx
+++ b/apps/spa/src/pages/ProductDetailPage.tsx
@@ -7,7 +7,7 @@ import ProductCard from '../components/ProductCard'
import ProductReviews from '../components/ProductReviews'
import ProductImageGallery from '../components/ProductImageGallery'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
-import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type TranslatableString } from '../types'
+import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type TranslatableString } from '../types'
import { useCartStore } from '../store/cartStore'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
@@ -38,6 +38,18 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
return val;
}
+function formatAttributeValue(attribute: ProductAttribute): string {
+ const value = attribute.value
+ if (value === null || value === undefined || value === '') return ''
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
+ if (typeof value === 'number') {
+ const unit = attribute.attributeDefinition.unit ? ` ${attribute.attributeDefinition.unit}` : ''
+ return `${value}${unit}`
+ }
+ if (typeof value === 'string') return value
+ return JSON.stringify(value)
+}
+
export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -143,6 +155,7 @@ export default function ProductDetailPage() {
}
const currentVariant = product.variants[selectedVariant]
+ const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
const handleAddToCart = () => {
if (!product || !currentVariant) return
@@ -226,6 +239,28 @@ export default function ProductDetailPage() {
)}
+ {visibleAttributes.length > 0 && (
+
+
+
Product Specs
+
Specifications
+
+
+ {visibleAttributes.map((attribute) => {
+ const label = typeof attribute.attributeDefinition.label === 'string'
+ ? attribute.attributeDefinition.label
+ : resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
+ return (
+
+ {label}
+ {formatAttributeValue(attribute)}
+
+ )
+ })}
+
+
+ )}
+
{/* Variant Selector */}
diff --git a/apps/website/src/app/[lang]/products/[id]/page.tsx b/apps/website/src/app/[lang]/products/[id]/page.tsx
index a17ccb5..68e1ff0 100644
--- a/apps/website/src/app/[lang]/products/[id]/page.tsx
+++ b/apps/website/src/app/[lang]/products/[id]/page.tsx
@@ -10,6 +10,19 @@ import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
+import type { ProductAttribute } from '@homaroma/types'
+
+function formatAttributeValue(attribute: ProductAttribute): string {
+ const value = attribute.value
+ if (value === null || value === undefined || value === '') return ''
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
+ if (typeof value === 'number') {
+ const unit = attribute.attributeDefinition.unit ? ` ${attribute.attributeDefinition.unit}` : ''
+ return `${value}${unit}`
+ }
+ if (typeof value === 'string') return value
+ return JSON.stringify(value)
+}
async function getRelated(categoryId: number, excludeId: number, lang: string): Promise {
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
@@ -80,6 +93,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const categoryName = resolveCategoryName(product.category, lang)
const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
+ const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
return (
@@ -128,6 +142,25 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
)}
+ {visibleAttributes.length > 0 && (
+
+ Specifications
+
+ {visibleAttributes.map((attribute) => {
+ const label = typeof attribute.attributeDefinition.label === 'string'
+ ? attribute.attributeDefinition.label
+ : resolveLocalized(attribute.attributeDefinition.label, lang)
+ return (
+
+ {label}
+ {formatAttributeValue(attribute)}
+
+ )
+ })}
+
+
+ )}
+
{related.length > 0 && (
diff --git a/packages/types/index.ts b/packages/types/index.ts
index dd8b36a..8c95eed 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -51,6 +51,51 @@ export interface ProductImage {
export type TranslatableString = string | Record;
+export interface AttributeDefinitionTranslation {
+ id: number
+ attributeDefinitionId: number
+ langCode: string
+ label: string
+ description?: string | null
+}
+
+export interface AttributeDefinition {
+ id: number
+ code: string
+ label: TranslatableString
+ description?: TranslatableString | null
+ type: 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'JSON'
+ unit?: string | null
+ options?: TranslatableString[] | null
+ isRequired: boolean
+ isFilterable: boolean
+ sortOrder: number
+ categoryId?: number | null
+ category?: Category | null
+ translations?: AttributeDefinitionTranslation[]
+}
+
+export interface ProductAttributeTranslation {
+ id: number
+ productAttributeId: number
+ langCode: string
+ valueText: string
+}
+
+export interface ProductAttribute {
+ id: number
+ productId: number
+ attributeDefinitionId: number
+ attributeDefinition: AttributeDefinition
+ valueText?: string | null
+ valueNumber?: number | null
+ valueBoolean?: boolean | null
+ valueJson?: any | null
+ value?: string | number | boolean | Record | null
+ valueTranslations?: Record
+ translations?: ProductAttributeTranslation[]
+}
+
export interface Product {
id: number
name: TranslatableString
@@ -63,6 +108,7 @@ export interface Product {
options?: Array<{ name: TranslatableString; values: TranslatableString[] }> | null
variants: Variant[]
images: ProductImage[]
+ attributes?: ProductAttribute[]
}
const DEFAULT_DEV_API_PORT = 3051