feat(admin): Shopify-style variant attributes matching production data shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> P0 user feedback: variants were just free-text name+price, and the user shared the real production payload - a flat array of {color, size, price, currency, remaining} rows, colors as '0x8B4513' hex. - New model: AdminProductVariantAttributeDef (key/label/isColor/values) + AdminProductVariant (attributes: Record<string,string>, sku, image, remaining, prices: {currency,price}[]) - this IS the production shape, grouped by combo with one row per currency instead of flattened, so admins edit one variant card instead of 4 duplicate rows - Attribute manager: add custom attributes (Color, Size, or anything), color attributes get a native color picker + live swatch preview instead of typing hex; other attributes get plain value chips - 'Generate variants' computes the cartesian product of attribute values (Color x Size = 4 combos for 1 color x 4 sizes) and preserves existing sku/price/stock data for combos that still exist after regeneration - Multi-currency pricing per variant (matches prod: same combo priced in RUB/USD/EUR/AMD) with per-currency add/remove - Color hex kept in the exact '0x8B4513' production format (toBackendColor/toCssColor conversion helpers) - Fixed an Angular v21 control-flow parser bug hit while building this: an @if/@else block whose only content is a bare {{ interpolation }} touching the block's closing brace fails to parse (NG5002 'Unclosed block for' cascading from a completely unrelated line) - worked around by keeping interpolation in its own element - Verified end-to-end in browser: added Color (color picker) + Size (S/M/L/XL) attributes, generated 4 variant combos, added all 4 currencies to a variant - matches the shared production JSON exactly
This commit is contained in:
@@ -180,22 +180,15 @@
|
|||||||
</app-key-value-editor>
|
</app-key-value-editor>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="form-card__group">
|
<details class="form-card__group" open>
|
||||||
<summary>{{ 'adminProducts.variants' | translate }}</summary>
|
<summary>{{ 'adminProducts.variants' | translate }}</summary>
|
||||||
<p class="form-card__explain">{{ 'adminProducts.variantsHint' | translate }}</p>
|
<p class="form-card__explain">{{ 'adminProducts.variantsHint' | translate }}</p>
|
||||||
<app-key-value-editor
|
<app-product-variants-editor
|
||||||
[rows]="product.variants"
|
[attributes]="product.variantAttributes"
|
||||||
[createRow]="createVariantRow"
|
[variants]="product.variants"
|
||||||
[addLabel]="'adminProducts.addVariant' | translate"
|
(attributesChange)="updateField('variantAttributes', $event)"
|
||||||
[removeLabel]="'adminProducts.removeRow' | translate"
|
(variantsChange)="updateField('variants', $event)"
|
||||||
(rowsChange)="updateVariants($event)"
|
/>
|
||||||
>
|
|
||||||
<ng-template let-row let-i="index">
|
|
||||||
<app-input [ngModel]="row.name" [placeholder]="'adminProducts.variantName' | translate" (ngModelChange)="updateVariantField(i, { name: $event })" />
|
|
||||||
<app-input type="number" [ngModel]="row.price" [placeholder]="'backoffice.price' | translate" (ngModelChange)="updateVariantField(i, { price: +$event })" />
|
|
||||||
<app-input type="number" [ngModel]="row.quantity" [placeholder]="'adminProducts.stockQuantity' | translate" (ngModelChange)="updateVariantField(i, { quantity: +$event })" />
|
|
||||||
</ng-template>
|
|
||||||
</app-key-value-editor>
|
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
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 { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
import { InputComponent } from '../../../../shared/ui/input/input.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 { MarketplaceHtmlEditorComponent } from '../../../project-editor/components/html-editor/marketplace-html-editor.component';
|
||||||
import { AdminProductHealth } from '../facade/admin-products.facade';
|
import { AdminProductHealth } from '../facade/admin-products.facade';
|
||||||
import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component';
|
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';
|
export type AdminProductEditorGroup = 'general' | 'media' | 'pricing' | 'inventory' | 'categories' | 'attributes' | 'seo' | 'visibility' | 'advanced';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export type AdminProductEditorGroup = 'general' | 'media' | 'pricing' | 'invento
|
|||||||
MediaPickerComponent,
|
MediaPickerComponent,
|
||||||
MarketplaceHtmlEditorComponent,
|
MarketplaceHtmlEditorComponent,
|
||||||
ProductHealthWidgetComponent,
|
ProductHealthWidgetComponent,
|
||||||
|
ProductVariantsEditorComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './admin-product-form.component.html',
|
templateUrl: './admin-product-form.component.html',
|
||||||
styleUrls: ['./admin-product-form.component.scss'],
|
styleUrls: ['./admin-product-form.component.scss'],
|
||||||
@@ -172,7 +174,6 @@ export class AdminProductFormComponent {
|
|||||||
|
|
||||||
readonly createSpecRow = () => ({ key: '', value: '' });
|
readonly createSpecRow = () => ({ key: '', value: '' });
|
||||||
readonly createAttributeRow = () => ({ key: '', value: '' });
|
readonly createAttributeRow = () => ({ key: '', value: '' });
|
||||||
readonly createVariantRow = (): AdminProductVariant => ({ name: '', price: 0, quantity: 0 });
|
|
||||||
|
|
||||||
updateSpecifications(rows: AdminProductSpecification[]): void {
|
updateSpecifications(rows: AdminProductSpecification[]): void {
|
||||||
this.productChange.emit({ specifications: rows });
|
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));
|
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<AdminProductVariant>): void {
|
|
||||||
this.updateVariants(this.product.variants.map((row, i) => i === index ? { ...row, ...patch } : row));
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleRelated(id: string, checked: boolean): void {
|
toggleRelated(id: string, checked: boolean): void {
|
||||||
const current = this.product.relatedProductIds;
|
const current = this.product.relatedProductIds;
|
||||||
this.productChange.emit({ relatedProductIds: checked ? [...new Set([...current, id])] : current.filter(item => item !== id) });
|
this.productChange.emit({ relatedProductIds: checked ? [...new Set([...current, id])] : current.filter(item => item !== id) });
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<div class="variants-editor">
|
||||||
|
<div class="variants-editor__attributes">
|
||||||
|
<h5>{{ 'adminProducts.variantAttributes' | translate }}</h5>
|
||||||
|
<p class="variants-editor__hint">{{ 'adminProducts.variantAttributesHint' | translate }}</p>
|
||||||
|
|
||||||
|
@for (attr of attributes; track attr.key) {
|
||||||
|
<div class="attribute-card">
|
||||||
|
<div class="attribute-card__head">
|
||||||
|
<strong>{{ attr.label }}</strong>
|
||||||
|
@if (attr.isColor) {
|
||||||
|
<span class="attribute-card__tag">{{ 'adminProducts.colorAttribute' | translate }}</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="attribute-card__remove" (click)="removeAttribute(attr.key)" [attr.aria-label]="('adminProducts.removeAttribute' | translate) + ' ' + attr.label">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attribute-card__values">
|
||||||
|
@for (value of attr.values; track value) {
|
||||||
|
<span class="value-chip">
|
||||||
|
@if (attr.isColor) {
|
||||||
|
<span class="value-chip__swatch" [style.background]="toCssColor(value)"></span>
|
||||||
|
}
|
||||||
|
<span class="value-chip__label">{{ attr.isColor ? '' : value }}</span>
|
||||||
|
<button type="button" (click)="removeValue(attr.key, value)" [attr.aria-label]="('adminProducts.removeValue' | translate) + ' ' + value">×</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attribute-card__add">
|
||||||
|
@if (attr.isColor) {
|
||||||
|
<input type="color" [(ngModel)]="newColorByAttribute[attr.key]" [attr.aria-label]="'adminProducts.pickColor' | translate" />
|
||||||
|
} @else {
|
||||||
|
<app-input [(ngModel)]="newValueByAttribute[attr.key]" [placeholder]="'adminProducts.newValuePlaceholder' | translate" />
|
||||||
|
}
|
||||||
|
<app-button variant="secondary" size="sm" (click)="addValue(attr.key)">{{ 'adminProducts.addValue' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="attribute-card attribute-card--new">
|
||||||
|
<app-input [(ngModel)]="newAttributeName" [placeholder]="'adminProducts.newAttributePlaceholder' | translate" />
|
||||||
|
<label class="attribute-card__color-toggle">
|
||||||
|
<input type="checkbox" [(ngModel)]="newAttributeIsColor" />
|
||||||
|
{{ 'adminProducts.colorAttribute' | translate }}
|
||||||
|
</label>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="addAttribute()">{{ 'adminProducts.addAttribute' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (attributes.length > 0) {
|
||||||
|
<app-button variant="primary" size="sm" (click)="generateVariants()">{{ 'adminProducts.generateVariants' | translate }}</app-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (variants.length > 0) {
|
||||||
|
<div class="variants-editor__list">
|
||||||
|
<h5>{{ 'adminProducts.variantsList' | translate }}</h5>
|
||||||
|
@for (variant of variants; track variant.id) {
|
||||||
|
<div class="variant-card">
|
||||||
|
<div class="variant-card__combo">
|
||||||
|
@for (key of variant.attributes | keyvalue; track key.key) {
|
||||||
|
<span class="value-chip">
|
||||||
|
@if (isColorAttribute(key.key)) {
|
||||||
|
<span class="value-chip__swatch" [style.background]="toCssColor(key.value)"></span>
|
||||||
|
}
|
||||||
|
{{ attributeLabel(key.key) }}: {{ key.value }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="variant-card__remove" (click)="removeVariant(variant.id)">{{ 'adminProducts.removeRow' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="variant-card__fields">
|
||||||
|
<label>
|
||||||
|
<span>{{ 'backoffice.sku' | translate }}</span>
|
||||||
|
<app-input [ngModel]="variant.sku" (ngModelChange)="updateVariantSku(variant.id, $event)" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ 'adminProducts.remaining' | translate }}</span>
|
||||||
|
<app-input type="number" [ngModel]="variant.remaining" (ngModelChange)="updateVariantRemaining(variant.id, +$event)" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ 'adminProducts.variantImage' | translate }}</span>
|
||||||
|
<app-image-field [value]="variant.image" (valueChange)="updateVariantImage(variant.id, $event)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="variant-card__prices">
|
||||||
|
@for (priceRow of variant.prices; track priceRow.currency) {
|
||||||
|
<div class="price-row">
|
||||||
|
<span class="price-row__currency">{{ priceRow.currency }}</span>
|
||||||
|
<app-input type="number" [ngModel]="priceRow.price" (ngModelChange)="updatePrice(variant, priceRow.currency, +$event)" />
|
||||||
|
<button type="button" (click)="removePrice(variant, priceRow.currency)" [attr.aria-label]="('adminProducts.removeRow' | translate) + ' ' + priceRow.currency">×</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (availableCurrencies(variant).length > 0) {
|
||||||
|
<div class="price-row price-row--add">
|
||||||
|
@for (currency of availableCurrencies(variant); track currency) {
|
||||||
|
<app-button variant="secondary" size="sm" (click)="addPrice(variant, currency)">+ {{ currency }}</app-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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/<input type=color>. */
|
||||||
|
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, string>): 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<AdminProductVariantAttributeDef[]>();
|
||||||
|
@Output() variantsChange = new EventEmitter<AdminProductVariant[]>();
|
||||||
|
|
||||||
|
readonly defaultCurrencies = DEFAULT_CURRENCIES;
|
||||||
|
readonly toCssColor = toCssColor;
|
||||||
|
|
||||||
|
newAttributeName = '';
|
||||||
|
newAttributeIsColor = false;
|
||||||
|
newValueByAttribute: Record<string, string> = {};
|
||||||
|
newColorByAttribute: Record<string, string> = {};
|
||||||
|
|
||||||
|
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<string, string>[] = [{}];
|
||||||
|
for (const attr of withValues) {
|
||||||
|
const next: Record<string, string>[] = [];
|
||||||
|
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<AdminProductVariant>): 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,35 @@ export interface AdminProductSpecification {
|
|||||||
value: string;
|
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;
|
price: number;
|
||||||
quantity: number;
|
}
|
||||||
|
|
||||||
|
export interface AdminProductVariant {
|
||||||
|
id: string;
|
||||||
|
/** attribute key -> selected value, e.g. { color: '0x8B4513', size: 'S' } */
|
||||||
|
attributes: Record<string, string>;
|
||||||
|
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 {
|
export interface AdminProductAttribute {
|
||||||
@@ -73,6 +98,7 @@ export interface AdminProduct {
|
|||||||
htmlDescription: string;
|
htmlDescription: string;
|
||||||
specifications: AdminProductSpecification[];
|
specifications: AdminProductSpecification[];
|
||||||
attributes: AdminProductAttribute[];
|
attributes: AdminProductAttribute[];
|
||||||
|
variantAttributes: AdminProductVariantAttributeDef[];
|
||||||
variants: AdminProductVariant[];
|
variants: AdminProductVariant[];
|
||||||
relatedProductIds: string[];
|
relatedProductIds: string[];
|
||||||
translations: Record<string, AdminProductTranslation>;
|
translations: Record<string, AdminProductTranslation>;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class AdminProductsFormFactory {
|
|||||||
htmlDescription: '',
|
htmlDescription: '',
|
||||||
specifications: [],
|
specifications: [],
|
||||||
attributes: [],
|
attributes: [],
|
||||||
variants: [],
|
variantAttributes: [], variants: [],
|
||||||
relatedProductIds: [],
|
relatedProductIds: [],
|
||||||
translations: { en: {}, ru: {}, hy: {} },
|
translations: { en: {}, ru: {}, hy: {} },
|
||||||
seo: { metaTitle: '', metaDescription: '', keywords: '' },
|
seo: { metaTitle: '', metaDescription: '', keywords: '' },
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
|
|||||||
htmlDescription: `<p>${product.subtitle ?? product.title}</p>`,
|
htmlDescription: `<p>${product.subtitle ?? product.title}</p>`,
|
||||||
specifications: [],
|
specifications: [],
|
||||||
attributes: [],
|
attributes: [],
|
||||||
variants: [],
|
variantAttributes: [], variants: [],
|
||||||
relatedProductIds: [],
|
relatedProductIds: [],
|
||||||
translations: {
|
translations: {
|
||||||
en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `<p>${product.subtitle ?? product.title}</p>` },
|
en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `<p>${product.subtitle ?? product.title}</p>` },
|
||||||
|
|||||||
@@ -1554,6 +1554,20 @@ export const en: Translations = {
|
|||||||
variantName: 'Variant name',
|
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.',
|
variantsHint: 'Each variant is a purchasable option of this product, e.g. a size or color, with its own price and stock.',
|
||||||
variants: 'Variants',
|
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',
|
archived: 'Archived',
|
||||||
slugHint: 'The web address of this product. Filled in automatically from the name - edit only if you need a custom URL.',
|
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.',
|
skuHint: 'SKU (stock keeping unit) is your internal product code, used for inventory and order lookups. Any unique text works.',
|
||||||
|
|||||||
@@ -1549,6 +1549,20 @@ export const hy: Translations = {
|
|||||||
variantName: 'Տարբերակի անուն',
|
variantName: 'Տարբերակի անուն',
|
||||||
variantsHint: 'Յուրաքանչյուր տարբերակ այս ապրանքի առանձին գնվող տարբերակ է (օր․՝ չափս կամ գույն)՝ իր գնով և մնացորդով։',
|
variantsHint: 'Յուրաքանչյուր տարբերակ այս ապրանքի առանձին գնվող տարբերակ է (օր․՝ չափս կամ գույն)՝ իր գնով և մնացորդով։',
|
||||||
variants: 'Տարբերակներ',
|
variants: 'Տարբերակներ',
|
||||||
|
variantAttributes: 'Ընտրանքներ',
|
||||||
|
variantAttributesHint: 'Ավելացրեք ընտրանքներ, օրինակ՝ Գույն կամ Չափս, ապա նշեք արժեքները։ Ստորև զուգակցումները ստեղծվում են ինքնաշխատ։',
|
||||||
|
colorAttribute: 'Գույն',
|
||||||
|
removeAttribute: 'Հեռացնել ընտրանքը',
|
||||||
|
removeValue: 'Հեռացնել արժեքը',
|
||||||
|
pickColor: 'Ընտրել գույնը',
|
||||||
|
newValuePlaceholder: 'օր.՝ Փոքր',
|
||||||
|
addValue: 'Ավելացնել',
|
||||||
|
newAttributePlaceholder: 'օր.՝ Չափս',
|
||||||
|
addAttribute: 'Ավելացնել ընտրանք',
|
||||||
|
generateVariants: 'Գեներացնել տարբերակները',
|
||||||
|
variantsList: 'Տարբերակներ',
|
||||||
|
remaining: 'Պաշար',
|
||||||
|
variantImage: 'Նկար',
|
||||||
archived: 'Արխիվում',
|
archived: 'Արխիվում',
|
||||||
slugHint: 'Ապրանքի վեբ-հասցեն։ Լրացվում է ինքնաշխատ անունից — փոխեք միայն, եթե պետք է հատուկ URL։',
|
slugHint: 'Ապրանքի վեբ-հասցեն։ Լրացվում է ինքնաշխատ անունից — փոխեք միայն, եթե պետք է հատուկ URL։',
|
||||||
skuHint: 'SKU (ապրանքային կոդ) — ձեր ներքին կոդն է պահեստի հաշվառման և պատվերների որոնման համար։ Ցանկացած եզակի տեքստ կաշխատի։',
|
skuHint: 'SKU (ապրանքային կոդ) — ձեր ներքին կոդն է պահեստի հաշվառման և պատվերների որոնման համար։ Ցանկացած եզակի տեքստ կաշխատի։',
|
||||||
|
|||||||
@@ -1549,6 +1549,20 @@ export const ru: Translations = {
|
|||||||
variantName: 'Название варианта',
|
variantName: 'Название варианта',
|
||||||
variantsHint: 'Каждый вариант — это отдельный покупаемый вариант товара (например, размер или цвет) со своей ценой и остатком.',
|
variantsHint: 'Каждый вариант — это отдельный покупаемый вариант товара (например, размер или цвет) со своей ценой и остатком.',
|
||||||
variants: 'Варианты',
|
variants: 'Варианты',
|
||||||
|
variantAttributes: 'Опции',
|
||||||
|
variantAttributesHint: 'Добавьте опции вроде Цвет или Размер, затем перечислите значения. Комбинации ниже создаются автоматически.',
|
||||||
|
colorAttribute: 'Цвет',
|
||||||
|
removeAttribute: 'Удалить опцию',
|
||||||
|
removeValue: 'Удалить значение',
|
||||||
|
pickColor: 'Выбрать цвет',
|
||||||
|
newValuePlaceholder: 'напр. Маленький',
|
||||||
|
addValue: 'Добавить',
|
||||||
|
newAttributePlaceholder: 'напр. Размер',
|
||||||
|
addAttribute: 'Добавить опцию',
|
||||||
|
generateVariants: 'Сгенерировать варианты',
|
||||||
|
variantsList: 'Варианты',
|
||||||
|
remaining: 'Остаток',
|
||||||
|
variantImage: 'Изображение',
|
||||||
archived: 'В архиве',
|
archived: 'В архиве',
|
||||||
slugHint: 'Веб-адрес этого товара. Заполняется автоматически из названия — меняйте, только если нужен особый URL.',
|
slugHint: 'Веб-адрес этого товара. Заполняется автоматически из названия — меняйте, только если нужен особый URL.',
|
||||||
skuHint: 'SKU (артикул) — ваш внутренний код товара для учёта склада и поиска заказов. Подойдёт любой уникальный текст.',
|
skuHint: 'SKU (артикул) — ваш внутренний код товара для учёта склада и поиска заказов. Подойдёт любой уникальный текст.',
|
||||||
|
|||||||
@@ -1562,6 +1562,20 @@ export interface Translations {
|
|||||||
variantName: string;
|
variantName: string;
|
||||||
variantsHint: string;
|
variantsHint: string;
|
||||||
variants: 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;
|
archived: string;
|
||||||
slugHint: string;
|
slugHint: string;
|
||||||
skuHint: string;
|
skuHint: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user