Add public catalog APIs, website slots, featured slots, and int category/product IDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:36 +03:30
co-authored by Cursor
parent 15dd3597f0
commit 835c8a0ac1
25 changed files with 1399 additions and 41 deletions
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "technicalFields" JSONB NOT NULL DEFAULT '[]';
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "technicalData" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "WebsiteSlotItem" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"slotKey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"nameFa" TEXT NOT NULL DEFAULT '',
"nameEn" TEXT NOT NULL DEFAULT '',
"subtext" TEXT NOT NULL DEFAULT '',
"linkText" TEXT NOT NULL DEFAULT '',
"link" TEXT NOT NULL DEFAULT '',
"imageUrl" TEXT,
"imageKey" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebsiteSlotItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WebsiteSlotItem_storeId_idx" ON "WebsiteSlotItem"("storeId");
-- CreateIndex
CREATE INDEX "WebsiteSlotItem_storeId_slotKey_idx" ON "WebsiteSlotItem"("storeId", "slotKey");
-- CreateIndex
CREATE INDEX "WebsiteSlotItem_storeId_slotKey_sortOrder_idx" ON "WebsiteSlotItem"("storeId", "slotKey", "sortOrder");
-- AddForeignKey
ALTER TABLE "WebsiteSlotItem" ADD CONSTRAINT "WebsiteSlotItem_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "featuredSlot" TEXT;
-- CreateIndex
CREATE INDEX "Product_featuredSlot_idx" ON "Product"("featuredSlot");
@@ -0,0 +1,92 @@
-- Convert Category.id (cuid text) → serial integer; remap Product.categoryId + parentId.
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_categoryId_fkey";
ALTER TABLE "Category" DROP CONSTRAINT IF EXISTS "Category_parentId_fkey";
ALTER TABLE "Category" DROP CONSTRAINT IF EXISTS "Category_storeId_fkey";
DROP INDEX IF EXISTS "Category_storeId_idx";
DROP INDEX IF EXISTS "Category_parentId_idx";
DROP INDEX IF EXISTS "Category_sortOrder_idx";
DROP INDEX IF EXISTS "Product_categoryId_idx";
CREATE TABLE "_category_id_map" (
"old_id" TEXT PRIMARY KEY,
"new_id" INTEGER NOT NULL UNIQUE
);
INSERT INTO "_category_id_map" ("old_id", "new_id")
SELECT "id", ROW_NUMBER() OVER (ORDER BY "id")::INTEGER
FROM "Category";
CREATE TABLE "Category_new" (
"id" INTEGER NOT NULL,
"storeId" TEXT NOT NULL,
"parentId" INTEGER,
"nameFa" TEXT NOT NULL,
"nameEn" TEXT NOT NULL DEFAULT '',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"colors" TEXT[] DEFAULT ARRAY[]::TEXT[],
"sizes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"packages" TEXT[] DEFAULT ARRAY[]::TEXT[],
"technicalFields" JSONB NOT NULL DEFAULT '[]',
CONSTRAINT "Category_new_pkey" PRIMARY KEY ("id")
);
INSERT INTO "Category_new" (
"id", "storeId", "parentId", "nameFa", "nameEn", "sortOrder",
"colors", "sizes", "packages", "technicalFields"
)
SELECT
m."new_id",
c."storeId",
mp."new_id",
c."nameFa",
c."nameEn",
c."sortOrder",
c."colors",
c."sizes",
c."packages",
COALESCE(c."technicalFields", '[]'::jsonb)
FROM "Category" c
JOIN "_category_id_map" m ON m."old_id" = c."id"
LEFT JOIN "_category_id_map" mp ON mp."old_id" = c."parentId";
ALTER TABLE "Product" ADD COLUMN "categoryId_new" INTEGER;
UPDATE "Product" AS p
SET "categoryId_new" = m."new_id"
FROM "_category_id_map" AS m
WHERE p."categoryId" = m."old_id";
ALTER TABLE "Product" DROP COLUMN "categoryId";
ALTER TABLE "Product" RENAME COLUMN "categoryId_new" TO "categoryId";
DROP TABLE "Category";
ALTER TABLE "Category_new" RENAME TO "Category";
ALTER TABLE "Category" RENAME CONSTRAINT "Category_new_pkey" TO "Category_pkey";
CREATE SEQUENCE "Category_id_seq" OWNED BY "Category"."id";
SELECT setval(
'"Category_id_seq"',
COALESCE((SELECT MAX("id") FROM "Category"), 1)
);
ALTER TABLE "Category" ALTER COLUMN "id" SET DEFAULT nextval('"Category_id_seq"');
CREATE INDEX "Category_storeId_idx" ON "Category"("storeId");
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder");
CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId");
ALTER TABLE "Category"
ADD CONSTRAINT "Category_storeId_fkey"
FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Category"
ADD CONSTRAINT "Category_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Product"
ADD CONSTRAINT "Product_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
DROP TABLE "_category_id_map";
@@ -0,0 +1,154 @@
-- Convert Product.id (cuid text) → serial integer; remap ProductVariant + OrderItem FKs.
ALTER TABLE "ProductVariant" DROP CONSTRAINT IF EXISTS "ProductVariant_productId_fkey";
ALTER TABLE "OrderItem" DROP CONSTRAINT IF EXISTS "OrderItem_productId_fkey";
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_storeId_fkey";
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_categoryId_fkey";
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_brandId_fkey";
DROP INDEX IF EXISTS "Product_storeId_idx";
DROP INDEX IF EXISTS "Product_categoryId_idx";
DROP INDEX IF EXISTS "Product_brandId_idx";
DROP INDEX IF EXISTS "Product_price_idx";
DROP INDEX IF EXISTS "Product_published_idx";
DROP INDEX IF EXISTS "Product_featuredSlot_idx";
DROP INDEX IF EXISTS "Product_vipEnabled_idx";
DROP INDEX IF EXISTS "Product_vipOnly_idx";
DROP INDEX IF EXISTS "ProductVariant_productId_idx";
DROP INDEX IF EXISTS "OrderItem_productId_idx";
CREATE TABLE "_product_id_map" (
"old_id" TEXT PRIMARY KEY,
"new_id" INTEGER NOT NULL UNIQUE
);
INSERT INTO "_product_id_map" ("old_id", "new_id")
SELECT "id", ROW_NUMBER() OVER (ORDER BY "createdAt", "id")::INTEGER
FROM "Product";
CREATE TABLE "Product_new" (
"id" INTEGER NOT NULL,
"storeId" TEXT NOT NULL,
"categoryId" INTEGER,
"brandId" TEXT,
"nameFa" TEXT NOT NULL,
"nameEn" TEXT NOT NULL DEFAULT '',
"summary" TEXT NOT NULL DEFAULT '',
"description" TEXT NOT NULL DEFAULT '',
"price" INTEGER NOT NULL DEFAULT 0,
"discountPrice" INTEGER,
"imageUrl" TEXT,
"imageKey" TEXT,
"galleryUrls" TEXT[] DEFAULT ARRAY[]::TEXT[],
"galleryKeys" TEXT[] DEFAULT ARRAY[]::TEXT[],
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"published" BOOLEAN NOT NULL DEFAULT true,
"featuredSlot" TEXT,
"vipEnabled" BOOLEAN NOT NULL DEFAULT false,
"vipOnly" BOOLEAN NOT NULL DEFAULT false,
"vipSpecialPrice" BOOLEAN NOT NULL DEFAULT false,
"vipPrice" INTEGER,
"technicalData" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_new_pkey" PRIMARY KEY ("id")
);
INSERT INTO "Product_new" (
"id", "storeId", "categoryId", "brandId", "nameFa", "nameEn", "summary", "description",
"price", "discountPrice", "imageUrl", "imageKey", "galleryUrls", "galleryKeys", "tags",
"published", "featuredSlot", "vipEnabled", "vipOnly", "vipSpecialPrice", "vipPrice",
"technicalData", "createdAt", "updatedAt"
)
SELECT
m."new_id",
p."storeId",
p."categoryId",
p."brandId",
p."nameFa",
p."nameEn",
p."summary",
p."description",
p."price",
p."discountPrice",
p."imageUrl",
p."imageKey",
p."galleryUrls",
p."galleryKeys",
p."tags",
p."published",
p."featuredSlot",
p."vipEnabled",
p."vipOnly",
p."vipSpecialPrice",
p."vipPrice",
COALESCE(p."technicalData", '{}'::jsonb),
p."createdAt",
p."updatedAt"
FROM "Product" p
JOIN "_product_id_map" m ON m."old_id" = p."id";
ALTER TABLE "ProductVariant" ADD COLUMN "productId_new" INTEGER;
UPDATE "ProductVariant" AS v
SET "productId_new" = m."new_id"
FROM "_product_id_map" AS m
WHERE v."productId" = m."old_id";
ALTER TABLE "ProductVariant" DROP COLUMN "productId";
ALTER TABLE "ProductVariant" RENAME COLUMN "productId_new" TO "productId";
ALTER TABLE "ProductVariant" ALTER COLUMN "productId" SET NOT NULL;
ALTER TABLE "OrderItem" ADD COLUMN "productId_new" INTEGER;
UPDATE "OrderItem" AS oi
SET "productId_new" = m."new_id"
FROM "_product_id_map" AS m
WHERE oi."productId" = m."old_id";
ALTER TABLE "OrderItem" DROP COLUMN "productId";
ALTER TABLE "OrderItem" RENAME COLUMN "productId_new" TO "productId";
DROP TABLE "Product";
ALTER TABLE "Product_new" RENAME TO "Product";
ALTER TABLE "Product" RENAME CONSTRAINT "Product_new_pkey" TO "Product_pkey";
CREATE SEQUENCE "Product_id_seq" OWNED BY "Product"."id";
SELECT setval(
'"Product_id_seq"',
COALESCE((SELECT MAX("id") FROM "Product"), 1)
);
ALTER TABLE "Product" ALTER COLUMN "id" SET DEFAULT nextval('"Product_id_seq"');
CREATE INDEX "Product_storeId_idx" ON "Product"("storeId");
CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId");
CREATE INDEX "Product_brandId_idx" ON "Product"("brandId");
CREATE INDEX "Product_price_idx" ON "Product"("price");
CREATE INDEX "Product_published_idx" ON "Product"("published");
CREATE INDEX "Product_featuredSlot_idx" ON "Product"("featuredSlot");
CREATE INDEX "Product_vipEnabled_idx" ON "Product"("vipEnabled");
CREATE INDEX "Product_vipOnly_idx" ON "Product"("vipOnly");
CREATE INDEX "ProductVariant_productId_idx" ON "ProductVariant"("productId");
CREATE INDEX "OrderItem_productId_idx" ON "OrderItem"("productId");
ALTER TABLE "Product"
ADD CONSTRAINT "Product_storeId_fkey"
FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Product"
ADD CONSTRAINT "Product_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Product"
ADD CONSTRAINT "Product_brandId_fkey"
FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProductVariant"
ADD CONSTRAINT "ProductVariant_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrderItem"
ADD CONSTRAINT "OrderItem_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
DROP TABLE "_product_id_map";
+41 -10
View File
@@ -131,24 +131,27 @@ model Store {
blogs BlogPost[]
orderItems OrderItem[]
fulfillments OrderStoreFulfillment[]
websiteSlots WebsiteSlotItem[]
@@index([status])
}
model Category {
id String @id @default(cuid())
id Int @id @default(autoincrement())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
parentId String?
parentId Int?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
nameFa String
nameEn String @default("")
sortOrder Int @default(0)
colors String[] @default([])
sizes String[] @default([])
packages String[] @default([])
products Product[]
colors String[] @default([])
sizes String[] @default([])
packages String[] @default([])
/// Ordered form-builder defs: [{ id, labelFa, labelEn?, type: "text", required? }]
technicalFields Json @default("[]")
products Product[]
@@index([storeId])
@@index([parentId])
@@ -169,10 +172,10 @@ model Brand {
}
model Product {
id String @id @default(cuid())
id Int @id @default(autoincrement())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
categoryId String?
categoryId Int?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
brandId String?
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
@@ -188,10 +191,14 @@ model Product {
galleryKeys String[] @default([])
tags String[] @default([])
published Boolean @default(true)
/// Home carousel featured slot: promo-1 | promo-2 | promo-3
featuredSlot String?
vipEnabled Boolean @default(false)
vipOnly Boolean @default(false)
vipSpecialPrice Boolean @default(false)
vipPrice Int?
/// Values keyed by category technical field id: { [fieldId]: string }
technicalData Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
variants ProductVariant[]
@@ -202,13 +209,14 @@ model Product {
@@index([brandId])
@@index([price])
@@index([published])
@@index([featuredSlot])
@@index([vipEnabled])
@@index([vipOnly])
}
model ProductVariant {
id String @id @default(cuid())
productId String
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
colorId String @default("")
sizeId String @default("")
@@ -289,7 +297,7 @@ model OrderItem {
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
productId String?
productId Int?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
nameFa String
variantLabel String?
@@ -313,3 +321,26 @@ model OrderStoreFulfillment {
@@index([storeId])
@@index([status])
}
/// Homepage / storefront image slots (hero slides, banners, category thumbs, …)
model WebsiteSlotItem {
id String @id @default(cuid())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
/// e.g. home.hero | home.category.shirts | home.banner.leather
slotKey String
sortOrder Int @default(0)
nameFa String @default("")
nameEn String @default("")
subtext String @default("")
linkText String @default("")
link String @default("")
imageUrl String?
imageKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([storeId])
@@index([storeId, slotKey])
@@index([storeId, slotKey, sortOrder])
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Add subcategories under remaining women categories + men clothes.
* Run: npx tsx scripts/fill-category-children.ts
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const SUBS: Record<string, Array<[string, string]>> = {
// پالتو و کت
cmujwdqxd0001q9g7gbkdzp90: [
["پالتو بلند", "Long Coat"],
["کت کوتاه", "Short Coat"],
["پالتو فوتر", "Wool Coat"],
],
// پیراهن
cmujwdqxh0003q9g7ywj3xtkg: [
["پیراهن کوتاه", "Short Dress"],
["پیراهن بلند", "Long Dress"],
["پیراهن مجلسی", "Party Dress"],
],
// شومیز
cmujwdqxj0005q9g7dmrujmar: [
["شومیز رسمی", "Formal Blouse"],
["شومیز روزانه", "Casual Blouse"],
["شومیز آستین کوتاه", "Short Sleeve"],
],
// تی‌شرت
cmujwdqxl0007q9g72fa9i1u6: [
["تی‌شرت ساده", "Basic Tee"],
["تی‌شرت طرح‌دار", "Printed Tee"],
["تی‌شرت یقه‌دار", "Collar Tee"],
],
// دامن
cmujwdqxm0009q9g7dygpixfl: [
["دامن کوتاه", "Mini Skirt"],
["دامن متوسط", "Midi Skirt"],
["دامن بلند", "Maxi Skirt"],
],
// لباس مجلسی
cmujwdqxn000bq9g769r3oaq2: [
["شب", "Evening"],
["عروس", "Bridal"],
["مهمانی", "Party"],
],
// کت و شلوار
cmujwdqxo000dq9g7syqycf39: [
["رسمی", "Formal"],
["نیمه‌رسمی", "Semi Formal"],
["کت تک", "Blazer"],
],
// کیف
cmujwdqxp000fq9g7pww4264v: [
["کیف دستی", "Handbag"],
["کیف دوشی", "Shoulder Bag"],
["کیف پول", "Wallet"],
],
// عینک
cmujwdqxq000hq9g7hsyx1c6p: [
["آفتابی", "Sunglasses"],
["طبی", "Optical"],
],
// جوراب
cmujwdqxr000jq9g7kesb73gy: [
["کوتاه", "Ankle"],
["بلند", "Knee High"],
["ورزشی", "Sport"],
],
};
const MEN_ID = "cmtrlp2zu000dqmyewzr566tk";
const MEN_CHILDREN: Array<[string, string]> = [
["پیراهن", "Shirt"],
["شلوار", "Pants"],
["کت", "Jacket"],
["تی‌شرت", "T-Shirt"],
["کت و شلوار", "Suit"],
];
async function ensureChild(
storeId: string,
parentId: string,
nameFa: string,
nameEn: string,
sortOrder: number,
) {
const existing = await prisma.category.findFirst({
where: { storeId, parentId, nameFa },
});
if (existing) {
return prisma.category.update({
where: { id: existing.id },
data: { nameEn, sortOrder },
});
}
return prisma.category.create({
data: { storeId, parentId, nameFa, nameEn, sortOrder },
});
}
async function main() {
for (const [parentId, children] of Object.entries(SUBS)) {
const parent = await prisma.category.findUnique({ where: { id: parentId } });
if (!parent) {
console.warn("skip missing", parentId);
continue;
}
for (let i = 0; i < children.length; i++) {
const [nameFa, nameEn] = children[i]!;
await ensureChild(parent.storeId, parentId, nameFa, nameEn, i);
}
console.log(`${parent.nameFa}: +${children.length}`);
}
const men = await prisma.category.findUnique({ where: { id: MEN_ID } });
if (men) {
for (let i = 0; i < MEN_CHILDREN.length; i++) {
const [nameFa, nameEn] = MEN_CHILDREN[i]!;
await ensureChild(men.storeId, MEN_ID, nameFa, nameEn, i);
}
console.log(`لباس مردانه: +${MEN_CHILDREN.length}`);
}
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+113
View File
@@ -0,0 +1,113 @@
/**
* One-off: fill Women Clothes category tree for Mana Collection.
* Run: npx tsx scripts/fill-women-categories.ts
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const WOMEN_ID = "cmtrlmy1r0003qmyebijmqbqt";
const PANTS_ID = "cmtrlo4dn0009qmye6ere4659";
const MANTEL_ID = "cmtrln6d80005qmye97lzhc01";
const JEANS_ID = "cmtrlonpc000bqmyexzvqd500";
async function ensureChild(
storeId: string,
parentId: string,
nameFa: string,
nameEn: string,
sortOrder: number,
) {
const existing = await prisma.category.findFirst({
where: { storeId, parentId, nameFa },
});
if (existing) {
return prisma.category.update({
where: { id: existing.id },
data: { nameEn, sortOrder },
});
}
return prisma.category.create({
data: { storeId, parentId, nameFa, nameEn, sortOrder },
});
}
async function main() {
const women = await prisma.category.findUnique({ where: { id: WOMEN_ID } });
if (!women) throw new Error("Women category not found");
const { storeId } = women;
await prisma.category.update({
where: { id: WOMEN_ID },
data: { nameEn: "Women Clothes", sortOrder: 0 },
});
const tops: Array<{
nameFa: string;
nameEn: string;
sortOrder: number;
id?: string;
}> = [
{ nameFa: "شلوار", nameEn: "Pants", sortOrder: 0, id: PANTS_ID },
{ nameFa: "مانتو", nameEn: "Mantel", sortOrder: 1, id: MANTEL_ID },
{ nameFa: "پالتو و کت", nameEn: "Coats", sortOrder: 2 },
{ nameFa: "پیراهن", nameEn: "Dress", sortOrder: 3 },
{ nameFa: "شومیز", nameEn: "Blouse", sortOrder: 4 },
{ nameFa: "تی‌شرت", nameEn: "T-Shirt", sortOrder: 5 },
{ nameFa: "دامن", nameEn: "Skirt", sortOrder: 6 },
{ nameFa: "لباس مجلسی", nameEn: "Formal Dress", sortOrder: 7 },
{ nameFa: "کت و شلوار", nameEn: "Suit", sortOrder: 8 },
{ nameFa: "کیف", nameEn: "Bag", sortOrder: 9 },
{ nameFa: "عینک", nameEn: "Glasses", sortOrder: 10 },
{ nameFa: "جوراب", nameEn: "Socks", sortOrder: 11 },
];
for (const row of tops) {
if (row.id) {
await prisma.category.update({
where: { id: row.id },
data: {
nameFa: row.nameFa,
nameEn: row.nameEn,
sortOrder: row.sortOrder,
},
});
} else {
await ensureChild(
storeId,
WOMEN_ID,
row.nameFa,
row.nameEn,
row.sortOrder,
);
}
}
await prisma.category.update({
where: { id: JEANS_ID },
data: { nameFa: "شلوار جین", nameEn: "Jean Pants", sortOrder: 0 },
});
await ensureChild(storeId, PANTS_ID, "شلوار پارچه‌ای", "Fabric Pants", 1);
await ensureChild(storeId, PANTS_ID, "شلوارک", "Shorts", 2);
await ensureChild(storeId, MANTEL_ID, "بهاره", "Spring", 0);
await ensureChild(storeId, MANTEL_ID, "پاییزه", "Autumn", 1);
await ensureChild(storeId, MANTEL_ID, "مجلسی", "Formal", 2);
const children = await prisma.category.findMany({
where: { parentId: WOMEN_ID },
orderBy: { sortOrder: "asc" },
select: { nameFa: true, nameEn: true, sortOrder: true },
});
console.log("Women children:", children.length);
for (const c of children) {
console.log(` ${c.sortOrder}. ${c.nameFa} / ${c.nameEn}`);
}
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+2
View File
@@ -16,6 +16,7 @@ import { PublicModule } from './public/public.module';
import { SmsModule } from './sms/sms.module';
import { StorageModule } from './storage/storage.module';
import { StoresModule } from './stores/stores.module';
import { WebsiteSlotsModule } from './website-slots/website-slots.module';
@Module({
imports: [
@@ -36,6 +37,7 @@ import { StoresModule } from './stores/stores.module';
CustomersModule,
OrdersModule,
ProfileModule,
WebsiteSlotsModule,
],
})
export class AppModule {}
+6 -2
View File
@@ -4,6 +4,7 @@ import {
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
@@ -40,14 +41,17 @@ export class CategoriesController {
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateCategoryDto,
) {
return this.categories.update(user, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.categories.remove(user, id);
}
}
+18 -8
View File
@@ -1,5 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { Prisma, UserRole } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { StoreScopeService } from '../common/store-scope.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -42,11 +42,13 @@ export class CategoriesService {
colors: dto.colors ?? [],
sizes: dto.sizes ?? [],
packages: dto.packages ?? [],
technicalFields: (dto.technicalFields ??
[]) as unknown as Prisma.InputJsonValue,
},
});
}
async update(actor: AuthUser, id: string, dto: UpdateCategoryDto) {
async update(actor: AuthUser, id: number, dto: UpdateCategoryDto) {
const existing = await this.prisma.category.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('دسته‌بندی یافت نشد');
@@ -63,11 +65,17 @@ export class CategoriesService {
...(dto.colors !== undefined ? { colors: dto.colors } : {}),
...(dto.sizes !== undefined ? { sizes: dto.sizes } : {}),
...(dto.packages !== undefined ? { packages: dto.packages } : {}),
...(dto.technicalFields !== undefined
? {
technicalFields:
dto.technicalFields as unknown as Prisma.InputJsonValue,
}
: {}),
},
});
}
async remove(actor: AuthUser, id: string) {
async remove(actor: AuthUser, id: number) {
const existing = await this.prisma.category.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('دسته‌بندی یافت نشد');
@@ -77,10 +85,10 @@ export class CategoriesService {
return { ok: true };
}
private buildTree<
T extends { id: string; parentId: string | null },
>(items: T[]) {
const byParent = new Map<string | null, T[]>();
private buildTree<T extends { id: number; parentId: number | null }>(
items: T[],
) {
const byParent = new Map<number | null, T[]>();
for (const item of items) {
const key = item.parentId;
const list = byParent.get(key) ?? [];
@@ -88,7 +96,9 @@ export class CategoriesService {
byParent.set(key, list);
}
const walk = (parentId: string | null): Array<T & { children: unknown[] }> =>
const walk = (
parentId: number | null,
): Array<T & { children: unknown[] }> =>
(byParent.get(parentId) ?? []).map((node) => ({
...node,
children: walk(node.id),
+43 -4
View File
@@ -1,12 +1,37 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export class TechnicalFieldDto {
@IsString()
@MinLength(1)
id!: string;
@IsString()
@MinLength(1)
labelFa!: string;
@IsOptional()
@IsString()
labelEn?: string;
@IsIn(['text'])
type!: 'text';
@IsOptional()
@IsBoolean()
required?: boolean;
}
export class CreateCategoryDto {
@IsString()
@MinLength(1)
@@ -17,8 +42,9 @@ export class CreateCategoryDto {
nameEn?: string;
@IsOptional()
@IsString()
parentId?: string;
@Type(() => Number)
@IsInt()
parentId?: number;
@IsOptional()
@IsInt()
@@ -40,6 +66,12 @@ export class CreateCategoryDto {
@IsString({ each: true })
packages?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TechnicalFieldDto)
technicalFields?: TechnicalFieldDto[];
@IsOptional()
@IsString()
storeId?: string;
@@ -56,8 +88,9 @@ export class UpdateCategoryDto {
nameEn?: string;
@IsOptional()
@IsString()
parentId?: string | null;
@Type(() => Number)
@IsInt()
parentId?: number | null;
@IsOptional()
@IsInt()
@@ -78,4 +111,10 @@ export class UpdateCategoryDto {
@IsArray()
@IsString({ each: true })
packages?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TechnicalFieldDto)
technicalFields?: TechnicalFieldDto[];
}
+1
View File
@@ -7,6 +7,7 @@ export const MEDIA_KINDS = [
'brand',
'store',
'avatar',
'website',
'temp',
] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
+3
View File
@@ -14,6 +14,7 @@ import {
storeMediaKey,
tempMediaKey,
userMediaKey,
websiteMediaKey,
} from '../storage/storage-keys';
import { StorageService } from '../storage/storage.service';
import { MediaKind } from './dto/media-upload-query.dto';
@@ -85,6 +86,8 @@ export class MediaService {
return storeMediaKey(fileName);
case 'avatar':
return userMediaKey(fileName);
case 'website':
return websiteMediaKey(fileName);
case 'temp':
return tempMediaKey(fileName);
default:
+23 -4
View File
@@ -3,6 +3,7 @@ import {
IsArray,
IsBoolean,
IsInt,
IsObject,
IsOptional,
IsString,
Min,
@@ -69,8 +70,9 @@ export class CreateProductDto {
discountPrice?: number;
@IsOptional()
@IsString()
categoryId?: string;
@Type(() => Number)
@IsInt()
categoryId?: number;
@IsOptional()
@IsString()
@@ -103,6 +105,10 @@ export class CreateProductDto {
@IsBoolean()
published?: boolean;
@IsOptional()
@IsString()
featuredSlot?: string | null;
@IsOptional()
@IsBoolean()
vipEnabled?: boolean;
@@ -120,6 +126,10 @@ export class CreateProductDto {
@Min(0)
vipPrice?: number;
@IsOptional()
@IsObject()
technicalData?: Record<string, string>;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@@ -161,8 +171,9 @@ export class UpdateProductDto {
discountPrice?: number | null;
@IsOptional()
@IsString()
categoryId?: string | null;
@Type(() => Number)
@IsInt()
categoryId?: number | null;
@IsOptional()
@IsString()
@@ -195,6 +206,10 @@ export class UpdateProductDto {
@IsBoolean()
published?: boolean;
@IsOptional()
@IsString()
featuredSlot?: string | null;
@IsOptional()
@IsBoolean()
vipEnabled?: boolean;
@@ -212,6 +227,10 @@ export class UpdateProductDto {
@Min(0)
vipPrice?: number | null;
@IsOptional()
@IsObject()
technicalData?: Record<string, string>;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
+18 -4
View File
@@ -4,6 +4,7 @@ import {
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
@@ -36,17 +37,27 @@ export class ProductsController {
@Query('minPrice') minPrice?: number,
@Query('maxPrice') maxPrice?: number,
) {
const parsedCategoryId =
categoryId !== undefined && categoryId !== ''
? Number(categoryId)
: undefined;
return this.products.list(user, {
storeId,
name,
categoryId,
categoryId:
parsedCategoryId !== undefined && Number.isFinite(parsedCategoryId)
? parsedCategoryId
: undefined,
minPrice,
maxPrice,
});
}
@Get(':id')
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
findOne(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.products.findOne(user, id);
}
@@ -58,14 +69,17 @@ export class ProductsController {
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateProductDto,
) {
return this.products.update(user, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.products.remove(user, id);
}
}
+16 -4
View File
@@ -20,7 +20,7 @@ export class ProductsService {
query: {
storeId?: string;
name?: string;
categoryId?: string;
categoryId?: number;
minPrice?: number;
maxPrice?: number;
},
@@ -62,7 +62,7 @@ export class ProductsService {
});
}
async findOne(actor: AuthUser, id: string) {
async findOne(actor: AuthUser, id: number) {
const product = await this.prisma.product.findUnique({
where: { id },
include: { variants: true, category: true, brand: true },
@@ -97,10 +97,13 @@ export class ProductsService {
galleryKeys: dto.galleryKeys ?? [],
tags: dto.tags ?? [],
published: dto.published ?? true,
featuredSlot: dto.featuredSlot ?? null,
vipEnabled: dto.vipEnabled ?? false,
vipOnly: dto.vipOnly ?? false,
vipSpecialPrice: dto.vipSpecialPrice ?? false,
vipPrice: dto.vipPrice,
technicalData: (dto.technicalData ??
{}) as unknown as Prisma.InputJsonValue,
variants: dto.variants?.length
? {
create: dto.variants.map((v) => ({
@@ -118,7 +121,7 @@ export class ProductsService {
});
}
async update(actor: AuthUser, id: string, dto: UpdateProductDto) {
async update(actor: AuthUser, id: number, dto: UpdateProductDto) {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('محصول یافت نشد');
@@ -170,6 +173,9 @@ export class ProductsService {
: {}),
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
...(dto.published !== undefined ? { published: dto.published } : {}),
...(dto.featuredSlot !== undefined
? { featuredSlot: dto.featuredSlot }
: {}),
...(dto.vipEnabled !== undefined
? { vipEnabled: dto.vipEnabled }
: {}),
@@ -178,13 +184,19 @@ export class ProductsService {
? { vipSpecialPrice: dto.vipSpecialPrice }
: {}),
...(dto.vipPrice !== undefined ? { vipPrice: dto.vipPrice } : {}),
...(dto.technicalData !== undefined
? {
technicalData:
dto.technicalData as unknown as Prisma.InputJsonValue,
}
: {}),
},
include: { variants: true },
});
});
}
async remove(actor: AuthUser, id: string) {
async remove(actor: AuthUser, id: number) {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('محصول یافت نشد');
+33 -3
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { PublicService } from './public.service';
@Controller('public')
@@ -19,23 +19,53 @@ export class PublicController {
@Query('minPrice') minPrice?: number,
@Query('maxPrice') maxPrice?: number,
) {
const parsedCategoryId =
categoryId !== undefined && categoryId !== ''
? Number(categoryId)
: undefined;
return this.publicService.listProducts({
storeId,
storeSlug,
name,
categoryId,
categoryId:
parsedCategoryId !== undefined && Number.isFinite(parsedCategoryId)
? parsedCategoryId
: undefined,
minPrice,
maxPrice,
});
}
@Get('products/:id')
findProduct(@Param('id') id: string) {
findProduct(@Param('id', ParseIntPipe) id: number) {
return this.publicService.findProduct(id);
}
@Get('categories')
listCategories(@Query('storeSlug') storeSlug?: string) {
return this.publicService.listCategories(storeSlug);
}
@Get('search')
search(
@Query('q') q?: string,
@Query('storeSlug') storeSlug?: string,
@Query('limit') limit?: number,
) {
return this.publicService.search({
q,
storeSlug,
limit: limit !== undefined ? Number(limit) : undefined,
});
}
@Get('blogs')
listBlogs(@Query('storeSlug') storeSlug?: string) {
return this.publicService.listBlogs(storeSlug);
}
@Get('website-slots')
listWebsiteSlots(@Query('storeSlug') storeSlug?: string) {
return this.publicService.listWebsiteSlots(storeSlug);
}
}
+156 -2
View File
@@ -2,6 +2,16 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, StoreStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type PublicCategoryTreeNode = {
id: number;
parentId: number | null;
nameFa: string;
nameEn: string;
sortOrder: number;
imageUrl: string | null;
children: PublicCategoryTreeNode[];
};
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
@@ -25,7 +35,7 @@ export class PublicService {
storeId?: string;
storeSlug?: string;
name?: string;
categoryId?: string;
categoryId?: number;
minPrice?: number;
maxPrice?: number;
}) {
@@ -67,7 +77,7 @@ export class PublicService {
});
}
async findProduct(id: string) {
async findProduct(id: number) {
const product = await this.prisma.product.findFirst({
where: {
id,
@@ -95,6 +105,119 @@ export class PublicService {
return product;
}
async listCategories(storeSlug?: string) {
const store = await this.prisma.store.findFirst({
where: {
status: StoreStatus.active,
...(storeSlug ? { slug: storeSlug } : { slug: 'mana-collection' }),
},
select: { id: true, slug: true },
});
if (!store) {
return { storeSlug: storeSlug ?? 'mana-collection', categories: [] };
}
const categories = await this.prisma.category.findMany({
where: { storeId: store.id },
orderBy: [{ sortOrder: 'asc' }, { nameFa: 'asc' }],
select: {
id: true,
parentId: true,
nameFa: true,
nameEn: true,
sortOrder: true,
products: {
where: { published: true },
take: 1,
orderBy: { updatedAt: 'desc' },
select: { imageUrl: true, galleryUrls: true },
},
},
});
const byParent = new Map<number | null, typeof categories>();
for (const cat of categories) {
const key = cat.parentId;
const list = byParent.get(key) ?? [];
list.push(cat);
byParent.set(key, list);
}
const walk = (parentId: number | null): PublicCategoryTreeNode[] =>
(byParent.get(parentId) ?? []).map((node) => {
const cover =
node.products[0]?.imageUrl ||
node.products[0]?.galleryUrls?.[0] ||
null;
return {
id: node.id,
parentId: node.parentId,
nameFa: node.nameFa,
nameEn: node.nameEn,
sortOrder: node.sortOrder,
imageUrl: cover,
children: walk(node.id),
};
});
return { storeSlug: store.slug, categories: walk(null) };
}
search(query: {
q?: string;
storeSlug?: string;
limit?: number;
}) {
const q = query.q?.trim() ?? '';
if (!q) {
return { products: [], categories: [] };
}
const storeFilter = query.storeSlug
? { store: { slug: query.storeSlug, status: StoreStatus.active } }
: { store: { status: StoreStatus.active } };
const take = Math.min(Math.max(query.limit ?? 12, 1), 40);
return Promise.all([
this.prisma.product.findMany({
where: {
published: true,
...storeFilter,
OR: [
{ nameFa: { contains: q, mode: 'insensitive' } },
{ nameEn: { contains: q, mode: 'insensitive' } },
{ summary: { contains: q, mode: 'insensitive' } },
{ tags: { has: q } },
],
},
take,
orderBy: { updatedAt: 'desc' },
include: {
category: { select: { id: true, nameFa: true, nameEn: true } },
brand: { select: { id: true, nameFa: true } },
},
}),
this.prisma.category.findMany({
where: {
...storeFilter,
OR: [
{ nameFa: { contains: q, mode: 'insensitive' } },
{ nameEn: { contains: q, mode: 'insensitive' } },
],
},
take: 8,
orderBy: { sortOrder: 'asc' },
select: {
id: true,
nameFa: true,
nameEn: true,
parentId: true,
},
}),
]).then(([products, categories]) => ({ products, categories }));
}
listBlogs(storeSlug?: string) {
return this.prisma.blogPost.findMany({
where: {
@@ -110,4 +233,35 @@ export class PublicService {
orderBy: { createdAt: 'desc' },
});
}
async listWebsiteSlots(storeSlug?: string) {
const store = await this.prisma.store.findFirst({
where: {
status: StoreStatus.active,
...(storeSlug ? { slug: storeSlug } : { slug: 'mana-collection' }),
},
select: { id: true, slug: true },
});
if (!store) {
return { storeSlug: storeSlug ?? 'mana-collection', items: [] };
}
const items = await this.prisma.websiteSlotItem.findMany({
where: { storeId: store.id },
orderBy: [{ slotKey: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
select: {
id: true,
slotKey: true,
sortOrder: true,
nameFa: true,
nameEn: true,
subtext: true,
linkText: true,
link: true,
imageUrl: true,
},
});
return { storeSlug: store.slug, items };
}
}
+4
View File
@@ -36,6 +36,10 @@ export function storeMediaKey(fileName: string): string {
return `${ROOT}/stores/${fileName}`;
}
export function websiteMediaKey(fileName: string): string {
return `${ROOT}/website/${fileName}`;
}
export function tempMediaKey(fileName: string): string {
return `${ROOT}/temp/${fileName}`;
}
+85
View File
@@ -0,0 +1,85 @@
import {
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
export class CreateWebsiteSlotItemDto {
@IsString()
@MinLength(1)
slotKey!: string;
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
nameEn?: string;
@IsOptional()
@IsString()
subtext?: string;
@IsOptional()
@IsString()
linkText?: string;
@IsOptional()
@IsString()
link?: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
@IsString()
imageKey?: string;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsString()
storeId?: string;
}
export class UpdateWebsiteSlotItemDto {
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
nameEn?: string;
@IsOptional()
@IsString()
subtext?: string;
@IsOptional()
@IsString()
linkText?: string;
@IsOptional()
@IsString()
link?: string;
@IsOptional()
@IsString()
imageUrl?: string | null;
@IsOptional()
@IsString()
imageKey?: string | null;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}
+174
View File
@@ -0,0 +1,174 @@
export type WebsiteSlotKind = 'collection' | 'fixed' | 'fixed-set';
export type WebsiteSlotDef = {
/** Stable slug used as slotKey in DB */
slug: string;
labelFa: string;
labelEn: string;
kind: WebsiteSlotKind;
/** Group several fixed slots under one admin section */
sectionKey?: string;
sectionLabelFa?: string;
sectionLabelEn?: string;
/** Exact cell count for fixed-set (e.g. 4 portraits) */
itemCount?: number;
/** width / height for upload preview box */
aspectRatio: number;
aspectLabel: string;
/** Optional labels for fixed-set cells (per-cell AR when images differ) */
cellLabels?: Array<{
labelFa: string;
labelEn: string;
aspectRatio?: number;
aspectLabel?: string;
/** Relative preview width in rem (matches on-site scale) */
previewWidthRem?: number;
}>;
};
export const WEBSITE_SLOT_DEFS: WebsiteSlotDef[] = [
{
slug: 'hero-slider',
labelFa: 'اسلایدر',
labelEn: 'Hero slider',
kind: 'collection',
aspectRatio: 1920 / 1080,
aspectLabel: '1920×1080',
},
{
slug: 'category-strip',
labelFa: 'دسته‌بندی‌ها',
labelEn: 'Categories',
kind: 'collection',
aspectRatio: 68 / 80,
aspectLabel: '68×80',
},
{
slug: 'promo-1',
labelFa: 'پرومو ۱',
labelEn: 'Promo 1',
kind: 'fixed',
sectionKey: 'promos',
sectionLabelFa: 'پروموها',
sectionLabelEn: 'Promos',
aspectRatio: 300 / 491,
aspectLabel: '300×491',
},
{
slug: 'promo-2',
labelFa: 'پرومو ۲',
labelEn: 'Promo 2',
kind: 'fixed',
sectionKey: 'promos',
sectionLabelFa: 'پروموها',
sectionLabelEn: 'Promos',
aspectRatio: 300 / 491,
aspectLabel: '300×491',
},
{
slug: 'promo-3',
labelFa: 'پرومو ۳',
labelEn: 'Promo 3',
kind: 'fixed',
sectionKey: 'promos',
sectionLabelFa: 'پروموها',
sectionLabelEn: 'Promos',
aspectRatio: 300 / 491,
aspectLabel: '300×491',
},
{
slug: 'leather-banner',
labelFa: 'بنر چرم',
labelEn: 'Leather banner',
kind: 'fixed',
aspectRatio: 1920 / 585,
aspectLabel: '1920×585',
},
{
slug: 'four-portrait-banner',
labelFa: 'کالکشن مشکی',
labelEn: 'Black collection',
kind: 'fixed-set',
itemCount: 4,
aspectRatio: 410 / 717,
aspectLabel: '410×717',
cellLabels: [
{ labelFa: 'اسلات ۱', labelEn: 'Slot 1' },
{ labelFa: 'اسلات ۲', labelEn: 'Slot 2' },
{ labelFa: 'اسلات ۳', labelEn: 'Slot 3' },
{ labelFa: 'اسلات ۴', labelEn: 'Slot 4' },
],
},
{
slug: 'about',
labelFa: 'درباره ما',
labelEn: 'About',
kind: 'fixed-set',
itemCount: 2,
aspectRatio: 4 / 5,
aspectLabel: '4×5 · 3×4',
cellLabels: [
{
labelFa: 'تصویر اصلی',
labelEn: 'Primary',
aspectRatio: 4 / 5,
aspectLabel: '4×5',
// On site: w-[78%] vs secondary w-[48%] ≈ 1.625×
previewWidthRem: 13,
},
{
labelFa: 'تصویر دوم',
labelEn: 'Secondary',
aspectRatio: 3 / 4,
aspectLabel: '3×4',
previewWidthRem: 8,
},
],
},
{
slug: 'mega-menu',
labelFa: 'مگامنو',
labelEn: 'Mega menu',
kind: 'fixed-set',
itemCount: 6,
aspectRatio: 160 / 220,
aspectLabel: '160×220',
cellLabels: [
{ labelFa: 'پالتو و کت', labelEn: 'Coats' },
{ labelFa: 'پیراهن', labelEn: 'Shirts' },
{ labelFa: 'شلوار', labelEn: 'Pants' },
{ labelFa: 'دامن', labelEn: 'Skirts' },
{ labelFa: 'کیف', labelEn: 'Bags' },
{ labelFa: 'چرم', labelEn: 'Leather' },
],
},
];
export const WEBSITE_SLOT_SLUGS = WEBSITE_SLOT_DEFS.map((d) => d.slug);
export function findSlotDef(slug: string): WebsiteSlotDef | undefined {
return WEBSITE_SLOT_DEFS.find((d) => d.slug === slug);
}
export function isCollectionSlot(slug: string): boolean {
return findSlotDef(slug)?.kind === 'collection';
}
/** @deprecated use isCollectionSlot */
export function isDuplicatableSlot(slug: string): boolean {
return isCollectionSlot(slug);
}
/** Compat: old keys still accepted when reading historical rows */
export const LEGACY_SLOT_ALIASES: Record<string, string> = {
'home.hero': 'hero-slider',
'home.banner.leather': 'leather-banner',
'home.promo.sale': 'promo-1',
'home.promo.autumn': 'promo-3',
'promo-sale': 'promo-1',
'promo-discount': 'promo-2',
'promo-autumn': 'promo-3',
'home.about.primary': 'about-primary',
'home.about.secondary': 'about-secondary',
'home.footer.payment_badges': 'payment-badges',
};
@@ -0,0 +1,59 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import type { AuthUser } from '../auth/auth.types';
import {
CreateWebsiteSlotItemDto,
UpdateWebsiteSlotItemDto,
} from './dto/website-slot.dto';
import { WebsiteSlotsService } from './website-slots.service';
@Controller('website-slots')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
export class WebsiteSlotsController {
constructor(private readonly slots: WebsiteSlotsService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query('storeId') storeId?: string,
) {
return this.slots.list(user, storeId);
}
@Post()
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateWebsiteSlotItemDto,
) {
return this.slots.create(user, dto);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateWebsiteSlotItemDto,
) {
return this.slots.update(user, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.slots.remove(user, id);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StoreScopeService } from '../common/store-scope.service';
import { WebsiteSlotsController } from './website-slots.controller';
import { WebsiteSlotsService } from './website-slots.service';
@Module({
controllers: [WebsiteSlotsController],
providers: [WebsiteSlotsService, StoreScopeService],
exports: [WebsiteSlotsService],
})
export class WebsiteSlotsModule {}
+177
View File
@@ -0,0 +1,177 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { StoreScopeService } from '../common/store-scope.service';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateWebsiteSlotItemDto,
UpdateWebsiteSlotItemDto,
} from './dto/website-slot.dto';
import { WEBSITE_SLOT_DEFS, findSlotDef } from './website-slot.catalog';
@Injectable()
export class WebsiteSlotsService {
constructor(
private readonly prisma: PrismaService,
private readonly storeScope: StoreScopeService,
) {}
async list(actor: AuthUser, storeIdQuery?: string) {
const storeId =
actor.role === UserRole.SUPER_ADMIN
? storeIdQuery
: (await this.storeScope.requireActiveStore(actor)).id;
const items = await this.prisma.websiteSlotItem.findMany({
where: storeId ? { storeId } : undefined,
orderBy: [{ slotKey: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
});
return { catalog: WEBSITE_SLOT_DEFS, items };
}
async create(actor: AuthUser, dto: CreateWebsiteSlotItemDto) {
const def = findSlotDef(dto.slotKey);
if (!def) {
throw new BadRequestException('اسلات نامعتبر است');
}
const store = await this.storeScope.requireActiveStore(
actor,
dto.storeId,
);
if (def.kind === 'fixed') {
const existing = await this.prisma.websiteSlotItem.findFirst({
where: { storeId: store.id, slotKey: dto.slotKey },
});
if (existing) {
return this.prisma.websiteSlotItem.update({
where: { id: existing.id },
data: this.mergeCreateOntoExisting(existing, dto),
});
}
}
if (def.kind === 'fixed-set') {
const max = def.itemCount ?? 1;
const count = await this.prisma.websiteSlotItem.count({
where: { storeId: store.id, slotKey: dto.slotKey },
});
if (count >= max && dto.sortOrder === undefined) {
throw new BadRequestException(
`حداکثر ${max} آیتم برای این اسلات مجاز است`,
);
}
if (dto.sortOrder !== undefined) {
const atIndex = await this.prisma.websiteSlotItem.findFirst({
where: {
storeId: store.id,
slotKey: dto.slotKey,
sortOrder: dto.sortOrder,
},
});
if (atIndex) {
return this.prisma.websiteSlotItem.update({
where: { id: atIndex.id },
data: this.mergeCreateOntoExisting(atIndex, dto),
});
}
}
}
let sortOrder = dto.sortOrder;
if (sortOrder === undefined) {
const last = await this.prisma.websiteSlotItem.findFirst({
where: { storeId: store.id, slotKey: dto.slotKey },
orderBy: { sortOrder: 'desc' },
});
sortOrder = (last?.sortOrder ?? -1) + 1;
}
return this.prisma.websiteSlotItem.create({
data: {
storeId: store.id,
slotKey: dto.slotKey,
sortOrder,
nameFa: dto.nameFa ?? '',
nameEn: dto.nameEn ?? '',
subtext: dto.subtext ?? '',
linkText: dto.linkText ?? '',
link: dto.link ?? '',
imageUrl: dto.imageUrl,
imageKey: dto.imageKey,
},
});
}
async update(actor: AuthUser, id: string, dto: UpdateWebsiteSlotItemDto) {
const existing = await this.prisma.websiteSlotItem.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException('آیتم یافت نشد');
}
await this.assertStoreAccess(actor, existing.storeId);
return this.prisma.websiteSlotItem.update({
where: { id },
data: {
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
...(dto.subtext !== undefined ? { subtext: dto.subtext } : {}),
...(dto.linkText !== undefined ? { linkText: dto.linkText } : {}),
...(dto.link !== undefined ? { link: dto.link } : {}),
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
}
async remove(actor: AuthUser, id: string) {
const existing = await this.prisma.websiteSlotItem.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException('آیتم یافت نشد');
}
await this.assertStoreAccess(actor, existing.storeId);
await this.prisma.websiteSlotItem.delete({ where: { id } });
return { ok: true };
}
private mergeCreateOntoExisting(
existing: {
nameFa: string;
nameEn: string;
subtext: string;
linkText: string;
link: string;
},
dto: CreateWebsiteSlotItemDto,
) {
return {
nameFa: dto.nameFa ?? existing.nameFa,
nameEn: dto.nameEn ?? existing.nameEn,
subtext: dto.subtext ?? existing.subtext,
linkText: dto.linkText ?? existing.linkText,
link: dto.link ?? existing.link,
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
};
}
private async assertStoreAccess(actor: AuthUser, storeId: string) {
if (actor.role === UserRole.SUPER_ADMIN) return;
const store = await this.storeScope.requireActiveStore(actor);
if (store.id !== storeId) {
throw new NotFoundException('آیتم یافت نشد');
}
}
}