Split blogs and other modules into child sitemaps.
Move blogs, portfolios, videos, instructions, and workshops out of sitemap-main into dedicated files like products, and allow saving migrated Farsi category color labels without wiping store-item links. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5850c90d84
commit
730407ec3a
@@ -4,28 +4,70 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType, VariationType } from '@prisma/client';
|
||||
import { MediaEntityType, Prisma, VariationType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
getColorHex,
|
||||
isColorPreset,
|
||||
} from './color-presets';
|
||||
import { COLOR_PRESETS, isHexColor, resolveColorHex } from './color-presets';
|
||||
import {
|
||||
CategoryVariationInputDto,
|
||||
ReplaceCategoryVariationsDto,
|
||||
} from './dto/category-variation.dto';
|
||||
|
||||
function slugifyValue(value: string): string {
|
||||
return value
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (ascii) {
|
||||
return ascii;
|
||||
}
|
||||
|
||||
// Keep letters/numbers from any script (e.g. Farsi labels) when ASCII strip is empty
|
||||
const unicode = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\p{L}\p{N}-]+/gu, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return unicode;
|
||||
}
|
||||
|
||||
function uniqueSlug(base: string, used: Set<string>, fallbackIndex: number): string {
|
||||
let value = base || `option-${fallbackIndex}`;
|
||||
if (!used.has(value)) {
|
||||
used.add(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (used.has(`${value}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
const next = `${value}-${suffix}`;
|
||||
used.add(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
type ExistingVariation = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
variationType: VariationType;
|
||||
sortOrder: number;
|
||||
options: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
value: string;
|
||||
colorHex: string | null;
|
||||
sortOrder: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CategoryVariationsService {
|
||||
constructor(
|
||||
@@ -70,32 +112,57 @@ export class CategoryVariationsService {
|
||||
this.validateVariationInputs(dto.variations);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryVariation.deleteMany({
|
||||
const existing = await tx.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
const matchedExistingIds = new Set<bigint>();
|
||||
|
||||
for (const [index, variation] of dto.variations.entries()) {
|
||||
const created = await tx.categoryVariation.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
name: this.resolveVariationName(variation),
|
||||
variationType: variation.type as VariationType,
|
||||
sortOrder: index,
|
||||
},
|
||||
const current = this.matchExistingVariation(existing, variation, matchedExistingIds);
|
||||
const name = this.resolveVariationName(variation);
|
||||
const uniqueValues = [
|
||||
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
|
||||
];
|
||||
|
||||
if (current) {
|
||||
matchedExistingIds.add(current.id);
|
||||
await tx.categoryVariation.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
name,
|
||||
variationType: variation.type as VariationType,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
await this.syncOptions(tx, current, variation.type, uniqueValues);
|
||||
} else {
|
||||
const created = await tx.categoryVariation.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
name,
|
||||
variationType: variation.type as VariationType,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
await this.createOptions(tx, created.id, variation.type, uniqueValues);
|
||||
}
|
||||
}
|
||||
|
||||
const removed = existing.filter((item) => !matchedExistingIds.has(item.id));
|
||||
if (removed.length) {
|
||||
const removedIds = removed.map((item) => item.id);
|
||||
await tx.storeItemVariantSelection.deleteMany({
|
||||
where: { variationId: { in: removedIds } },
|
||||
});
|
||||
|
||||
const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))];
|
||||
|
||||
await tx.categoryVariationOption.createMany({
|
||||
data: uniqueValues.map((label, optionIndex) => ({
|
||||
variationId: created.id,
|
||||
label,
|
||||
value: slugifyValue(label) || `option-${optionIndex + 1}`,
|
||||
colorHex:
|
||||
variation.type === 'color' ? getColorHex(label) : null,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
await tx.productVariationValue.deleteMany({
|
||||
where: { variationId: { in: removedIds } },
|
||||
});
|
||||
await tx.categoryVariation.deleteMany({
|
||||
where: { id: { in: removedIds } },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -113,6 +180,109 @@ export class CategoryVariationsService {
|
||||
return variations.map((item) => this.serialize(item));
|
||||
}
|
||||
|
||||
private matchExistingVariation(
|
||||
existing: ExistingVariation[],
|
||||
incoming: CategoryVariationInputDto,
|
||||
alreadyMatched: Set<bigint>,
|
||||
): ExistingVariation | undefined {
|
||||
const candidates = existing.filter((item) => !alreadyMatched.has(item.id));
|
||||
|
||||
if (incoming.type === 'color' || incoming.type === 'size') {
|
||||
return candidates.find((item) => item.variationType === incoming.type);
|
||||
}
|
||||
|
||||
const name = incoming.name.trim().toLowerCase();
|
||||
return candidates.find(
|
||||
(item) =>
|
||||
item.variationType === 'custom' && item.name.trim().toLowerCase() === name,
|
||||
);
|
||||
}
|
||||
|
||||
private async syncOptions(
|
||||
tx: Prisma.TransactionClient,
|
||||
current: ExistingVariation,
|
||||
type: CategoryVariationInputDto['type'],
|
||||
labels: string[],
|
||||
) {
|
||||
const usedSlugs = new Set<string>();
|
||||
const matchedOptionIds = new Set<bigint>();
|
||||
|
||||
for (const [optionIndex, label] of labels.entries()) {
|
||||
const existingOption = current.options.find(
|
||||
(option) =>
|
||||
!matchedOptionIds.has(option.id) && option.label.trim() === label,
|
||||
);
|
||||
|
||||
const colorHex =
|
||||
type === 'color'
|
||||
? resolveColorHex(label) ??
|
||||
existingOption?.colorHex ??
|
||||
(existingOption && isHexColor(existingOption.value)
|
||||
? existingOption.value.trim()
|
||||
: null)
|
||||
: null;
|
||||
|
||||
if (existingOption) {
|
||||
matchedOptionIds.add(existingOption.id);
|
||||
usedSlugs.add(existingOption.value);
|
||||
await tx.categoryVariationOption.update({
|
||||
where: { id: existingOption.id },
|
||||
data: {
|
||||
label,
|
||||
colorHex,
|
||||
sortOrder: optionIndex,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1);
|
||||
await tx.categoryVariationOption.create({
|
||||
data: {
|
||||
variationId: current.id,
|
||||
label,
|
||||
value,
|
||||
colorHex,
|
||||
sortOrder: optionIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const removedOptions = current.options.filter(
|
||||
(option) => !matchedOptionIds.has(option.id),
|
||||
);
|
||||
if (removedOptions.length) {
|
||||
const removedOptionIds = removedOptions.map((option) => option.id);
|
||||
await tx.storeItemVariantSelection.deleteMany({
|
||||
where: { optionId: { in: removedOptionIds } },
|
||||
});
|
||||
await tx.productVariationValue.deleteMany({
|
||||
where: { optionId: { in: removedOptionIds } },
|
||||
});
|
||||
await tx.categoryVariationOption.deleteMany({
|
||||
where: { id: { in: removedOptionIds } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createOptions(
|
||||
tx: Prisma.TransactionClient,
|
||||
variationId: bigint,
|
||||
type: CategoryVariationInputDto['type'],
|
||||
labels: string[],
|
||||
) {
|
||||
const usedSlugs = new Set<string>();
|
||||
await tx.categoryVariationOption.createMany({
|
||||
data: labels.map((label, optionIndex) => ({
|
||||
variationId,
|
||||
label,
|
||||
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
|
||||
colorHex: type === 'color' ? resolveColorHex(label) : null,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private validateVariationInputs(variations: CategoryVariationInputDto[]) {
|
||||
const colorCount = variations.filter((v) => v.type === 'color').length;
|
||||
const sizeCount = variations.filter((v) => v.type === 'size').length;
|
||||
@@ -128,21 +298,9 @@ export class CategoryVariationsService {
|
||||
const customNames = new Set<string>();
|
||||
|
||||
for (const variation of variations) {
|
||||
if (variation.type === 'color') {
|
||||
for (const value of variation.values) {
|
||||
if (!isColorPreset(value.trim())) {
|
||||
throw new BadRequestException(
|
||||
`Invalid color "${value}". Choose from the predefined color palette.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variation.type === 'size' || variation.type === 'custom') {
|
||||
const values = variation.values.map((v) => v.trim()).filter(Boolean);
|
||||
if (!values.length) {
|
||||
throw new BadRequestException('Each variation must have at least one value');
|
||||
}
|
||||
const values = variation.values.map((v) => v.trim()).filter(Boolean);
|
||||
if (!values.length) {
|
||||
throw new BadRequestException('Each variation must have at least one value');
|
||||
}
|
||||
|
||||
if (variation.type === 'custom') {
|
||||
@@ -162,10 +320,10 @@ export class CategoryVariationsService {
|
||||
|
||||
private resolveVariationName(variation: CategoryVariationInputDto): string {
|
||||
if (variation.type === 'color') {
|
||||
return 'Color';
|
||||
return variation.name.trim() || 'Color';
|
||||
}
|
||||
if (variation.type === 'size') {
|
||||
return 'Size';
|
||||
return variation.name.trim() || 'Size';
|
||||
}
|
||||
return variation.name.trim();
|
||||
}
|
||||
@@ -226,7 +384,9 @@ export class CategoryVariationsService {
|
||||
id: option.id.toString(),
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
colorHex: option.colorHex,
|
||||
colorHex:
|
||||
option.colorHex ??
|
||||
(isHexColor(option.value) ? option.value.trim() : null),
|
||||
sortOrder: option.sortOrder,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -16,6 +16,34 @@ export const COLOR_PRESETS = [
|
||||
{ name: 'Beige', hex: '#D4C4A8' },
|
||||
] as const;
|
||||
|
||||
/** Common Farsi labels from legacy migrations → English preset name */
|
||||
const COLOR_ALIASES: Record<string, (typeof COLOR_PRESETS)[number]['name']> = {
|
||||
قرمز: 'Red',
|
||||
آبی: 'Blue',
|
||||
سبز: 'Green',
|
||||
مشکی: 'Black',
|
||||
سیاه: 'Black',
|
||||
سفید: 'White',
|
||||
نقرهای: 'Silver',
|
||||
'نقره ای': 'Silver',
|
||||
نقرهای: 'Silver',
|
||||
طلایی: 'Gold',
|
||||
طلائی: 'Gold',
|
||||
سرمهای: 'Navy',
|
||||
'سرمه ای': 'Navy',
|
||||
سرمهای: 'Navy',
|
||||
زرد: 'Yellow',
|
||||
نارنجی: 'Orange',
|
||||
بنفش: 'Purple',
|
||||
صورتی: 'Pink',
|
||||
قهوهای: 'Brown',
|
||||
'قهوه ای': 'Brown',
|
||||
قهوهای: 'Brown',
|
||||
خاکستری: 'Gray',
|
||||
طوسی: 'Gray',
|
||||
بژ: 'Beige',
|
||||
};
|
||||
|
||||
export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name);
|
||||
|
||||
export function isColorPreset(value: string): boolean {
|
||||
@@ -25,3 +53,27 @@ export function isColorPreset(value: string): boolean {
|
||||
export function getColorHex(name: string): string | null {
|
||||
return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null;
|
||||
}
|
||||
|
||||
function normalizeColorKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\u200c/g, '') // ZWNJ
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** Resolve hex from English preset name or known Farsi alias. */
|
||||
export function resolveColorHex(label: string): string | null {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const fromPreset = getColorHex(trimmed);
|
||||
if (fromPreset) return fromPreset;
|
||||
|
||||
const alias = COLOR_ALIASES[trimmed] ?? COLOR_ALIASES[normalizeColorKey(trimmed)];
|
||||
return alias ? getColorHex(alias) : null;
|
||||
}
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user