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:
sdarbinyan
2026-07-19 13:40:51 +04:00
parent 440d2ec211
commit 3b955b116a
12 changed files with 461 additions and 29 deletions

View File

@@ -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>
} }

View File

@@ -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) });

View File

@@ -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">&times;</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">&times;</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">&times;</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>

View File

@@ -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; }
}

View File

@@ -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));
}
}

View File

@@ -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>;

View File

@@ -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: '' },

View File

@@ -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>` },

View File

@@ -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.',

View File

@@ -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 (ապրանքային կոդ) — ձեր ներքին կոդն է պահեստի հաշվառման և պատվերների որոնման համար։ Ցանկացած եզակի տեքստ կաշխատի։',

View File

@@ -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 (артикул) — ваш внутренний код товара для учёта склада и поиска заказов. Подойдёт любой уникальный текст.',

View File

@@ -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;