change UI & fix bugs
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- Allow dynamic category ids on products (was Prisma enums)
|
||||
ALTER TABLE "Product" ALTER COLUMN "usageCategory" DROP DEFAULT;
|
||||
ALTER TABLE "Product" ALTER COLUMN "usageCategory" TYPE TEXT USING "usageCategory"::text;
|
||||
ALTER TABLE "Product" ALTER COLUMN "usageCategory" SET DEFAULT 'other';
|
||||
|
||||
ALTER TABLE "Product" ALTER COLUMN "season" DROP DEFAULT;
|
||||
ALTER TABLE "Product" ALTER COLUMN "season" TYPE TEXT USING "season"::text;
|
||||
ALTER TABLE "Product" ALTER COLUMN "season" SET DEFAULT 'all_season';
|
||||
|
||||
-- Persist admin-managed category definitions
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "categoryDefsJson" JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "AppBanner" ADD COLUMN IF NOT EXISTS "showTitle" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AppBanner" ADD COLUMN IF NOT EXISTS "showSubtitle" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AppBanner" ADD COLUMN IF NOT EXISTS "showCta" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -202,8 +202,8 @@ model Product {
|
||||
stock Float?
|
||||
isActive Boolean @default(true)
|
||||
/// فیلدهای نمایش کاتالوگ موکاپ (اپ کاربر)
|
||||
usageCategory ProductUsageCategory @default(other)
|
||||
season ProductSeason @default(all_season)
|
||||
usageCategory String @default("other")
|
||||
season String @default("all_season")
|
||||
widthCm Float?
|
||||
composition String?
|
||||
weightGrams Float?
|
||||
@@ -509,6 +509,8 @@ model SystemSettings {
|
||||
supportEmail String?
|
||||
/// تصاویر دستههای کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
|
||||
categoryImagesJson Json?
|
||||
/// تعاریف دستهها (کاربرد/فصل). اگر خالی باشد از مقادیر پیشفرض کد استفاده میشود.
|
||||
categoryDefsJson Json?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -523,6 +525,9 @@ model AppBanner {
|
||||
imageUrl String?
|
||||
displayOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
showTitle Boolean @default(true)
|
||||
showSubtitle Boolean @default(true)
|
||||
showCta Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Allow dynamic category ids on products (was Prisma enums)
|
||||
ALTER TABLE "Product" ALTER COLUMN "usageCategory" DROP DEFAULT;
|
||||
ALTER TABLE "Product" ALTER COLUMN "usageCategory" TYPE TEXT USING "usageCategory"::text;
|
||||
ALTER TABLE "Product" ALTER COLUMN "usageCategory" SET DEFAULT 'other';
|
||||
|
||||
ALTER TABLE "Product" ALTER COLUMN "season" DROP DEFAULT;
|
||||
ALTER TABLE "Product" ALTER COLUMN "season" TYPE TEXT USING "season"::text;
|
||||
ALTER TABLE "Product" ALTER COLUMN "season" SET DEFAULT 'all_season';
|
||||
|
||||
-- Persist admin-managed category definitions
|
||||
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "categoryDefsJson" JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "AppBanner" ADD COLUMN IF NOT EXISTS "showTitle" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AppBanner" ADD COLUMN IF NOT EXISTS "showSubtitle" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AppBanner" ADD COLUMN IF NOT EXISTS "showCta" BOOLEAN NOT NULL DEFAULT true;
|
||||
+15
-10
@@ -189,21 +189,21 @@ model Catalog {
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
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)
|
||||
minMeterage Float @default(30)
|
||||
orderMultiplier Float @default(10)
|
||||
basePrice Float?
|
||||
stock Float?
|
||||
isActive Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
/// فیلدهای نمایش کاتالوگ موکاپ (اپ کاربر)
|
||||
usageCategory ProductUsageCategory @default(other)
|
||||
season ProductSeason @default(all_season)
|
||||
usageCategory String @default("other")
|
||||
season String @default("all_season")
|
||||
widthCm Float?
|
||||
composition String?
|
||||
weightGrams Float?
|
||||
@@ -212,10 +212,10 @@ model Product {
|
||||
tagsJson Json?
|
||||
imageUrlsJson Json?
|
||||
subtitle String?
|
||||
rollMeters Float @default(50)
|
||||
isNew Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
rollMeters Float @default(50)
|
||||
isNew Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
catalog Catalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
|
||||
@@ -509,6 +509,8 @@ model SystemSettings {
|
||||
supportEmail String?
|
||||
/// تصاویر دستههای کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
|
||||
categoryImagesJson Json?
|
||||
/// تعاریف دستهها (کاربرد/فصل). اگر خالی باشد از مقادیر پیشفرض کد استفاده میشود.
|
||||
categoryDefsJson Json?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -523,6 +525,9 @@ model AppBanner {
|
||||
imageUrl String?
|
||||
displayOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
showTitle Boolean @default(true)
|
||||
showSubtitle Boolean @default(true)
|
||||
showCta Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
+9
-4
File diff suppressed because one or more lines are too long
+294
-22
File diff suppressed because one or more lines are too long
@@ -31,6 +31,9 @@ export class BannersService {
|
||||
imageUrl: this.normalizeImage(dto.imageUrl),
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
showTitle: dto.showTitle ?? true,
|
||||
showSubtitle: dto.showSubtitle ?? true,
|
||||
showCta: dto.showCta ?? true,
|
||||
},
|
||||
});
|
||||
return this.map(banner);
|
||||
@@ -55,6 +58,9 @@ export class BannersService {
|
||||
: this.normalizeImage(dto.imageUrl),
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
showTitle: dto.showTitle,
|
||||
showSubtitle: dto.showSubtitle,
|
||||
showCta: dto.showCta,
|
||||
},
|
||||
});
|
||||
return this.map(banner);
|
||||
@@ -106,6 +112,9 @@ export class BannersService {
|
||||
imageUrl: string | null;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
showTitle: boolean;
|
||||
showSubtitle: boolean;
|
||||
showCta: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -119,6 +128,9 @@ export class BannersService {
|
||||
imageUrl: row.imageUrl ?? '',
|
||||
displayOrder: row.displayOrder,
|
||||
isActive: row.isActive,
|
||||
showTitle: row.showTitle !== false,
|
||||
showSubtitle: row.showSubtitle !== false,
|
||||
showCta: row.showCta !== false,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -42,6 +42,18 @@ export class CreateBannerDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showTitle?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showSubtitle?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showCta?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBannerDto {
|
||||
@@ -80,4 +92,16 @@ export class UpdateBannerDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showTitle?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showSubtitle?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showCta?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,116 @@
|
||||
import type { PrismaService } from '../prisma/prisma.service'
|
||||
|
||||
export const USAGE_CATEGORIES = [
|
||||
{ id: 'trousers', title: 'پارچههای شلواری', subtitle: 'مشاهده محصولات' },
|
||||
{ id: 'suit', title: 'پارچههای کتشلواری', subtitle: 'مشاهده محصولات' },
|
||||
{ id: 'shirt', title: 'پارچههای پیراهنی', subtitle: 'مشاهده محصولات' },
|
||||
export const CATEGORY_ICONS = [
|
||||
'bolt',
|
||||
'feather',
|
||||
'diamond',
|
||||
'grid',
|
||||
'bag',
|
||||
'id-card',
|
||||
'catalog',
|
||||
'headset',
|
||||
'home',
|
||||
] as const
|
||||
|
||||
export const SEASON_CATEGORIES = [
|
||||
export type CategoryIconId = (typeof CATEGORY_ICONS)[number]
|
||||
|
||||
export type UsageCategoryDef = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: CategoryIconId
|
||||
showTitle: boolean
|
||||
showSubtitle: boolean
|
||||
showIcon: boolean
|
||||
}
|
||||
|
||||
export type SeasonCategoryDef = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
ctaLabel: string
|
||||
icon: CategoryIconId
|
||||
showTitle: boolean
|
||||
showDescription: boolean
|
||||
showCta: boolean
|
||||
showIcon: boolean
|
||||
}
|
||||
|
||||
export type CategoryDefs = {
|
||||
usage: UsageCategoryDef[]
|
||||
seasons: SeasonCategoryDef[]
|
||||
}
|
||||
|
||||
export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
||||
{
|
||||
id: 'trousers',
|
||||
title: 'پارچههای شلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'grid',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
},
|
||||
{
|
||||
id: 'suit',
|
||||
title: 'پارچههای کتشلواری',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'bag',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
},
|
||||
{
|
||||
id: 'shirt',
|
||||
title: 'پارچههای پیراهنی',
|
||||
subtitle: 'مشاهده محصولات',
|
||||
icon: 'id-card',
|
||||
showTitle: true,
|
||||
showSubtitle: true,
|
||||
showIcon: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const SEASON_CATEGORIES: SeasonCategoryDef[] = [
|
||||
{
|
||||
id: 'summer',
|
||||
title: 'تابستانه',
|
||||
description: 'سبک، خنک و مناسب فصل گرم',
|
||||
ctaLabel: 'مشاهده محصولات',
|
||||
icon: 'bolt',
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
showCta: true,
|
||||
showIcon: true,
|
||||
},
|
||||
{
|
||||
id: 'winter',
|
||||
title: 'زمستانه',
|
||||
description: 'گرم و مناسب فصل سرد',
|
||||
ctaLabel: 'مشاهده محصولات',
|
||||
icon: 'feather',
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
showCta: true,
|
||||
showIcon: true,
|
||||
},
|
||||
{
|
||||
id: 'all_season',
|
||||
title: 'چهارفصل',
|
||||
description: 'مناسب استفاده در بیشتر فصلهای سال',
|
||||
ctaLabel: 'مشاهده محصولات',
|
||||
icon: 'diamond',
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
showCta: true,
|
||||
showIcon: true,
|
||||
},
|
||||
] as const
|
||||
]
|
||||
|
||||
export const DEFAULT_CATEGORY_DEFS: CategoryDefs = {
|
||||
usage: USAGE_CATEGORIES,
|
||||
seasons: SEASON_CATEGORIES,
|
||||
}
|
||||
|
||||
export const CATEGORY_IMAGE_KEYS = [
|
||||
...USAGE_CATEGORIES.map((c) => c.id),
|
||||
@@ -40,17 +128,110 @@ export function asImageMap(value: unknown): Record<string, string> {
|
||||
return out
|
||||
}
|
||||
|
||||
function isShown(value: unknown) {
|
||||
return value !== false
|
||||
}
|
||||
|
||||
function parseIcon(value: unknown, fallback: CategoryIconId): CategoryIconId {
|
||||
if (typeof value === 'string' && (CATEGORY_ICONS as readonly string[]).includes(value)) {
|
||||
return value as CategoryIconId
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function defaultUsageIcon(id: string): CategoryIconId {
|
||||
if (id === 'shirt') return 'id-card'
|
||||
if (id === 'suit') return 'bag'
|
||||
return 'grid'
|
||||
}
|
||||
|
||||
function defaultSeasonIcon(id: string): CategoryIconId {
|
||||
if (id === 'summer') return 'bolt'
|
||||
if (id === 'winter') return 'feather'
|
||||
return 'diamond'
|
||||
}
|
||||
|
||||
function slugifyId(raw: string) {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9_\u0600-\u06ff-]/gi, '')
|
||||
.slice(0, 48)
|
||||
}
|
||||
|
||||
export function parseCategoryDefs(value: unknown): CategoryDefs | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const raw = value as { usage?: unknown; seasons?: unknown }
|
||||
if (!Array.isArray(raw.usage) || !Array.isArray(raw.seasons)) return null
|
||||
|
||||
const usage: UsageCategoryDef[] = []
|
||||
for (const item of raw.usage) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as Record<string, unknown>
|
||||
const id = typeof row.id === 'string' ? slugifyId(row.id) : ''
|
||||
const title = typeof row.title === 'string' ? row.title.trim() : ''
|
||||
if (!id || !title) continue
|
||||
usage.push({
|
||||
id,
|
||||
title,
|
||||
subtitle:
|
||||
typeof row.subtitle === 'string' && row.subtitle.trim()
|
||||
? row.subtitle.trim()
|
||||
: 'مشاهده محصولات',
|
||||
icon: parseIcon(row.icon, defaultUsageIcon(id)),
|
||||
showTitle: isShown(row.showTitle),
|
||||
showSubtitle: isShown(row.showSubtitle),
|
||||
showIcon: isShown(row.showIcon),
|
||||
})
|
||||
}
|
||||
|
||||
const seasons: SeasonCategoryDef[] = []
|
||||
for (const item of raw.seasons) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as Record<string, unknown>
|
||||
const id = typeof row.id === 'string' ? slugifyId(row.id) : ''
|
||||
const title = typeof row.title === 'string' ? row.title.trim() : ''
|
||||
if (!id || !title) continue
|
||||
seasons.push({
|
||||
id,
|
||||
title,
|
||||
description:
|
||||
typeof row.description === 'string' && row.description.trim()
|
||||
? row.description.trim()
|
||||
: '',
|
||||
ctaLabel:
|
||||
typeof row.ctaLabel === 'string' && row.ctaLabel.trim()
|
||||
? row.ctaLabel.trim()
|
||||
: 'مشاهده محصولات',
|
||||
icon: parseIcon(row.icon, defaultSeasonIcon(id)),
|
||||
showTitle: isShown(row.showTitle),
|
||||
showDescription: isShown(row.showDescription),
|
||||
showCta: isShown(row.showCta),
|
||||
showIcon: isShown(row.showIcon),
|
||||
})
|
||||
}
|
||||
|
||||
if (!usage.length || !seasons.length) return null
|
||||
return { usage, seasons }
|
||||
}
|
||||
|
||||
export function resolveCategoryDefs(value: unknown): CategoryDefs {
|
||||
return parseCategoryDefs(value) ?? DEFAULT_CATEGORY_DEFS
|
||||
}
|
||||
|
||||
export async function loadCatalogCategories(prisma: PrismaService) {
|
||||
const settings = await prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
})
|
||||
const images = asImageMap(settings?.categoryImagesJson)
|
||||
const defs = resolveCategoryDefs(settings?.categoryDefsJson)
|
||||
return {
|
||||
usage: USAGE_CATEGORIES.map((item) => ({
|
||||
usage: defs.usage.map((item) => ({
|
||||
...item,
|
||||
imageUrl: images[item.id] || undefined,
|
||||
})),
|
||||
seasons: SEASON_CATEGORIES.map((item) => ({
|
||||
seasons: defs.seasons.map((item) => ({
|
||||
...item,
|
||||
imageUrl: images[item.id] || undefined,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ProductColorEntry = {
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
export function parseColorEntries(
|
||||
colorsJson: unknown,
|
||||
fallbackColor?: string,
|
||||
): ProductColorEntry[] {
|
||||
if (!Array.isArray(colorsJson) || colorsJson.length === 0) {
|
||||
const name = fallbackColor?.trim();
|
||||
return name ? [{ name }] : [];
|
||||
}
|
||||
|
||||
return colorsJson
|
||||
.map((item): ProductColorEntry | null => {
|
||||
if (typeof item === 'string') {
|
||||
const name = item.trim();
|
||||
return name ? { name } : null;
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const raw = item as { name?: unknown; imageUrl?: unknown };
|
||||
const name = String(raw.name ?? '').trim();
|
||||
if (!name) return null;
|
||||
const imageUrl =
|
||||
typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
|
||||
? raw.imageUrl
|
||||
: undefined;
|
||||
return { name, imageUrl };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is ProductColorEntry => Boolean(entry));
|
||||
}
|
||||
|
||||
export function mergeProductImages(
|
||||
entries: ProductColorEntry[],
|
||||
imageUrlsJson: unknown,
|
||||
): 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))];
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export class CreateProductDto {
|
||||
isNew?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
colorsJson?: string[];
|
||||
colorsJson?: Array<string | { name: string; imageUrl?: string }>;
|
||||
|
||||
@IsOptional()
|
||||
imageUrlsJson?: string[];
|
||||
@@ -253,7 +253,7 @@ export class UpdateProductDto {
|
||||
isNew?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
colorsJson?: string[];
|
||||
colorsJson?: Array<string | { name: string; imageUrl?: string }>;
|
||||
|
||||
@IsOptional()
|
||||
imageUrlsJson?: string[];
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
@@ -107,8 +108,8 @@ export class ProductsService {
|
||||
subtitle: dto.subtitle?.trim() || null,
|
||||
rollMeters: dto.rollMeters,
|
||||
isNew: dto.isNew,
|
||||
colorsJson: dto.colorsJson ?? undefined,
|
||||
imageUrlsJson: dto.imageUrlsJson ?? undefined,
|
||||
colorsJson: (dto.colorsJson ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
imageUrlsJson: (dto.imageUrlsJson ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
tagsJson: dto.tagsJson ?? undefined,
|
||||
},
|
||||
include: {
|
||||
@@ -188,8 +189,14 @@ export class ProductsService {
|
||||
: dto.subtitle?.trim() || null,
|
||||
rollMeters: dto.rollMeters,
|
||||
isNew: dto.isNew,
|
||||
colorsJson: dto.colorsJson,
|
||||
imageUrlsJson: dto.imageUrlsJson,
|
||||
colorsJson:
|
||||
dto.colorsJson === undefined
|
||||
? undefined
|
||||
: (dto.colorsJson as Prisma.InputJsonValue),
|
||||
imageUrlsJson:
|
||||
dto.imageUrlsJson === undefined
|
||||
? undefined
|
||||
: (dto.imageUrlsJson as Prisma.InputJsonValue),
|
||||
tagsJson: dto.tagsJson,
|
||||
},
|
||||
include: {
|
||||
@@ -303,11 +310,10 @@ export class ProductsService {
|
||||
},
|
||||
hidePrice = false,
|
||||
) {
|
||||
const colors = Array.isArray(product.colorsJson)
|
||||
? (product.colorsJson as string[])
|
||||
: product.color
|
||||
? [product.color]
|
||||
: [];
|
||||
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 tags = Array.isArray(product.tagsJson)
|
||||
? (product.tagsJson as string[])
|
||||
: [];
|
||||
@@ -340,10 +346,9 @@ export class ProductsService {
|
||||
weightGrams: product.weightGrams ?? undefined,
|
||||
weaveType: product.weaveType ?? undefined,
|
||||
colors,
|
||||
colorImages,
|
||||
tags,
|
||||
imageUrls: Array.isArray(product.imageUrlsJson)
|
||||
? product.imageUrlsJson
|
||||
: [],
|
||||
imageUrls,
|
||||
subtitle: product.subtitle ?? undefined,
|
||||
rollMeters: product.rollMeters ?? 50,
|
||||
isNew: product.isNew ?? false,
|
||||
|
||||
@@ -73,4 +73,11 @@ export class UpdateSettingsDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
categoryImagesJson?: Record<string, string | null>;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
categoryDefsJson?: {
|
||||
usage: Array<{ id: string; title: string; subtitle?: string }>
|
||||
seasons: Array<{ id: string; title: string; description?: string }>
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateSettingsDto } from './dto/settings.dto';
|
||||
import { CATEGORY_IMAGE_KEYS, asImageMap } from '../catalog/category-defs';
|
||||
import {
|
||||
asImageMap,
|
||||
parseCategoryDefs,
|
||||
resolveCategoryDefs,
|
||||
type CategoryDefs,
|
||||
} from '../catalog/category-defs';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
@@ -22,6 +27,15 @@ export class SettingsService {
|
||||
|
||||
async update(dto: UpdateSettingsDto) {
|
||||
await this.ensureDefaults();
|
||||
const current = await this.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
});
|
||||
const nextDefs =
|
||||
dto.categoryDefsJson === undefined
|
||||
? undefined
|
||||
: this.normalizeCategoryDefs(dto.categoryDefsJson);
|
||||
const effectiveDefs = nextDefs ?? resolveCategoryDefs(current?.categoryDefsJson);
|
||||
|
||||
const settings = await this.prisma.systemSettings.update({
|
||||
where: { id: 'default' },
|
||||
data: {
|
||||
@@ -49,10 +63,11 @@ export class SettingsService {
|
||||
dto.supportEmail === undefined
|
||||
? undefined
|
||||
: dto.supportEmail?.trim() || null,
|
||||
categoryDefsJson: nextDefs,
|
||||
categoryImagesJson:
|
||||
dto.categoryImagesJson === undefined
|
||||
? undefined
|
||||
: this.normalizeCategoryImages(dto.categoryImagesJson),
|
||||
: this.normalizeCategoryImages(dto.categoryImagesJson, effectiveDefs),
|
||||
},
|
||||
});
|
||||
return this.map(settings);
|
||||
@@ -84,8 +99,10 @@ export class SettingsService {
|
||||
supportPhone: string | null;
|
||||
supportEmail: string | null;
|
||||
categoryImagesJson?: unknown;
|
||||
categoryDefsJson?: unknown;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
const defs = resolveCategoryDefs(settings.categoryDefsJson);
|
||||
return {
|
||||
id: settings.id,
|
||||
maxMeteragePerInvoice: settings.maxMeteragePerInvoice,
|
||||
@@ -104,14 +121,35 @@ export class SettingsService {
|
||||
supportPhone: settings.supportPhone,
|
||||
supportEmail: settings.supportEmail,
|
||||
categoryImagesJson: asImageMap(settings.categoryImagesJson),
|
||||
categoryDefsJson: defs,
|
||||
updatedAt: settings.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCategoryDefs(input: unknown): CategoryDefs {
|
||||
const parsed = parseCategoryDefs(input);
|
||||
if (!parsed) {
|
||||
throw new BadRequestException('تعاریف دستهبندی نامعتبر است');
|
||||
}
|
||||
const usageIds = new Set(parsed.usage.map((item) => item.id));
|
||||
const seasonIds = new Set(parsed.seasons.map((item) => item.id));
|
||||
if (usageIds.size !== parsed.usage.length) {
|
||||
throw new BadRequestException('شناسه دستههای کاربرد تکراری است');
|
||||
}
|
||||
if (seasonIds.size !== parsed.seasons.length) {
|
||||
throw new BadRequestException('شناسه دستههای فصل تکراری است');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private normalizeCategoryImages(
|
||||
input: Record<string, string | null>,
|
||||
defs: CategoryDefs,
|
||||
): Record<string, string> {
|
||||
const allowed = new Set<string>(CATEGORY_IMAGE_KEYS);
|
||||
const allowed = new Set([
|
||||
...defs.usage.map((item) => item.id),
|
||||
...defs.seasons.map((item) => item.id),
|
||||
]);
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (!allowed.has(key)) continue;
|
||||
|
||||
Reference in New Issue
Block a user