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:
Alireza Hassani
2026-08-26 19:02:38 +03:30
co-authored by Cursor
parent 5850c90d84
commit 730407ec3a
14 changed files with 839 additions and 113 deletions
+206 -46
View File
@@ -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,
})),
};
+52
View File
@@ -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());
}