change UI & fix bugs

This commit is contained in:
2026-09-14 16:32:03 +03:30
parent 602da7f860
commit 4660b4f922
16 changed files with 693 additions and 63 deletions
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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
+9 -4
View File
File diff suppressed because one or more lines are too long
+294 -22
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -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(),
};
+24
View File
@@ -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;
}
+189 -8
View File
@@ -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,
})),
+48
View File
@@ -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))];
}
+2 -2
View File
@@ -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[];
+17 -12
View File
@@ -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,
+7
View File
@@ -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 }>
};
}
+41 -3
View File
@@ -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;