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:
co-authored by
Cursor
parent
15dd3597f0
commit
835c8a0ac1
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('آیتم یافت نشد');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user