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
+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('آیتم یافت نشد');
}
}
}