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>
|
||||
</details>
|
||||
|
||||
<details class="form-card__group">
|
||||
<details class="form-card__group" open>
|
||||
<summary>{{ 'adminProducts.variants' | translate }}</summary>
|
||||
<p class="form-card__explain">{{ 'adminProducts.variantsHint' | translate }}</p>
|
||||
<app-key-value-editor
|
||||
[rows]="product.variants"
|
||||
[createRow]="createVariantRow"
|
||||
[addLabel]="'adminProducts.addVariant' | translate"
|
||||
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||
(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>
|
||||
<app-product-variants-editor
|
||||
[attributes]="product.variantAttributes"
|
||||
[variants]="product.variants"
|
||||
(attributesChange)="updateField('variantAttributes', $event)"
|
||||
(variantsChange)="updateField('variants', $event)"
|
||||
/>
|
||||
</details>
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AdminProductVariant>): 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) });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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<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 {
|
||||
@@ -73,6 +98,7 @@ export interface AdminProduct {
|
||||
htmlDescription: string;
|
||||
specifications: AdminProductSpecification[];
|
||||
attributes: AdminProductAttribute[];
|
||||
variantAttributes: AdminProductVariantAttributeDef[];
|
||||
variants: AdminProductVariant[];
|
||||
relatedProductIds: string[];
|
||||
translations: Record<string, AdminProductTranslation>;
|
||||
|
||||
@@ -26,7 +26,7 @@ export class AdminProductsFormFactory {
|
||||
htmlDescription: '',
|
||||
specifications: [],
|
||||
attributes: [],
|
||||
variants: [],
|
||||
variantAttributes: [], variants: [],
|
||||
relatedProductIds: [],
|
||||
translations: { en: {}, ru: {}, hy: {} },
|
||||
seo: { metaTitle: '', metaDescription: '', keywords: '' },
|
||||
|
||||
@@ -159,7 +159,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway {
|
||||
htmlDescription: `<p>${product.subtitle ?? product.title}</p>`,
|
||||
specifications: [],
|
||||
attributes: [],
|
||||
variants: [],
|
||||
variantAttributes: [], variants: [],
|
||||
relatedProductIds: [],
|
||||
translations: {
|
||||
en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `<p>${product.subtitle ?? product.title}</p>` },
|
||||
|
||||
Reference in New Issue
Block a user