Files
web-frontend/app/components/catalog/CatalogConfigurator.vue
2026-05-16 17:15:10 +07:00

880 lines
30 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 { catalogProductImageSrc } from '~/utils/catalogProductImages';
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 productImageSrc(name: string, sku: string) {
return catalogProductImageSrc(name) ?? createProductCover(name, sku);
}
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 totalAvailableQty(product: ParsedProduct) {
return product.availableInWarehouses.reduce((sum, balance) => sum + Number(balance.availableQty || 0), 0);
}
function warehouseAvailability(product: ParsedProduct) {
return product.availableInWarehouses
.filter((balance) => Number(balance.availableQty || 0) > 0)
.map((balance) => `${balance.warehouse.code}: ${balance.availableQty}`)
.join(' · ');
}
function availabilityTone(product: ParsedProduct) {
const qty = totalAvailableQty(product);
if (qty <= 0) {
return 'bg-[#d95c5c]';
}
if (qty < 20) {
return 'bg-[#e2b534]';
}
return 'bg-[#2aa36b]';
}
function availabilityLabel(product: ParsedProduct) {
const qty = totalAvailableQty(product);
if (qty <= 0) {
return 'Нет в наличии';
}
if (qty < 20) {
return 'Остаток ограничен';
}
return 'В наличии';
}
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="relative pb-10">
<NuxtLink
v-if="previousGroup"
:to="productDetailPath(previousGroup)"
class="absolute left-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:-translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
>
<img
:src="productImageSrc(previousGroup.typeLabel, previousGroup.key)"
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
class="aspect-square w-full rounded-[20px] bg-[#f7fbf8] object-contain"
loading="lazy"
>
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ previousGroup.typeLabel }}</p>
</NuxtLink>
<NuxtLink
v-if="nextGroup"
:to="productDetailPath(nextGroup)"
class="absolute right-[-212px] top-28 z-10 hidden w-44 rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:translate-x-2 hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
>
<img
:src="productImageSrc(nextGroup.typeLabel, nextGroup.key)"
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
class="aspect-square w-full rounded-[20px] bg-[#f7fbf8] object-contain"
loading="lazy"
>
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ nextGroup.typeLabel }}</p>
</NuxtLink>
<header class="mb-5 flex items-center gap-4">
<NuxtLink
to="/products"
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#dce9e1] bg-white text-xl text-[#163624] shadow-[0_10px_24px_rgba(18,56,36,0.06)] transition hover:-translate-y-0.5"
aria-label="Назад к списку товаров"
>
</NuxtLink>
<div class="min-w-0">
<h1 class="text-3xl font-bold leading-tight text-[#163624] md:text-[2.5rem]">{{ selectedGroup.typeLabel }}</h1>
</div>
</header>
<div class="mb-5 grid gap-3 2xl:hidden">
<NuxtLink
v-if="previousGroup"
:to="productDetailPath(previousGroup)"
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
>
<img
:src="productImageSrc(previousGroup.typeLabel, previousGroup.key)"
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
class="h-16 w-16 rounded-2xl bg-[#f7fbf8] object-contain"
loading="lazy"
>
<span class="text-sm font-semibold text-[#163624]">{{ previousGroup.typeLabel }}</span>
</NuxtLink>
<NuxtLink
v-if="nextGroup"
:to="productDetailPath(nextGroup)"
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
>
<img
:src="productImageSrc(nextGroup.typeLabel, nextGroup.key)"
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
class="h-16 w-16 rounded-2xl bg-[#f7fbf8] object-contain"
loading="lazy"
>
<span class="text-sm font-semibold text-[#163624]">{{ nextGroup.typeLabel }}</span>
</NuxtLink>
</div>
<div class="grid gap-5 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.15fr)_320px]">
<div class="space-y-3">
<div class="overflow-hidden rounded-[32px] border border-[#e6efe9] bg-white p-4 shadow-[0_20px_40px_rgba(18,56,36,0.06)]">
<img
:src="productImageSrc(selectedGroup.typeLabel, articleLabel(selectedGroup))"
:alt="selectedGroup.typeLabel"
class="aspect-[5/4] w-full rounded-[26px] bg-[#f7fbf8] object-contain"
loading="lazy"
>
</div>
<div
v-if="customizationDetails(selectedGroup).length"
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
>
<div class="space-y-2">
<p
v-for="note in customizationDetails(selectedGroup)"
:key="`${selectedGroup.key}-${note}`"
class="text-sm leading-6 text-[#456555]"
>
{{ note }}
</p>
</div>
</div>
</div>
<div class="space-y-4">
<article
v-for="field in visibleFields(selectedGroup)"
:key="`${selectedGroup.key}-${field.key}`"
class="rounded-[28px] border border-[#e6efe9] bg-white p-4 shadow-[0_18px_36px_rgba(18,56,36,0.05)]"
>
<div class="flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
:key="`${selectedGroup.key}-${field.key}-${option}`"
class="cursor-pointer rounded-2xl border px-4 py-2 text-sm font-medium transition"
:class="[
getGroupState(selectedGroup)[field.key] === option
? 'border-[#163624] bg-[#163624] text-white'
: isOptionAvailable(selectedGroup, field.key, option)
? 'border-[#dce9e1] bg-white text-[#163624] hover:border-[#163624]'
: 'border-[#e6eaee] bg-[#f3f5f7] text-[#8a949d]',
]"
>
<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>
<details class="mt-3 rounded-[20px] bg-[#f7fbf8] px-4 py-3 text-sm text-[#587064]">
<summary class="cursor-pointer font-medium text-[#355947]">Подробнее</summary>
<p class="mt-2 leading-6">{{ fieldHelperText(selectedGroup, field.key) }}</p>
</details>
</article>
</div>
<aside class="self-start xl:sticky xl:top-24">
<div class="rounded-[30px] border border-[#e6efe9] bg-white p-5 shadow-[0_24px_48px_rgba(18,56,36,0.08)]">
<p class="mt-1 text-lg font-medium leading-tight text-[#163624]">{{ articleLabel(selectedGroup) }}</p>
<button
v-if="selectedQty(selectedGroup) === 0"
class="btn mt-4 h-12 w-full rounded-full border-0 bg-[#139957] px-6 text-base font-semibold text-white hover:bg-[#0d854a]"
:disabled="!selectedProduct(selectedGroup)"
@click="incrementSelected(selectedGroup)"
>
В корзину
</button>
<div
v-else
class="mt-4 flex items-center justify-between rounded-[24px] border border-[#dce9e1] bg-[#f8fbf9] px-2 py-2"
>
<button
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
:disabled="selectedQty(selectedGroup) === 0"
@click="decrementSelected(selectedGroup)"
>
-
</button>
<div class="min-w-12 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
<button
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
:disabled="!selectedProduct(selectedGroup)"
@click="incrementSelected(selectedGroup)"
>
+
</button>
</div>
</div>
</aside>
</div>
<div class="mt-8">
<p class="mb-4 text-base font-semibold text-[#163624]">Доступные варианты</p>
<div class="overflow-x-auto rounded-[24px] border border-[#edf4ef] bg-white">
<table class="table bg-white">
<thead>
<tr class="text-[#587064]">
<th>SKU</th>
<th>Ширина</th>
<th>Длина</th>
<th>Толщина</th>
<th>Втулка</th>
<th>Цвет</th>
<th>Надпись</th>
<th>Остаток</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`" class="align-middle">
<td class="font-semibold text-[#163624]">{{ product.sku }}</td>
<td>{{ product.widthMm ?? '—' }}</td>
<td>{{ product.lengthM ?? '—' }}</td>
<td>{{ product.thicknessMicron ?? '—' }}</td>
<td>{{ product.sleeveBrand ?? '—' }}</td>
<td>{{ product.colorTags.join(', ') || '—' }}</td>
<td>{{ product.labelTags.join(', ') || '—' }}</td>
<td>
<div class="flex min-w-[180px] items-center gap-3">
<span class="h-3 w-3 rounded-sm" :class="availabilityTone(product)" />
<div class="min-w-0">
<p class="text-sm font-medium text-[#163624]">{{ availabilityLabel(product) }}</p>
<p class="text-xs text-[#607569]">
{{ totalAvailableQty(product) }}<span v-if="warehouseAvailability(product)"> · {{ warehouseAvailability(product) }}</span>
</p>
</div>
</div>
</td>
<td class="text-right">
<button
v-if="getQuantity(product.id) === 0"
class="btn h-10 rounded-full border-0 bg-[#139957] px-5 text-sm font-semibold text-white hover:bg-[#0d854a]"
@click="incrementProduct(product)"
>
В корзину
</button>
<div v-else class="ml-auto flex w-32 items-center justify-between rounded-[20px] border border-[#dce9e1] bg-white px-2 py-2">
<button
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
:disabled="getQuantity(product.id) === 0"
@click="decrementProduct(product.id)"
>
-
</button>
<span class="min-w-8 text-center text-sm font-semibold text-[#163624]">{{ getQuantity(product.id) }}</span>
<button
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
@click="incrementProduct(product)"
>
+
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
</section>
</template>