Files
web-frontend/app/components/catalog/CatalogConfigurator.vue
2026-04-09 19:14:14 +07:00

786 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import {
CatalogProductTypeSettingsDocument,
ClientProductsDocument,
type CatalogProductTypeSettingsQuery,
type ClientProductsQuery,
} from '~/composables/graphql/generated';
import { useClientCart } from '~/composables/useClientCart';
const props = defineProps<{
productTypeSlug: string;
}>();
type ProductNode = ClientProductsQuery['clientProducts'][number];
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
type ParamValue = number | string;
type ParsedProduct = ProductNode & {
productTypeLabel: string;
quantityPerBoxOptions: string[];
normalizedTags: string[];
colorTags: string[];
labelTags: string[];
};
type ProductGroup = {
key: string;
typeLabel: string;
products: ParsedProduct[];
};
type GroupState = {
widthMm: number | null;
lengthM: number | null;
thicknessMicron: number | null;
sleeveBrand: string | null;
quantityPerBox: string | null;
colorTag: string | null;
labelTag: string | null;
};
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox', 'colorTag', 'labelTag'];
const DEFAULT_CATALOG_PRODUCT_TYPE_SETTING: CatalogProductTypeSettingNode = {
productType: '',
showQuantityPerBox: false,
allowCustomLength: false,
customLengthMinM: null,
customLengthMaxM: null,
customLengthStepM: null,
allowCustomSleeveBrand: false,
allowCustomLabel: false,
widthOptionsMm: [],
lengthOptionsM: [],
thicknessOptionsMicron: [],
sleeveOptions: [],
colorOptions: [],
labelOptions: [],
};
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
{ key: 'widthMm', label: 'Ширина' },
{ key: 'lengthM', label: 'Длина' },
{ key: 'thicknessMicron', label: 'Толщина' },
{ key: 'sleeveBrand', label: 'Втулка' },
{ key: 'quantityPerBox', label: 'Короб' },
{ key: 'colorTag', label: 'Цвет' },
{ key: 'labelTag', label: 'Надпись' },
];
const coverPresets = [
['#d9f5e6', '#9ce8c1', '#6fd09d'],
['#eaf9ef', '#b3e8cb', '#76c89f'],
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
];
const productsQuery = useQuery(ClientProductsDocument);
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
const groupStates = reactive<Record<string, GroupState>>({});
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
const loading = computed(() => productsQuery.loading.value || catalogSettingsQuery.loading.value);
const error = computed(() => productsQuery.error.value || catalogSettingsQuery.error.value);
const catalogSettingsByType = computed<Record<string, CatalogProductTypeSettingNode>>(() => (
Object.fromEntries(
(catalogSettingsQuery.result.value?.catalogProductTypeSettings ?? [])
.map((setting) => [setting.productType, setting]),
)
));
function normalizeText(value: string | null | undefined) {
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
}
function splitBoxValues(value: string | null | undefined) {
return normalizeText(value)
.split('/')
.map((item) => item.trim())
.filter(Boolean);
}
function createProductCover(name: string, sku: string) {
const seed = `${name}${sku}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const [start, middle, finish] = coverPresets[seed % coverPresets.length];
const firstLetter = name.trim().charAt(0).toUpperCase() || 'P';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${start}" />
<stop offset="55%" stop-color="${middle}" />
<stop offset="100%" stop-color="${finish}" />
</linearGradient>
</defs>
<rect width="320" height="220" fill="url(#g)" rx="22" />
<g opacity="0.15">
<circle cx="266" cy="45" r="55" fill="#0f7a49" />
<circle cx="42" cy="198" r="55" fill="#0f7a49" />
</g>
<text x="50%" y="56%" text-anchor="middle" fill="#11412c" font-family="Manrope, sans-serif" font-size="84" font-weight="700">${firstLetter}</text>
</svg>
`.trim();
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function hydrateProduct(product: ProductNode): ParsedProduct {
const normalizedTags = product.tags.map((tag) => normalizeText(tag)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'ru'));
return {
...product,
productTypeLabel: normalizeText(product.productType) || 'Без типа',
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
normalizedTags,
colorTags: normalizedTags.filter((tag) => COLOR_TAGS.includes(tag)),
labelTags: normalizedTags.filter((tag) => LABEL_TAGS.includes(tag)),
};
}
function productSortValue(value: number | null | undefined) {
return value ?? Number.MAX_SAFE_INTEGER;
}
function compareProducts(a: ParsedProduct, b: ParsedProduct) {
const byWidth = productSortValue(a.widthMm) - productSortValue(b.widthMm);
if (byWidth !== 0) {
return byWidth;
}
const byLength = productSortValue(a.lengthM) - productSortValue(b.lengthM);
if (byLength !== 0) {
return byLength;
}
const byThickness = productSortValue(a.thicknessMicron) - productSortValue(b.thicknessMicron);
if (byThickness !== 0) {
return byThickness;
}
const bySleeve = normalizeText(a.sleeveBrand).localeCompare(normalizeText(b.sleeveBrand), 'ru');
if (bySleeve !== 0) {
return bySleeve;
}
return a.sku.localeCompare(b.sku, 'ru');
}
const parsedProducts = computed<ParsedProduct[]>(() => {
const list = productsQuery.result.value?.clientProducts ?? [];
return list
.map(hydrateProduct)
.sort(compareProducts);
});
const productGroups = computed<ProductGroup[]>(() => {
const map = new Map<string, ParsedProduct[]>();
for (const product of parsedProducts.value) {
const existing = map.get(product.productTypeLabel);
if (existing) {
existing.push(product);
} else {
map.set(product.productTypeLabel, [product]);
}
}
return [...map.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'ru'))
.map(([typeLabel, products]) => ({
key: typeLabel
.toLowerCase()
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^-|-$/g, ''),
typeLabel,
products: [...products].sort(compareProducts),
}));
});
function sortParamValues(values: ParamValue[]) {
return [...values].sort((a, b) => {
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
return String(a).localeCompare(String(b), 'ru');
});
}
function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
const values = new Set<ParamValue>();
for (const product of group.products) {
if (field === 'quantityPerBox') {
for (const option of product.quantityPerBoxOptions) {
values.add(option);
}
continue;
}
if (field === 'colorTag') {
for (const tag of product.colorTags) {
values.add(tag);
}
continue;
}
if (field === 'labelTag') {
for (const tag of product.labelTags) {
values.add(tag);
}
continue;
}
const value = product[field];
if (value !== null && value !== undefined) {
values.add(value);
}
}
return sortParamValues([...values]);
}
function groupCatalogSetting(group: ProductGroup) {
return catalogSettingsByType.value[group.typeLabel] ?? {
...DEFAULT_CATALOG_PRODUCT_TYPE_SETTING,
productType: group.typeLabel,
};
}
function visibleFields(group: ProductGroup) {
return parameterFields.filter((field) => {
if (field.key === 'quantityPerBox') {
return false;
}
return getAllFieldOptions(group, field.key).length > 1;
});
}
const selectedGroup = computed(() => productGroups.value.find((group) => group.key === props.productTypeSlug) ?? null);
const currentGroupIndex = computed(() => productGroups.value.findIndex((group) => group.key === props.productTypeSlug));
const previousGroup = computed(() => {
if (currentGroupIndex.value <= 0) {
return null;
}
return productGroups.value[currentGroupIndex.value - 1] ?? null;
});
const nextGroup = computed(() => {
if (currentGroupIndex.value < 0 || currentGroupIndex.value >= productGroups.value.length - 1) {
return null;
}
return productGroups.value[currentGroupIndex.value + 1] ?? null;
});
function requiredKeys(group: ProductGroup) {
return visibleFields(group).map((field) => field.key);
}
function createGroupState(group: ProductGroup): GroupState {
const firstProduct = group.products[0];
return {
widthMm: firstProduct?.widthMm ?? null,
lengthM: firstProduct?.lengthM ?? null,
thicknessMicron: firstProduct?.thicknessMicron ?? null,
sleeveBrand: firstProduct?.sleeveBrand ?? null,
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
colorTag: firstProduct?.colorTags[0] ?? null,
labelTag: firstProduct?.labelTags[0] ?? null,
};
}
watch(
productGroups,
(groups) => {
const keys = new Set(groups.map((group) => group.key));
for (const key of Object.keys(groupStates)) {
if (!keys.has(key)) {
Reflect.deleteProperty(groupStates, key);
}
}
for (const group of groups) {
groupStates[group.key] ??= createGroupState(group);
}
},
{ immediate: true },
);
function getGroupState(group: ProductGroup) {
groupStates[group.key] ??= createGroupState(group);
return groupStates[group.key];
}
function matchesProductState(product: ParsedProduct, state: GroupState, keys: ParamFieldKey[]) {
return keys.every((key) => {
if (state[key] === null) {
return true;
}
if (key === 'quantityPerBox') {
return product.quantityPerBoxOptions.includes(String(state[key]));
}
if (key === 'colorTag') {
return product.colorTags.includes(String(state[key]));
}
if (key === 'labelTag') {
return product.labelTags.includes(String(state[key]));
}
return product[key] === state[key];
});
}
function selectedProduct(group: ProductGroup) {
const keys = requiredKeys(group);
const state = getGroupState(group);
if (keys.length === 0) {
return group.products[0] ?? null;
}
if (keys.some((key) => state[key] === null)) {
return null;
}
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
return matches[0] ?? null;
}
function formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
if (field === 'widthMm') {
return `${value} мм`;
}
if (field === 'lengthM') {
return `${value} м`;
}
if (field === 'thicknessMicron') {
return `${value} мкм`;
}
return String(value);
}
function productHasOption(product: ParsedProduct, field: ParamFieldKey, option: ParamValue) {
if (field === 'quantityPerBox') {
return product.quantityPerBoxOptions.includes(String(option));
}
if (field === 'colorTag') {
return product.colorTags.includes(String(option));
}
if (field === 'labelTag') {
return product.labelTags.includes(String(option));
}
return product[field] === option;
}
function isOptionAvailable(group: ProductGroup, field: ParamFieldKey, option: ParamValue) {
const state = getGroupState(group);
const scopedState = {
...state,
[field]: option,
} satisfies GroupState;
return group.products.some((product) => matchesProductState(product, scopedState, requiredKeys(group)));
}
function pickBestProductForOption(group: ProductGroup, field: ParamFieldKey, option: ParamValue) {
const state = getGroupState(group);
const candidates = group.products.filter((product) => productHasOption(product, field, option));
if (candidates.length === 0) {
return null;
}
return [...candidates].sort((a, b) => {
let aScore = 0;
let bScore = 0;
for (const key of PARAM_KEYS) {
if (key === field) {
continue;
}
const selectedValue = state[key];
if (selectedValue === null) {
continue;
}
if (productHasOption(a, key, selectedValue)) {
aScore += 1;
}
if (productHasOption(b, key, selectedValue)) {
bScore += 1;
}
}
if (aScore !== bScore) {
return bScore - aScore;
}
return compareProducts(a, b);
})[0];
}
function applyProductToState(state: GroupState, product: ParsedProduct, preferredBoxOption: ParamValue | null = null) {
state.widthMm = product.widthMm ?? null;
state.lengthM = product.lengthM ?? null;
state.thicknessMicron = product.thicknessMicron ?? null;
state.sleeveBrand = product.sleeveBrand ?? null;
state.colorTag = product.colorTags[0] ?? null;
state.labelTag = product.labelTags[0] ?? null;
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
state.quantityPerBox = String(preferredBoxOption);
return;
}
state.quantityPerBox = product.quantityPerBoxOptions[0] ?? null;
}
function updateField(group: ProductGroup, field: ParamFieldKey, value: ParamValue) {
const state = getGroupState(group);
const fallback = pickBestProductForOption(group, field, value);
if (!fallback) {
return;
}
applyProductToState(state, fallback, field === 'quantityPerBox' ? value : state.quantityPerBox);
if (field === 'quantityPerBox') {
state.quantityPerBox = String(value);
return;
}
state[field] = value as GroupState[typeof field];
}
function articleLabel(group: ProductGroup) {
return selectedProduct(group)?.sku ?? '—';
}
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
return null;
}
return `${setting.customLengthMinM}-${setting.customLengthMaxM} м, шаг ${setting.customLengthStepM} м`;
}
function fieldHelperText(group: ProductGroup, field: ParamFieldKey) {
const setting = groupCatalogSetting(group);
if (field === 'widthMm') {
return 'Ширина рулона. От неё зависит, насколько широкую полосу материала вы получите.';
}
if (field === 'lengthM') {
const customRange = formatLengthRange(setting);
if (setting.allowCustomLength && customRange) {
return `Выберите стандартную длину или закажите свою. Доступный диапазон: ${customRange}.`;
}
return 'Длина рулона в метрах. Здесь выбирается стандартный доступный размер.';
}
if (field === 'thicknessMicron') {
return 'Толщина материала. Чем выше значение, тем плотнее и заметнее сама лента.';
}
if (field === 'sleeveBrand') {
if (setting.allowCustomSleeveBrand) {
return 'Тип втулки внутри рулона. При необходимости можем сделать втулку с вашим логотипом.';
}
return 'Тип втулки внутри рулона. Выберите подходящий стандартный вариант.';
}
if (field === 'colorTag') {
return 'Цвет или визуальное исполнение ленты. Он влияет на внешний вид готового продукта.';
}
if (field === 'labelTag') {
if (setting.allowCustomLabel) {
return 'Готовая надпись или маркировка. Если нужно, можем нанести и вашу собственную надпись.';
}
return 'Готовая надпись или маркировка на ленте для стандартных сценариев использования.';
}
return 'Выберите подходящий параметр для этой позиции.';
}
function customizationDetails(group: ProductGroup) {
const setting = groupCatalogSetting(group);
const details: string[] = [];
const customRange = formatLengthRange(setting);
if (setting.allowCustomLength && customRange) {
details.push(`Длина под заказ: ${customRange}.`);
}
if (setting.allowCustomSleeveBrand) {
details.push('Втулка под заказ: можно сделать с вашим логотипом.');
}
if (setting.allowCustomLabel) {
details.push('Надпись под заказ: можно нанести вашу маркировку или текст.');
}
return details;
}
function incrementProduct(product: ProductNode) {
if (getQuantity(product.id) === 0) {
addProduct({
id: product.id,
name: product.name,
sku: product.sku,
isCustomizable: product.isCustomizable,
});
return;
}
incrementQuantity(product.id);
}
function decrementProduct(productId: string) {
decrementQuantity(productId);
}
function selectedQty(group: ProductGroup) {
const product = selectedProduct(group);
return product ? getQuantity(product.id) : 0;
}
function incrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
incrementProduct(product);
}
}
function decrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
decrementProduct(product.id);
}
}
function productDetailPath(group: ProductGroup) {
return `/products/${group.key}`;
}
</script>
<template>
<section class="space-y-5">
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
<div v-else-if="selectedGroup" class="space-y-4">
<NuxtLink
v-if="previousGroup"
:to="productDetailPath(previousGroup)"
class="fixed left-6 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-[#dce9e1] bg-white text-xl text-[#163624] shadow-[0_12px_28px_rgba(18,56,36,0.08)] 2xl:flex"
aria-label="Предыдущий товар"
>
</NuxtLink>
<NuxtLink
v-if="nextGroup"
:to="productDetailPath(nextGroup)"
class="fixed right-6 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-[#dce9e1] bg-white text-xl text-[#163624] shadow-[0_12px_28px_rgba(18,56,36,0.08)] 2xl:flex"
aria-label="Следующий товар"
>
</NuxtLink>
<div class="flex items-center justify-between gap-3">
<NuxtLink to="/products" class="btn btn-ghost rounded-full px-3 text-[#163624]">
Назад
</NuxtLink>
<div class="flex items-center gap-2">
<NuxtLink
v-if="previousGroup"
:to="productDetailPath(previousGroup)"
class="btn btn-ghost btn-circle"
aria-label="Предыдущий товар"
>
</NuxtLink>
<NuxtLink
v-if="nextGroup"
:to="productDetailPath(nextGroup)"
class="btn btn-ghost btn-circle"
aria-label="Следующий товар"
>
</NuxtLink>
</div>
</div>
<div class="space-y-4">
<div class="rounded-[28px] border border-[#e6efe9] bg-white p-5 md:p-7">
<h1 class="text-3xl font-bold text-[#163624]">{{ selectedGroup.typeLabel }}</h1>
<div v-if="customizationDetails(selectedGroup).length" class="mt-4 flex flex-wrap gap-2">
<span
v-for="note in customizationDetails(selectedGroup)"
:key="`${selectedGroup.key}-${note}`"
class="rounded-full border border-[#dce9e1] bg-white px-3 py-1 text-xs font-semibold text-[#355947]"
>
{{ note }}
</span>
</div>
</div>
<div class="space-y-3">
<article
v-for="field in visibleFields(selectedGroup)"
:key="`${selectedGroup.key}-${field.key}`"
class="rounded-[24px] border border-[#e6efe9] bg-white p-5"
>
<div class="space-y-4">
<div class="space-y-2">
<h2 class="text-lg font-semibold text-[#163624]">{{ field.label }}</h2>
<p class="text-sm leading-6 text-[#5d7468]">{{ fieldHelperText(selectedGroup, field.key) }}</p>
</div>
<div class="flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
:key="`${selectedGroup.key}-${field.key}-${option}`"
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
:class="[
getGroupState(selectedGroup)[field.key] === option
? 'bg-neutral text-neutral-content'
: isOptionAvailable(selectedGroup, field.key, option)
? 'bg-base-100 text-base-content hover:bg-base-200'
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
'cursor-pointer',
]"
>
<input
type="radio"
class="sr-only"
:name="`${selectedGroup.key}-${field.key}`"
:checked="getGroupState(selectedGroup)[field.key] === option"
@change="updateField(selectedGroup, field.key, option)"
>
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
</div>
</div>
</article>
<article class="rounded-[24px] border border-[#e6efe9] bg-white p-5">
<div class="space-y-4">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-[#163624]">В корзину</h2>
<p class="text-sm leading-6 text-[#5d7468]">
Артикул: <span class="font-semibold text-[#163624]">{{ articleLabel(selectedGroup) }}</span>
</p>
</div>
<button
v-if="selectedQty(selectedGroup) === 0"
class="btn h-11 rounded-full border-0 bg-[#139957] px-6 text-sm font-semibold text-white hover:bg-[#0d854a]"
:disabled="!selectedProduct(selectedGroup)"
@click="incrementSelected(selectedGroup)"
>
В корзину
</button>
<div v-else class="inline-flex rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
<div class="flex items-center justify-between gap-2">
<button
class="btn btn-square btn-sm"
:disabled="selectedQty(selectedGroup) === 0"
@click="decrementSelected(selectedGroup)"
>
-
</button>
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(selectedGroup)" @click="incrementSelected(selectedGroup)">
+
</button>
</div>
</div>
</div>
</article>
<article class="rounded-[24px] border border-[#e6efe9] bg-white p-5">
<div class="space-y-4">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-[#163624]">Товар в наличии</h2>
<p class="text-sm leading-6 text-[#5d7468]">
Все доступные варианты по этому типу товара.
</p>
</div>
<div class="overflow-x-auto rounded-[20px] border border-[#edf4ef] bg-white">
<table class="table border-separate border-spacing-0 bg-white [&_tbody_tr:hover]:bg-white [&_tbody_tr]:bg-white [&_td]:bg-white [&_th]:bg-white [&_thead_tr]:bg-white">
<thead>
<tr>
<th class="border-b border-base-300">Артикул</th>
<th class="border-b border-base-300">Ширина</th>
<th class="border-b border-base-300">Длина</th>
<th class="border-b border-base-300">Толщина</th>
<th class="border-b border-base-300">Втулка</th>
<th class="border-b border-base-300 text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`">
<td class="border-b border-base-200">{{ product.sku }}</td>
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.thicknessMicron ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.sleeveBrand ?? '—' }}</td>
<td class="border-b border-base-200 text-right">
<button
v-if="getQuantity(product.id) === 0"
class="btn h-9 rounded-full border-0 bg-[#139957] px-4 text-xs font-semibold text-white hover:bg-[#0d854a]"
@click="incrementProduct(product)"
>
В корзину
</button>
<div v-else class="ml-auto flex w-28 items-center justify-between rounded-xl border border-base-300 px-1 py-1">
<button
class="btn btn-xs btn-square"
:disabled="getQuantity(product.id) === 0"
@click="decrementProduct(product.id)"
>
-
</button>
<span class="min-w-8 text-center text-sm font-semibold">{{ getQuantity(product.id) }}</span>
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</article>
</div>
</div>
</div>
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
</section>
</template>