diff --git a/prisma/migrations/20260930210000_drop_catalog_entity/migration.sql b/prisma/migrations/20260930210000_drop_catalog_entity/migration.sql new file mode 100644 index 0000000..1beba9a --- /dev/null +++ b/prisma/migrations/20260930210000_drop_catalog_entity/migration.sql @@ -0,0 +1,5 @@ +-- Remove Catalog as independent entity; Product is the sole merchandise unit. +ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_catalogId_fkey"; +DROP INDEX IF EXISTS "Product_catalogId_idx"; +ALTER TABLE "Product" DROP COLUMN IF EXISTS "catalogId"; +DROP TABLE IF EXISTS "Catalog"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 072f738..9f6610e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -143,7 +143,6 @@ model Company { updatedAt DateTime @updatedAt bankAccounts BankAccount[] - catalogs Catalog[] products Product[] orders Order[] customers Customer[] @@ -169,35 +168,16 @@ model BankAccount { @@index([companyId]) } -model Catalog { - id String @id @default(cuid()) - companyId String - name String - code String - description String? - imageUrl String? - displayOrder Int @default(0) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - products Product[] - - @@unique([companyId, code]) - @@index([companyId]) -} - model Product { id String @id @default(cuid()) companyId String - catalogId String name String code String color String description String? minMeterage Float @default(30) orderMultiplier Float @default(10) + /// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور basePrice Float? stock Float? isActive Boolean @default(true) @@ -219,7 +199,6 @@ model Product { updatedAt DateTime @updatedAt company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - catalog Catalog @relation(fields: [catalogId], references: [id], onDelete: Cascade) brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull) cartItems CartItem[] orderItems OrderItem[] @@ -229,7 +208,6 @@ model Product { productSuggestions ProductSuggestion[] @@unique([companyId, code]) - @@index([catalogId]) @@index([companyId]) @@index([brandId]) @@index([usageCategory]) diff --git a/prisma/seed.ts b/prisma/seed.ts index ce0b05c..2673874 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -40,7 +40,6 @@ async function main() { await prisma.otpChallenge.deleteMany(); await prisma.customerNotification.deleteMany(); await prisma.product.deleteMany(); - await prisma.catalog.deleteMany(); await prisma.bankAccount.deleteMany(); await prisma.levelBenefit.deleteMany(); await prisma.customerLevelDef.deleteMany(); @@ -130,24 +129,11 @@ async function main() { ], }); - await prisma.catalog.createMany({ - data: [ - { id: 'cat-377', companyId: 'company-par', name: 'کالیته ۳۷۷', code: '377', displayOrder: 1, isActive: true }, - { id: 'cat-378', companyId: 'company-par', name: 'کالیته ۳۷۸', code: '378', displayOrder: 2, isActive: true }, - { id: 'cat-379', companyId: 'company-par', name: 'کالیته ۳۷۹', code: '379', displayOrder: 3, isActive: true }, - { id: 'cat-380', companyId: 'company-par', name: 'کالیته ۳۸۰', code: '380', displayOrder: 4, isActive: true }, - { id: 'cat-381', companyId: 'company-par', name: 'کالیته ۳۸۱', code: '381', displayOrder: 5, isActive: true }, - { id: 'cat-fam-401', companyId: 'company-fam-baft', name: 'کالیته ۴۰۱', code: '401', displayOrder: 1, isActive: true }, - { id: 'cat-fam-402', companyId: 'company-fam-baft', name: 'کالیته ۴۰۲', code: '402', displayOrder: 2, isActive: true }, - ], - }); - await prisma.product.createMany({ data: [ { id: 'prod-377-blue', companyId: 'company-par', - catalogId: 'cat-377', name: 'فاستونی ایتالیایی', code: '377-BL', color: 'آبی', @@ -171,7 +157,6 @@ async function main() { { id: 'prod-377-black', companyId: 'company-par', - catalogId: 'cat-377', name: 'پارچه مشکی رسمی', code: '377-BK', color: 'مشکی', @@ -195,7 +180,6 @@ async function main() { { id: 'prod-378-blue', companyId: 'company-par', - catalogId: 'cat-378', name: 'پیراهنی نخی آبی', code: '378-BL', color: 'آبی', @@ -219,7 +203,6 @@ async function main() { { id: 'prod-378-black', companyId: 'company-par', - catalogId: 'cat-378', name: 'پیراهنی مشکی', code: '378-BK', color: 'مشکی', @@ -242,7 +225,6 @@ async function main() { { id: 'prod-378-blue-white', companyId: 'company-par', - catalogId: 'cat-378', name: 'آبی پشت سفید', code: '378-BLW', color: 'آبی پشت سفید', @@ -265,7 +247,6 @@ async function main() { { id: 'prod-378-black-white', companyId: 'company-par', - catalogId: 'cat-378', name: 'مشکی پشت سفید', code: '378-BKW', color: 'مشکی پشت سفید', @@ -288,7 +269,6 @@ async function main() { { id: 'prod-fam-401-1', companyId: 'company-fam-baft', - catalogId: 'cat-fam-401', name: 'پارچه مخمل', code: '401-MV', color: 'سرمه‌ای', @@ -311,7 +291,6 @@ async function main() { { id: 'prod-fam-401-2', companyId: 'company-fam-baft', - catalogId: 'cat-fam-401', name: 'پارچه کجراه', code: '401-KJ', color: 'کرم', diff --git a/src/app.module.ts b/src/app.module.ts index d75cecf..7ce2aa6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,7 +6,6 @@ import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { CompaniesModule } from './companies/companies.module'; -import { CatalogsModule } from './catalogs/catalogs.module'; import { ProductsModule } from './products/products.module'; import { CustomersModule } from './customers/customers.module'; import { AdminUsersModule } from './admin-users/admin-users.module'; @@ -35,7 +34,6 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo PrismaModule, AuthModule, CompaniesModule, - CatalogsModule, ProductsModule, CustomersModule, AdminUsersModule, diff --git a/src/carts/carts.service.ts b/src/carts/carts.service.ts index 3d811b2..27a470b 100644 --- a/src/carts/carts.service.ts +++ b/src/carts/carts.service.ts @@ -23,7 +23,6 @@ export class CartsService { const product = await this.prisma.product.findUnique({ where: { id: dto.productId }, include: { - catalog: true, company: true, }, }); @@ -54,7 +53,7 @@ export class CartsService { data: { cartId: cart.id, productId: product.id, - catalogCode: product.catalog.code, + catalogCode: product.code, productName: product.name, productCode: product.code, color: product.color, diff --git a/src/companies/companies.service.ts b/src/companies/companies.service.ts index 3691681..687cf27 100644 --- a/src/companies/companies.service.ts +++ b/src/companies/companies.service.ts @@ -24,7 +24,7 @@ export class CompaniesService { orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }], include: { _count: { - select: { catalogs: true, products: true, bankAccounts: true }, + select: { products: true, bankAccounts: true }, }, }, }), @@ -40,7 +40,7 @@ export class CompaniesService { include: { bankAccounts: { orderBy: { displayOrder: 'asc' } }, _count: { - select: { catalogs: true, products: true, bankAccounts: true }, + select: { products: true, bankAccounts: true }, }, }, }), @@ -78,7 +78,7 @@ export class CompaniesService { }, include: { _count: { - select: { catalogs: true, products: true, bankAccounts: true }, + select: { products: true, bankAccounts: true }, }, }, }); @@ -116,7 +116,7 @@ export class CompaniesService { }, include: { _count: { - select: { catalogs: true, products: true, bankAccounts: true }, + select: { products: true, bankAccounts: true }, }, }, }); @@ -139,7 +139,7 @@ export class CompaniesService { data: { isActive }, include: { _count: { - select: { catalogs: true, products: true, bankAccounts: true }, + select: { products: true, bankAccounts: true }, }, }, }); @@ -226,7 +226,7 @@ export class CompaniesService { cashInvoiceValidUntilHour: number | null; createdAt: Date; updatedAt: Date; - _count?: { catalogs: number; products: number; bankAccounts: number }; + _count?: { products: number; bankAccounts: number }; }, fallbackMaxMeterage: number | null = null, ) { @@ -247,7 +247,6 @@ export class CompaniesService { ownMaxMeteragePerInvoice: company.maxMeteragePerInvoice ?? undefined, cashInvoiceValidityHours: company.cashInvoiceValidityHours, cashInvoiceValidUntilHour: company.cashInvoiceValidUntilHour ?? undefined, - catalogCount: company._count?.catalogs ?? 0, productCount: company._count?.products ?? 0, bankAccountCount: company._count?.bankAccounts ?? 0, createdAt: company.createdAt.toISOString(), diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index a48807f..72cbbb5 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -736,7 +736,7 @@ export class OrdersService { const products = await this.prisma.product.findMany({ where: { id: { in: uniqueProductIds } }, - include: { catalog: true, company: true }, + include: { company: true }, }); const productMap = new Map(products.map((p) => [p.id, p])); @@ -818,7 +818,7 @@ export class OrdersService { totalMeterage += item.meterage; return { productId: product.id, - catalogCode: product.catalog.code, + catalogCode: product.code, productName: product.name, productCode: product.code, color: product.color, diff --git a/src/products/color-entries.spec.ts b/src/products/color-entries.spec.ts new file mode 100644 index 0000000..1bd6c6f --- /dev/null +++ b/src/products/color-entries.spec.ts @@ -0,0 +1,33 @@ +import { + colorVariantImageLists, + parseColorEntries, + parseGeneralImageUrls, +} from './color-entries'; + +describe('product image separation', () => { + it('keeps general images separate from color variant urls', () => { + const entries = parseColorEntries( + [ + { name: 'کرم', imageUrls: ['https://cdn/a.jpg', 'https://cdn/b.jpg'] }, + { name: 'طوسی', imageUrl: 'https://cdn/grey.jpg' }, + ], + 'کرم', + ); + const general = parseGeneralImageUrls( + ['https://cdn/g1.jpg', 'https://cdn/a.jpg'], + entries, + ); + expect(general).toEqual(['https://cdn/g1.jpg']); + expect(colorVariantImageLists(entries)).toEqual([ + ['https://cdn/a.jpg', 'https://cdn/b.jpg'], + ['https://cdn/grey.jpg'], + ]); + }); + + it('does not merge color images into general list when only imageUrlsJson is set', () => { + const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]); + expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([ + 'https://cdn/g.jpg', + ]); + }); +}); diff --git a/src/products/color-entries.ts b/src/products/color-entries.ts index 3530bb6..ee75f4d 100644 --- a/src/products/color-entries.ts +++ b/src/products/color-entries.ts @@ -1,48 +1,85 @@ export type ProductColorEntry = { name: string; + /** @deprecated use imageUrls — first image for swatches */ imageUrl?: string; + imageUrls: string[]; }; +function normalizeUrlList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of raw) { + if (typeof item !== 'string') continue; + const url = item.trim(); + if (!url || seen.has(url)) continue; + seen.add(url); + out.push(url); + } + return out; +} + export function parseColorEntries( colorsJson: unknown, fallbackColor?: string, ): ProductColorEntry[] { if (!Array.isArray(colorsJson) || colorsJson.length === 0) { const name = fallbackColor?.trim(); - return name ? [{ name }] : []; + return name ? [{ name, imageUrls: [] }] : []; } return colorsJson .map((item): ProductColorEntry | null => { if (typeof item === 'string') { const name = item.trim(); - return name ? { name } : null; + return name ? { name, imageUrls: [] } : null; } if (item && typeof item === 'object') { - const raw = item as { name?: unknown; imageUrl?: unknown }; + const raw = item as { + name?: unknown; + imageUrl?: unknown; + imageUrls?: unknown; + }; const name = String(raw.name ?? '').trim(); if (!name) return null; - const imageUrl = + const fromArray = normalizeUrlList(raw.imageUrls); + const legacy = typeof raw.imageUrl === 'string' && raw.imageUrl.trim() - ? raw.imageUrl + ? raw.imageUrl.trim() : undefined; - return { name, imageUrl }; + const imageUrls = + fromArray.length > 0 + ? legacy && !fromArray.includes(legacy) + ? [legacy, ...fromArray] + : fromArray + : legacy + ? [legacy] + : []; + const imageUrl = imageUrls[0]; + return { name, imageUrl, imageUrls }; } return null; }) .filter((entry): entry is ProductColorEntry => Boolean(entry)); } -export function mergeProductImages( - entries: ProductColorEntry[], +/** تصاویر عمومی محصول — فقط از imageUrlsJson، بدون merge با رنگ‌ها */ +export function parseGeneralImageUrls( imageUrlsJson: unknown, + colorEntries?: ProductColorEntry[], ): string[] { - const extras = Array.isArray(imageUrlsJson) - ? imageUrlsJson.filter((url): url is string => typeof url === 'string' && url.trim().length > 0) - : []; - const fromColors = entries - .map((entry) => entry.imageUrl) - .filter((url): url is string => Boolean(url)); - const seen = new Set(fromColors); - return [...fromColors, ...extras.filter((url) => !seen.has(url))]; + const general = normalizeUrlList(imageUrlsJson); + if (!colorEntries?.length) return general; + + const colorOnly = new Set(); + for (const entry of colorEntries) { + for (const url of entry.imageUrls) colorOnly.add(url); + } + return general.filter((url) => !colorOnly.has(url)); +} + +export function colorVariantImageLists( + entries: ProductColorEntry[], +): string[][] { + return entries.map((entry) => [...entry.imageUrls]); } diff --git a/src/products/dto/product-color-json.dto.ts b/src/products/dto/product-color-json.dto.ts index 3cb1fe9..7de5e87 100644 --- a/src/products/dto/product-color-json.dto.ts +++ b/src/products/dto/product-color-json.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsArray, IsOptional, IsString, MinLength } from 'class-validator'; export class ProductColorJsonItemDto { @IsString() @@ -8,6 +8,11 @@ export class ProductColorJsonItemDto { @IsOptional() @IsString() imageUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + imageUrls?: string[]; } /** نرمال‌سازی colorsJson (رشتهٔ legacy یا آبجکت ادمین) قبل از اعتبارسنجی */ @@ -26,11 +31,20 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] | const raw = item as Record; const name = String(raw.name ?? '').trim(); if (!name) continue; + const imageUrls = Array.isArray(raw.imageUrls) + ? raw.imageUrls + .filter((u): u is string => typeof u === 'string' && u.trim().length > 0) + .map((u) => u.trim()) + : undefined; const imageUrl = typeof raw.imageUrl === 'string' && raw.imageUrl.trim() ? raw.imageUrl.trim() : undefined; - out.push({ name, imageUrl }); + out.push({ + name, + imageUrl, + imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined, + }); } } return out; diff --git a/src/products/dto/product.dto.ts b/src/products/dto/product.dto.ts index 1671889..82748ce 100644 --- a/src/products/dto/product.dto.ts +++ b/src/products/dto/product.dto.ts @@ -18,9 +18,6 @@ export class CreateProductDto { @IsString() companyId!: string; - @IsString() - catalogId!: string; - @IsOptional() @IsString() brandId?: string | null; @@ -69,9 +66,10 @@ export class CreateProductDto { @Type(() => Number) @IsNumber() @Min(0) + /** قیمت هر متر (تومان) */ basePrice?: number | null; - /** Alias from admin UI */ + /** Alias from admin UI — قیمت هر متر (تومان) */ @IsOptional() @Type(() => Number) @IsNumber() @@ -154,10 +152,6 @@ export class CreateProductDto { } export class UpdateProductDto { - @IsOptional() - @IsString() - catalogId?: string; - @IsOptional() @IsString() brandId?: string | null; diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 61bd232..60177a8 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -32,7 +32,6 @@ export class ProductsController { @Get() findAll( @CurrentUser() user: AuthPrincipal | undefined, - @Query('catalogId') catalogId?: string, @Query('companyId') companyId?: string, @Query('color') color?: string, @Query('activeOnly') activeOnly?: string, @@ -42,7 +41,6 @@ export class ProductsController { ) { const isAdmin = user?.type === 'admin'; return this.productsService.findAll({ - catalogId, companyId, color, activeOnly: activeOnly === 'true' || activeOnly === '1', diff --git a/src/products/products.service.ts b/src/products/products.service.ts index beab417..72372eb 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -8,11 +8,14 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateProductDto, UpdateProductDto } from './dto/product.dto'; import { loadCatalogCategories } from '../catalog/category-defs'; -import { mergeProductImages, parseColorEntries } from './color-entries'; +import { + colorVariantImageLists, + parseColorEntries, + parseGeneralImageUrls, +} from './color-entries'; const productInclude = { company: { select: { name: true } }, - catalog: { select: { code: true, name: true } }, brand: { select: { id: true, name: true } }, }; @@ -25,7 +28,6 @@ export class ProductsService { } async findAll(filters: { - catalogId?: string; companyId?: string; color?: string; activeOnly?: boolean; @@ -35,7 +37,6 @@ export class ProductsService { }) { const products = await this.prisma.product.findMany({ where: { - ...(filters.catalogId ? { catalogId: filters.catalogId } : {}), ...(filters.companyId ? { companyId: filters.companyId } : {}), ...(filters.color ? { color: { contains: filters.color, mode: 'insensitive' } } @@ -75,14 +76,6 @@ export class ProductsService { } async create(dto: CreateProductDto) { - const catalog = await this.prisma.catalog.findUnique({ - where: { id: dto.catalogId }, - }); - if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد'); - if (catalog.companyId !== dto.companyId) { - throw new ConflictException('کاتالوگ متعلق به این شرکت نیست'); - } - const code = (dto.code || dto.productCode || '').trim(); if (!code) throw new ConflictException('کد محصول الزامی است'); const brandId = await this.resolveBrandId(dto.brandId ?? null); @@ -91,7 +84,6 @@ export class ProductsService { const product = await this.prisma.product.create({ data: { companyId: dto.companyId, - catalogId: dto.catalogId, brandId, name: dto.name.trim(), code, @@ -145,7 +137,6 @@ export class ProductsService { const product = await this.prisma.product.update({ where: { id }, data: { - catalogId: dto.catalogId, ...(brandId !== undefined ? { brandId } : {}), name: dto.name?.trim(), code, @@ -290,7 +281,6 @@ export class ProductsService { product: { id: string; companyId: string; - catalogId: string; name: string; code: string; color: string; @@ -303,7 +293,6 @@ export class ProductsService { createdAt: Date; updatedAt: Date; company: { name: string }; - catalog: { code: string; name: string }; brand?: { id: string; name: string } | null; usageCategory?: string; season?: string; @@ -322,8 +311,14 @@ export class ProductsService { ) { const colorEntries = parseColorEntries(product.colorsJson, product.color); const colors = colorEntries.map((entry) => entry.name); - const colorImages = colorEntries.map((entry) => entry.imageUrl ?? null); - const imageUrls = mergeProductImages(colorEntries, product.imageUrlsJson); + const colorImages = colorEntries.map( + (entry) => entry.imageUrls[0] ?? entry.imageUrl ?? null, + ); + const colorVariantImages = colorVariantImageLists(colorEntries); + const imageUrls = parseGeneralImageUrls( + product.imageUrlsJson, + colorEntries, + ); const tags = Array.isArray(product.tagsJson) ? (product.tagsJson as string[]) : []; @@ -332,9 +327,10 @@ export class ProductsService { id: product.id, companyId: product.companyId, companyName: product.company.name, - catalogId: product.catalogId, - catalogCode: product.catalog.code, - catalogName: product.catalog.name, + /** @deprecated use `code` — kept for existing clients */ + catalogCode: product.code, + /** @deprecated use `name` */ + catalogName: product.name, brandId: product.brand?.id ?? null, brandName: product.brand?.name ?? null, name: product.name, @@ -359,6 +355,7 @@ export class ProductsService { weaveType: product.weaveType ?? undefined, colors, colorImages, + colorVariantImages, tags, imageUrls, subtitle: product.subtitle ?? undefined, diff --git a/src/stock-notify/stock-notify.service.ts b/src/stock-notify/stock-notify.service.ts index 046342e..33c4508 100644 --- a/src/stock-notify/stock-notify.service.ts +++ b/src/stock-notify/stock-notify.service.ts @@ -18,7 +18,6 @@ export class StockNotifyService { color: true, stock: true, imageUrlsJson: true, - catalog: { select: { code: true } }, }, }, }, @@ -110,7 +109,6 @@ export class StockNotifyService { color: string; stock: number | null; imageUrlsJson: unknown; - catalog: { code: string }; }; }) { return { @@ -118,7 +116,7 @@ export class StockNotifyService { productId: r.productId, productName: r.product.name, productCode: r.product.code, - catalogCode: r.product.catalog.code, + catalogCode: r.product.code, color: r.product.color, stock: r.product.stock, imageUrl: this.productThumb(r.product.imageUrlsJson), diff --git a/src/suggestions/suggestions.service.ts b/src/suggestions/suggestions.service.ts index 2c93e11..7257246 100644 --- a/src/suggestions/suggestions.service.ts +++ b/src/suggestions/suggestions.service.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTone } from '@prisma/client'; import { NotificationsService } from '../notifications/notifications.service'; import { - mergeProductImages, + parseGeneralImageUrls, parseColorEntries, } from '../products/color-entries'; import { PrismaService } from '../prisma/prisma.service'; @@ -19,7 +19,6 @@ const PRODUCT_INCLUDE = { imageUrlsJson: true, widthCm: true, composition: true, - catalog: { select: { name: true, code: true } }, }, }, customer: { select: { firstName: true, lastName: true, phone: true } }, @@ -147,17 +146,20 @@ export class SuggestionsService { imageUrlsJson: unknown; widthCm: number | null; composition: string | null; - catalog: { name: string; code: string } | null; }; }) { const colorEntries = parseColorEntries(r.product.colorsJson, r.product.color); - const images = mergeProductImages(colorEntries, r.product.imageUrlsJson); + const imageUrls = parseGeneralImageUrls( + r.product.imageUrlsJson, + colorEntries, + ); const colors = colorEntries.map((entry) => entry.name); - const colorImages = colorEntries.map((entry) => entry.imageUrl ?? null); - const catalogName = r.product.catalog?.name; + const colorImages = colorEntries.map( + (entry) => entry.imageUrls[0] ?? entry.imageUrl ?? null, + ); const subtitle = r.product.subtitle?.trim() || - [catalogName, r.product.color].filter(Boolean).join(' · ') || + [r.product.name, r.product.color].filter(Boolean).join(' · ') || undefined; return { @@ -168,11 +170,11 @@ export class SuggestionsService { productId: r.productId, productName: r.product.name, productCode: r.product.code, - catalogName, - catalogCode: r.product.catalog?.code, + catalogName: r.product.name, + catalogCode: r.product.code, color: r.product.color, subtitle, - imageUrl: images[0] ?? undefined, + imageUrl: imageUrls[0] ?? colorImages.find(Boolean) ?? undefined, colors, colorImages, widthCm: r.product.widthCm ?? undefined,