diff --git a/src/app/features/admin/products/components/admin-product-form.component.html b/src/app/features/admin/products/components/admin-product-form.component.html index 43935d0..e447da3 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.html +++ b/src/app/features/admin/products/components/admin-product-form.component.html @@ -180,22 +180,15 @@ -
+
{{ 'adminProducts.variants' | translate }}

{{ 'adminProducts.variantsHint' | translate }}

- - - - - - - +
} diff --git a/src/app/features/admin/products/components/admin-product-form.component.ts b/src/app/features/admin/products/components/admin-product-form.component.ts index 392726e..1f47f1e 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.ts +++ b/src/app/features/admin/products/components/admin-product-form.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { AdminProduct, AdminProductAttribute, AdminProductCategoryOption, AdminProductSpecification, AdminProductVariant } from '../models/admin-product.model'; +import { AdminProduct, AdminProductAttribute, AdminProductCategoryOption, AdminProductSpecification } from '../models/admin-product.model'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { InputComponent } from '../../../../shared/ui/input/input.component'; @@ -14,6 +14,7 @@ import { MediaAsset } from '../../../../core/media/models/media-asset.model'; import { MarketplaceHtmlEditorComponent } from '../../../project-editor/components/html-editor/marketplace-html-editor.component'; import { AdminProductHealth } from '../facade/admin-products.facade'; import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component'; +import { ProductVariantsEditorComponent } from './product-variants-editor/product-variants-editor.component'; export type AdminProductEditorGroup = 'general' | 'media' | 'pricing' | 'inventory' | 'categories' | 'attributes' | 'seo' | 'visibility' | 'advanced'; @@ -33,6 +34,7 @@ export type AdminProductEditorGroup = 'general' | 'media' | 'pricing' | 'invento MediaPickerComponent, MarketplaceHtmlEditorComponent, ProductHealthWidgetComponent, + ProductVariantsEditorComponent, ], templateUrl: './admin-product-form.component.html', styleUrls: ['./admin-product-form.component.scss'], @@ -172,7 +174,6 @@ export class AdminProductFormComponent { readonly createSpecRow = () => ({ key: '', value: '' }); readonly createAttributeRow = () => ({ key: '', value: '' }); - readonly createVariantRow = (): AdminProductVariant => ({ name: '', price: 0, quantity: 0 }); updateSpecifications(rows: AdminProductSpecification[]): void { this.productChange.emit({ specifications: rows }); @@ -190,14 +191,6 @@ export class AdminProductFormComponent { this.updateAttributes(this.product.attributes.map((row, i) => i === index ? { ...row, [field]: value } : row)); } - updateVariants(rows: AdminProductVariant[]): void { - this.productChange.emit({ variants: rows }); - } - - updateVariantField(index: number, patch: Partial): void { - this.updateVariants(this.product.variants.map((row, i) => i === index ? { ...row, ...patch } : row)); - } - toggleRelated(id: string, checked: boolean): void { const current = this.product.relatedProductIds; this.productChange.emit({ relatedProductIds: checked ? [...new Set([...current, id])] : current.filter(item => item !== id) }); diff --git a/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.html b/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.html new file mode 100644 index 0000000..80fa9d4 --- /dev/null +++ b/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.html @@ -0,0 +1,105 @@ +
+
+
{{ 'adminProducts.variantAttributes' | translate }}
+

{{ 'adminProducts.variantAttributesHint' | translate }}

+ + @for (attr of attributes; track attr.key) { +
+
+ {{ attr.label }} + @if (attr.isColor) { + {{ 'adminProducts.colorAttribute' | translate }} + } + +
+ +
+ @for (value of attr.values; track value) { + + @if (attr.isColor) { + + } + {{ attr.isColor ? '' : value }} + + + } +
+ +
+ @if (attr.isColor) { + + } @else { + + } + {{ 'adminProducts.addValue' | translate }} +
+
+ } + +
+ + + {{ 'adminProducts.addAttribute' | translate }} +
+ + @if (attributes.length > 0) { + {{ 'adminProducts.generateVariants' | translate }} + } +
+ + @if (variants.length > 0) { +
+
{{ 'adminProducts.variantsList' | translate }}
+ @for (variant of variants; track variant.id) { +
+
+ @for (key of variant.attributes | keyvalue; track key.key) { + + @if (isColorAttribute(key.key)) { + + } + {{ attributeLabel(key.key) }}: {{ key.value }} + + } + +
+ +
+ + + +
+ +
+ @for (priceRow of variant.prices; track priceRow.currency) { +
+ {{ priceRow.currency }} + + +
+ } + @if (availableCurrencies(variant).length > 0) { +
+ @for (currency of availableCurrencies(variant); track currency) { + + {{ currency }} + } +
+ } +
+
+ } +
+ } +
diff --git a/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.scss b/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.scss new file mode 100644 index 0000000..3f4fdbb --- /dev/null +++ b/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.scss @@ -0,0 +1,90 @@ +.variants-editor { display: grid; gap: 16px; } +.variants-editor h5 { margin: 0 0 4px; font-size: 0.9rem; } +.variants-editor__hint { margin: 0 0 8px; color: var(--text-secondary, #6b7280); font-size: 0.85rem; } + +.attribute-card { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-sm); + margin-bottom: 8px; +} + +.attribute-card--new { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } + +.attribute-card__head { display: flex; align-items: center; gap: 8px; } +.attribute-card__tag { + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.4px; + color: var(--text-secondary, #6b7280); border: 1px solid var(--border-color, #d3dad9); + border-radius: 999px; padding: 1px 8px; +} +.attribute-card__remove { + margin-left: auto; border: none; background: transparent; cursor: pointer; + font-size: 1rem; color: var(--text-secondary, #6b7280); + &:hover { color: var(--danger-color, #c0392b); } +} + +.attribute-card__values { display: flex; flex-wrap: wrap; gap: 6px; } +.attribute-card__add { display: flex; align-items: center; gap: 8px; } +.attribute-card__color-toggle { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; } + +.value-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 8px; border-radius: 999px; + border: 1px solid var(--border-color, #d3dad9); + font-size: 0.8rem; + + button { + border: none; background: transparent; cursor: pointer; padding: 0; + color: var(--text-secondary, #6b7280); font-size: 0.85rem; line-height: 1; + } +} + +.value-chip__label:empty { display: none; } + +.value-chip__swatch { + width: 14px; height: 14px; border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.15); + display: inline-block; +} + +.variants-editor__list { display: grid; gap: 10px; } + +.variant-card { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-sm); + background: var(--bg-secondary, #f7f8f8); +} + +.variant-card__combo { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.variant-card__remove { + margin-left: auto; border: none; background: transparent; cursor: pointer; + color: var(--text-secondary, #6b7280); font-size: 0.8rem; text-decoration: underline; +} + +.variant-card__fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + + label { display: grid; gap: 4px; font-size: 0.8rem; color: var(--text-secondary, #6b7280); } +} + +.variant-card__prices { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.price-row { + display: flex; align-items: center; gap: 6px; + + app-input { width: 100px; } + + button { border: none; background: transparent; cursor: pointer; color: var(--text-secondary, #6b7280); } +} +.price-row__currency { font-weight: 700; font-size: 0.8rem; min-width: 32px; } +.price-row--add { gap: 4px; } + +@media (max-width: 640px) { + .variant-card__fields { grid-template-columns: 1fr; } +} diff --git a/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.ts b/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.ts new file mode 100644 index 0000000..a9a2dc0 --- /dev/null +++ b/src/app/features/admin/products/components/product-variants-editor/product-variants-editor.component.ts @@ -0,0 +1,169 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../../shared/ui/button/button.component'; +import { InputComponent } from '../../../../../shared/ui/input/input.component'; +import { ImageFieldComponent } from '../../../../../shared/ui/image-field/image-field.component'; +import { AdminProductVariant, AdminProductVariantAttributeDef, AdminProductVariantPrice } from '../../models/admin-product.model'; + +const DEFAULT_CURRENCIES = ['RUB', 'USD', 'EUR', 'AMD']; + +/** '0x8B4513' (production shape) or '#8B4513' -> '#8b4513' for CSS/. */ +export function toCssColor(value: string): string { + const hex = value.trim().replace(/^0x/i, '#'); + return /^#[0-9a-f]{6}$/i.test(hex) ? hex.toLowerCase() : '#cccccc'; +} + +/** '#8b4513' -> '0x8B4513' to match the production payload shape on save. */ +export function toBackendColor(value: string): string { + return `0x${value.replace('#', '').toUpperCase()}`; +} + +function attributeSignature(attributes: Record): string { + return Object.keys(attributes).sort().map(key => `${key}=${attributes[key]}`).join('|'); +} + +@Component({ + selector: 'app-product-variants-editor', + standalone: true, + imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, ImageFieldComponent], + templateUrl: './product-variants-editor.component.html', + styleUrl: './product-variants-editor.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductVariantsEditorComponent { + @Input() attributes: AdminProductVariantAttributeDef[] = []; + @Input() variants: AdminProductVariant[] = []; + + @Output() attributesChange = new EventEmitter(); + @Output() variantsChange = new EventEmitter(); + + readonly defaultCurrencies = DEFAULT_CURRENCIES; + readonly toCssColor = toCssColor; + + newAttributeName = ''; + newAttributeIsColor = false; + newValueByAttribute: Record = {}; + newColorByAttribute: Record = {}; + + addAttribute(): void { + const label = this.newAttributeName.trim(); + if (!label) { + return; + } + const key = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || `attr-${this.attributes.length + 1}`; + if (this.attributes.some(attr => attr.key === key)) { + return; + } + this.attributesChange.emit([...this.attributes, { key, label, isColor: this.newAttributeIsColor, values: [] }]); + this.newAttributeName = ''; + this.newAttributeIsColor = false; + } + + removeAttribute(key: string): void { + this.attributesChange.emit(this.attributes.filter(attr => attr.key !== key)); + this.variantsChange.emit(this.variants.filter(variant => !(key in variant.attributes))); + } + + addValue(attrKey: string): void { + const attr = this.attributes.find(a => a.key === attrKey); + if (!attr) { + return; + } + const raw = attr.isColor ? this.newColorByAttribute[attrKey] : this.newValueByAttribute[attrKey]; + const value = attr.isColor ? (raw ? toBackendColor(raw) : '') : (raw ?? '').trim(); + if (!value || attr.values.includes(value)) { + return; + } + this.attributesChange.emit(this.attributes.map(a => a.key === attrKey ? { ...a, values: [...a.values, value] } : a)); + this.newValueByAttribute[attrKey] = ''; + } + + removeValue(attrKey: string, value: string): void { + this.attributesChange.emit(this.attributes.map(a => a.key === attrKey ? { ...a, values: a.values.filter(v => v !== value) } : a)); + this.variantsChange.emit(this.variants.filter(variant => variant.attributes[attrKey] !== value)); + } + + /** Cartesian product of all attribute values; keeps existing variant data (price/sku/stock) for combos that already exist. */ + generateVariants(): void { + const withValues = this.attributes.filter(attr => attr.values.length > 0); + if (withValues.length === 0) { + return; + } + + let combos: Record[] = [{}]; + for (const attr of withValues) { + const next: Record[] = []; + for (const combo of combos) { + for (const value of attr.values) { + next.push({ ...combo, [attr.key]: value }); + } + } + combos = next; + } + + const existingBySignature = new Map(this.variants.map(v => [attributeSignature(v.attributes), v])); + const generated = combos.map((attributes, index) => { + const existing = existingBySignature.get(attributeSignature(attributes)); + return existing ?? { + id: `variant-${Date.now()}-${index}`, + attributes, + sku: '', + image: '', + remaining: 0, + prices: [], + }; + }); + this.variantsChange.emit(generated); + } + + attributeLabel(key: string): string { + return this.attributes.find(attr => attr.key === key)?.label ?? key; + } + + isColorAttribute(key: string): boolean { + return !!this.attributes.find(attr => attr.key === key)?.isColor; + } + + updateVariant(id: string, patch: Partial): void { + this.variantsChange.emit(this.variants.map(v => v.id === id ? { ...v, ...patch } : v)); + } + + updateVariantSku(id: string, sku: string): void { + this.updateVariant(id, { sku }); + } + + updateVariantRemaining(id: string, remaining: number): void { + this.updateVariant(id, { remaining }); + } + + updateVariantImage(id: string, image: string): void { + this.updateVariant(id, { image }); + } + + removeVariant(id: string): void { + this.variantsChange.emit(this.variants.filter(v => v.id !== id)); + } + + addPrice(variant: AdminProductVariant, currency: string): void { + if (!currency || variant.prices.some(p => p.currency === currency)) { + return; + } + this.updateVariant(variant.id, { prices: [...variant.prices, { currency, price: 0 }] }); + } + + updatePrice(variant: AdminProductVariant, currency: string, price: number): void { + this.updateVariant(variant.id, { + prices: variant.prices.map((p): AdminProductVariantPrice => p.currency === currency ? { ...p, price } : p), + }); + } + + removePrice(variant: AdminProductVariant, currency: string): void { + this.updateVariant(variant.id, { prices: variant.prices.filter(p => p.currency !== currency) }); + } + + availableCurrencies(variant: AdminProductVariant): string[] { + return this.defaultCurrencies.filter(currency => !variant.prices.some(p => p.currency === currency)); + } +} diff --git a/src/app/features/admin/products/models/admin-product.model.ts b/src/app/features/admin/products/models/admin-product.model.ts index ffd9965..e3ee7f8 100644 --- a/src/app/features/admin/products/models/admin-product.model.ts +++ b/src/app/features/admin/products/models/admin-product.model.ts @@ -13,10 +13,35 @@ export interface AdminProductSpecification { value: string; } -export interface AdminProductVariant { - name: string; +/** + * Matches the production variant shape: a flat list of attribute-value + * combinations, each priced per currency, e.g. {color:'0x8B4513', size:'S', + * price:62560, currency:'RUB', remaining:100}. The admin UI groups rows that + * share the same attribute combo into one AdminProductVariant with multiple + * prices, then flattens back to that shape on save (see + * admin-product-form.factory.ts variantsToBackendRows/variantsFromBackendRows). + */ +export interface AdminProductVariantPrice { + currency: string; price: number; - quantity: number; +} + +export interface AdminProductVariant { + id: string; + /** attribute key -> selected value, e.g. { color: '0x8B4513', size: 'S' } */ + attributes: Record; + sku: string; + image: string; + remaining: number; + prices: AdminProductVariantPrice[]; +} + +export interface AdminProductVariantAttributeDef { + /** lowercase, used as the backend field name (e.g. 'color', 'size') */ + key: string; + label: string; + isColor: boolean; + values: string[]; } export interface AdminProductAttribute { @@ -73,6 +98,7 @@ export interface AdminProduct { htmlDescription: string; specifications: AdminProductSpecification[]; attributes: AdminProductAttribute[]; + variantAttributes: AdminProductVariantAttributeDef[]; variants: AdminProductVariant[]; relatedProductIds: string[]; translations: Record; diff --git a/src/app/features/admin/products/services/admin-products-form.factory.ts b/src/app/features/admin/products/services/admin-products-form.factory.ts index c71f1c0..0301238 100644 --- a/src/app/features/admin/products/services/admin-products-form.factory.ts +++ b/src/app/features/admin/products/services/admin-products-form.factory.ts @@ -26,7 +26,7 @@ export class AdminProductsFormFactory { htmlDescription: '', specifications: [], attributes: [], - variants: [], + variantAttributes: [], variants: [], relatedProductIds: [], translations: { en: {}, ru: {}, hy: {} }, seo: { metaTitle: '', metaDescription: '', keywords: '' }, diff --git a/src/app/features/admin/products/services/admin-products-local.gateway.ts b/src/app/features/admin/products/services/admin-products-local.gateway.ts index 93359ff..659578e 100644 --- a/src/app/features/admin/products/services/admin-products-local.gateway.ts +++ b/src/app/features/admin/products/services/admin-products-local.gateway.ts @@ -159,7 +159,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { htmlDescription: `

${product.subtitle ?? product.title}

`, specifications: [], attributes: [], - variants: [], + variantAttributes: [], variants: [], relatedProductIds: [], translations: { en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `

${product.subtitle ?? product.title}

` }, diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index c87cca3..0160c52 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1554,6 +1554,20 @@ export const en: Translations = { variantName: 'Variant name', variantsHint: 'Each variant is a purchasable option of this product, e.g. a size or color, with its own price and stock.', variants: 'Variants', + variantAttributes: 'Options', + variantAttributesHint: 'Add options like Color or Size, then list the values customers can choose from. Combinations below are generated automatically.', + colorAttribute: 'Color', + removeAttribute: 'Remove option', + removeValue: 'Remove value', + pickColor: 'Pick a color', + newValuePlaceholder: 'e.g. Small', + addValue: 'Add', + newAttributePlaceholder: 'e.g. Size', + addAttribute: 'Add option', + generateVariants: 'Generate variants', + variantsList: 'Variants', + remaining: 'Stock', + variantImage: 'Image', archived: 'Archived', slugHint: 'The web address of this product. Filled in automatically from the name - edit only if you need a custom URL.', skuHint: 'SKU (stock keeping unit) is your internal product code, used for inventory and order lookups. Any unique text works.', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index eafaea9..218e17c 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1549,6 +1549,20 @@ export const hy: Translations = { variantName: 'Տարբերակի անուն', variantsHint: 'Յուրաքանչյուր տարբերակ այս ապրանքի առանձին գնվող տարբերակ է (օր․՝ չափս կամ գույն)՝ իր գնով և մնացորդով։', variants: 'Տարբերակներ', + variantAttributes: 'Ընտրանքներ', + variantAttributesHint: 'Ավելացրեք ընտրանքներ, օրինակ՝ Գույն կամ Չափս, ապա նշեք արժեքները։ Ստորև զուգակցումները ստեղծվում են ինքնաշխատ։', + colorAttribute: 'Գույն', + removeAttribute: 'Հեռացնել ընտրանքը', + removeValue: 'Հեռացնել արժեքը', + pickColor: 'Ընտրել գույնը', + newValuePlaceholder: 'օր.՝ Փոքր', + addValue: 'Ավելացնել', + newAttributePlaceholder: 'օր.՝ Չափս', + addAttribute: 'Ավելացնել ընտրանք', + generateVariants: 'Գեներացնել տարբերակները', + variantsList: 'Տարբերակներ', + remaining: 'Պաշար', + variantImage: 'Նկար', archived: 'Արխիվում', slugHint: 'Ապրանքի վեբ-հասցեն։ Լրացվում է ինքնաշխատ անունից — փոխեք միայն, եթե պետք է հատուկ URL։', skuHint: 'SKU (ապրանքային կոդ) — ձեր ներքին կոդն է պահեստի հաշվառման և պատվերների որոնման համար։ Ցանկացած եզակի տեքստ կաշխատի։', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 1e253d1..9cbc098 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1549,6 +1549,20 @@ export const ru: Translations = { variantName: 'Название варианта', variantsHint: 'Каждый вариант — это отдельный покупаемый вариант товара (например, размер или цвет) со своей ценой и остатком.', variants: 'Варианты', + variantAttributes: 'Опции', + variantAttributesHint: 'Добавьте опции вроде Цвет или Размер, затем перечислите значения. Комбинации ниже создаются автоматически.', + colorAttribute: 'Цвет', + removeAttribute: 'Удалить опцию', + removeValue: 'Удалить значение', + pickColor: 'Выбрать цвет', + newValuePlaceholder: 'напр. Маленький', + addValue: 'Добавить', + newAttributePlaceholder: 'напр. Размер', + addAttribute: 'Добавить опцию', + generateVariants: 'Сгенерировать варианты', + variantsList: 'Варианты', + remaining: 'Остаток', + variantImage: 'Изображение', archived: 'В архиве', slugHint: 'Веб-адрес этого товара. Заполняется автоматически из названия — меняйте, только если нужен особый URL.', skuHint: 'SKU (артикул) — ваш внутренний код товара для учёта склада и поиска заказов. Подойдёт любой уникальный текст.', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index d4a02fc..2a97a7e 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1562,6 +1562,20 @@ export interface Translations { variantName: string; variantsHint: string; variants: string; + variantAttributes: string; + variantAttributesHint: string; + colorAttribute: string; + removeAttribute: string; + removeValue: string; + pickColor: string; + newValuePlaceholder: string; + addValue: string; + newAttributePlaceholder: string; + addAttribute: string; + generateVariants: string; + variantsList: string; + remaining: string; + variantImage: string; archived: string; slugHint: string; skuHint: string;