refactor(catalog): default valid selections and minimal action panel

This commit is contained in:
Ruslan Bakiev
2026-04-03 15:25:33 +07:00
parent 21911f80ff
commit fd015ad1b7

View File

@@ -194,12 +194,14 @@ function requiredKeys(group: ProductGroup) {
return visibleFields(group).map((field) => field.key);
}
function createGroupState(): GroupState {
function createGroupState(group: ProductGroup): GroupState {
const firstProduct = group.products[0];
return {
widthMm: null,
lengthM: null,
thicknessMicron: null,
sleeveBrand: null,
widthMm: firstProduct?.widthMm ?? null,
lengthM: firstProduct?.lengthM ?? null,
thicknessMicron: firstProduct?.thicknessMicron ?? null,
sleeveBrand: firstProduct?.sleeveBrand ?? null,
isExpanded: false,
};
}
@@ -216,14 +218,14 @@ watch(
}
for (const group of groups) {
groupStates[group.key] ??= createGroupState();
groupStates[group.key] ??= createGroupState(group);
}
},
{ immediate: true },
);
function getGroupState(group: ProductGroup) {
groupStates[group.key] ??= createGroupState();
groupStates[group.key] ??= createGroupState(group);
return groupStates[group.key];
}
@@ -252,61 +254,6 @@ function selectedProduct(group: ProductGroup) {
return matches.length === 1 ? matches[0] : null;
}
function remainingSelectionCount(group: ProductGroup) {
const state = getGroupState(group);
return requiredKeys(group).filter((key) => state[key] === null).length;
}
function pluralize(value: number, one: string, few: string, many: string) {
const mod10 = value % 10;
const mod100 = value % 100;
if (mod10 === 1 && mod100 !== 11) {
return one;
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return few;
}
return many;
}
function selectionHeadline(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
return `SKU ${product.sku}`;
}
const remaining = remainingSelectionCount(group);
if (remaining > 0) {
return `Выберите еще ${remaining} ${pluralize(remaining, 'параметр', 'параметра', 'параметров')}`;
}
return 'Комбинация не найдена';
}
function selectionDescription(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
return [
product.widthMm ? `${product.widthMm} мм` : null,
product.lengthM ? `${product.lengthM} м` : null,
product.thicknessMicron ? `${product.thicknessMicron} мкм` : null,
normalizeText(product.sleeveBrand) || null,
].filter(Boolean).join(' • ');
}
const remaining = remainingSelectionCount(group);
if (remaining > 0) {
return 'Переключатели ниже собирают точную модификацию товара.';
}
return 'Разверните весь список, если нужна ручная проверка вариантов.';
}
function addButtonLabel(group: ProductGroup) {
return selectedProduct(group) ? 'В корзину' : 'Выбрать параметры';
}
function boxQuantityLabel(group: ProductGroup) {
const product = selectedProduct(group);
if (product?.quantityPerBoxOptions.length) {
@@ -349,17 +296,25 @@ function isOptionAvailable(group: ProductGroup, field: ParamFieldKey, option: Pa
function updateField(group: ProductGroup, field: ParamFieldKey, value: ParamValue) {
const state = getGroupState(group);
state[field] = value as GroupState[typeof field];
}
function clearField(group: ProductGroup, field: ParamFieldKey) {
getGroupState(group)[field] = null;
}
function clearSelection(group: ProductGroup) {
const state = getGroupState(group);
for (const key of PARAM_KEYS) {
state[key] = null;
const resolved = selectedProduct(group);
if (resolved) {
return;
}
const fallback = group.products.find((product) => product[field] === value) ?? group.products[0];
if (!fallback) {
return;
}
state.widthMm = fallback.widthMm ?? null;
state.lengthM = fallback.lengthM ?? null;
state.thicknessMicron = fallback.thicknessMicron ?? null;
state.sleeveBrand = fallback.sleeveBrand ?? null;
}
function articleLabel(group: ProductGroup) {
return selectedProduct(group)?.sku ?? '—';
}
function toggleExpanded(group: ProductGroup) {
@@ -441,26 +396,16 @@ function decrementSelected(group: ProductGroup) {
<div class="rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-4">
<div class="mb-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45">Тип товара</p>
<h2 class="mt-2 text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
<h2 class="text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
</div>
<div class="grid gap-4 md:grid-cols-2">
<fieldset
<div class="space-y-4">
<div
v-for="field in visibleFields(group)"
:key="`${group.key}-${field.key}`"
class="rounded-[24px] bg-base-200/50 p-4"
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
>
<div class="flex items-center justify-between gap-3">
<legend class="text-sm font-semibold text-[#163624]">{{ field.label }}</legend>
<button
v-if="getGroupState(group)[field.key] !== null"
class="btn btn-ghost btn-xs rounded-full px-2"
@click="clearField(group, field.key)"
>
Сбросить
</button>
</div>
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<label
@@ -487,30 +432,29 @@ function decrementSelected(group: ProductGroup) {
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
</div>
</fieldset>
</div>
</div>
</div>
<aside class="rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-1">
<div class="flex h-full flex-col justify-between gap-4">
<div class="space-y-3">
<div class="rounded-[22px] bg-base-200/70 p-3">
<p v-if="selectedProduct(group)" class="text-sm font-semibold text-[#163624]">{{ selectionHeadline(group) }}</p>
<p class="text-xs leading-5 text-base-content/65">{{ selectionDescription(group) }}</p>
<p class="mt-2 text-xs leading-5 text-base-content/65">Короб: {{ boxQuantityLabel(group) }}</p>
</div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45">Артикул</p>
<p class="text-xl font-bold text-[#163624]">{{ articleLabel(group) }}</p>
<p class="text-xs leading-5 text-base-content/65">Короб: {{ boxQuantityLabel(group) }}</p>
</div>
<div class="space-y-3">
<button
class="btn btn-neutral h-11 w-full rounded-full text-sm font-semibold"
v-if="selectedQty(group) === 0"
class="btn btn-success h-11 w-full rounded-full border-0 text-sm font-semibold text-success-content"
:disabled="!selectedProduct(group)"
@click="incrementSelected(group)"
>
{{ addButtonLabel(group) }}
В корзину
</button>
<div class="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
<div v-else class="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"
@@ -528,14 +472,6 @@ function decrementSelected(group: ProductGroup) {
</button>
</div>
</div>
<button
class="btn btn-ghost btn-sm w-full rounded-full"
:disabled="requiredKeys(group).every((key) => getGroupState(group)[key] === null)"
@click="clearSelection(group)"
>
Сбросить выбор
</button>
</div>
</div>
</aside>
@@ -543,24 +479,24 @@ function decrementSelected(group: ProductGroup) {
<div
v-if="getGroupState(group).isExpanded"
class="mt-4 overflow-x-auto rounded-[28px] bg-base-100 p-2"
class="mt-4 overflow-x-auto rounded-[28px] bg-base-100"
>
<table class="table table-zebra">
<table class="table border-separate border-spacing-0">
<thead>
<tr>
<th>Фото</th>
<th>SKU</th>
<th>Ширина</th>
<th>Длина</th>
<th>Толщина</th>
<th>Втулка</th>
<th>Короб</th>
<th class="text-right">Действие</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">Толщина</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 group.products" :key="`${group.key}-${product.id}`">
<td>
<td class="border-b border-base-200">
<img
:src="createProductCover(product.name, product.sku)"
:alt="`Превью ${product.sku}`"
@@ -568,13 +504,13 @@ function decrementSelected(group: ProductGroup) {
loading="lazy"
>
</td>
<td>{{ product.sku }}</td>
<td>{{ product.widthMm ?? '—' }}</td>
<td>{{ product.lengthM ?? '—' }}</td>
<td>{{ product.thicknessMicron ?? '—' }}</td>
<td>{{ product.sleeveBrand ?? '—' }}</td>
<td>{{ product.quantityPerBox ?? '—' }}</td>
<td class="text-right">
<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">{{ product.quantityPerBox ?? '—' }}</td>
<td class="border-b border-base-200 text-right">
<div 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"