refactor(catalog): use structured product toggles

This commit is contained in:
Ruslan Bakiev
2026-04-03 14:52:30 +07:00
parent 5bb0e3928f
commit 9c889e200a
4 changed files with 280 additions and 165 deletions

View File

@@ -9,11 +9,6 @@ type ParamValue = number | string;
type ParsedProduct = ProductNode & {
productTypeLabel: string;
widthMm: number | null;
lengthM: number | null;
thicknessMicron: number | null;
sleeveBrand: string | null;
quantityPerBox: string | null;
quantityPerBoxOptions: string[];
};
@@ -33,10 +28,10 @@ type GroupState = {
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand'];
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
{ key: 'widthMm', label: 'Ширина, мм' },
{ key: 'lengthM', label: 'Длина, м' },
{ key: 'thicknessMicron', label: 'Толщина, мкм' },
{ key: 'sleeveBrand', label: 'Бренд втулки' },
{ key: 'widthMm', label: 'Ширина' },
{ key: 'lengthM', label: 'Длина' },
{ key: 'thicknessMicron', label: 'Толщина' },
{ key: 'sleeveBrand', label: 'Втулка' },
];
const coverPresets = [
@@ -50,8 +45,15 @@ const search = ref('');
const groupStates = reactive<Record<string, GroupState>>({});
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
function normalizeText(value: string) {
return value.replaceAll(/\s+/g, ' ').trim();
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) {
@@ -80,31 +82,15 @@ function createProductCover(name: string, sku: string) {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function parseProductMeta(product: ProductNode) {
const normalizedName = normalizeText(product.name);
const [typeLabelRaw] = normalizedName.split(',');
const typeLabel = typeLabelRaw?.trim() || 'Без типа';
const sizeMatch = normalizedName.match(/(\d+)\s*[xх*]\s*(\d+)\s*м/i);
const thicknessMatch = normalizedName.match(/(\d+)\s*мкм/i);
const sleeveMatch = normalizedName.match(/втулка\s+([^,]+)/i);
const quantityMatch = normalizedName.match(/короб\s+([^,]+)/i);
const quantityPerBox = quantityMatch?.[1]?.trim() ?? null;
function hydrateProduct(product: ProductNode): ParsedProduct {
return {
productTypeLabel: typeLabel,
widthMm: sizeMatch ? Number.parseInt(sizeMatch[1] ?? '', 10) : null,
lengthM: sizeMatch ? Number.parseInt(sizeMatch[2] ?? '', 10) : null,
thicknessMicron: thicknessMatch ? Number.parseInt(thicknessMatch[1] ?? '', 10) : null,
sleeveBrand: sleeveMatch?.[1]?.trim() ?? null,
quantityPerBox,
quantityPerBoxOptions: quantityPerBox
? quantityPerBox.split('/').map((value) => value.trim()).filter(Boolean)
: [],
...product,
productTypeLabel: normalizeText(product.productType) || 'Без типа',
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
};
}
function productSortValue(value: number | null) {
function productSortValue(value: number | null | undefined) {
return value ?? Number.MAX_SAFE_INTEGER;
}
@@ -124,7 +110,7 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
return byThickness;
}
const bySleeve = (a.sleeveBrand ?? '').localeCompare(b.sleeveBrand ?? '', 'ru');
const bySleeve = normalizeText(a.sleeveBrand).localeCompare(normalizeText(b.sleeveBrand), 'ru');
if (bySleeve !== 0) {
return bySleeve;
}
@@ -137,13 +123,22 @@ const parsedProducts = computed<ParsedProduct[]>(() => {
const query = search.value.trim().toLowerCase();
return list
.map((product) => ({ ...product, ...parseProductMeta(product) }))
.map(hydrateProduct)
.filter((product) => {
if (!query) {
return true;
}
return [product.name, product.sku, product.productTypeLabel, product.sleeveBrand ?? '']
.some((part) => part.toLowerCase().includes(query));
return [
product.name,
product.sku,
product.productTypeLabel,
String(product.widthMm ?? ''),
String(product.lengthM ?? ''),
String(product.thicknessMicron ?? ''),
normalizeText(product.sleeveBrand),
normalizeText(product.quantityPerBox),
].some((part) => part.toLowerCase().includes(query));
})
.sort(compareProducts);
});
@@ -152,12 +147,11 @@ const productGroups = computed<ProductGroup[]>(() => {
const map = new Map<string, ParsedProduct[]>();
for (const product of parsedProducts.value) {
const key = product.productTypeLabel;
const existing = map.get(key);
const existing = map.get(product.productTypeLabel);
if (existing) {
existing.push(product);
} else {
map.set(key, [product]);
map.set(product.productTypeLabel, [product]);
}
}
@@ -170,25 +164,6 @@ const productGroups = computed<ProductGroup[]>(() => {
}));
});
function matchesState(product: ParsedProduct, state: GroupState, skipField?: ParamFieldKey) {
for (const key of PARAM_KEYS) {
if (key === skipField) {
continue;
}
const selectedValue = state[key];
if (selectedValue === null) {
continue;
}
if (product[key] !== selectedValue) {
return false;
}
}
return true;
}
function sortParamValues(values: ParamValue[]) {
return [...values].sort((a, b) => {
if (typeof a === 'number' && typeof b === 'number') {
@@ -203,7 +178,7 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
for (const product of group.products) {
const value = product[field];
if (value !== null) {
if (value !== null && value !== undefined) {
values.add(value);
}
}
@@ -211,17 +186,22 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
return sortParamValues([...values]);
}
function createGroupState(group: ProductGroup): GroupState {
void group;
const state: GroupState = {
function visibleFields(group: ProductGroup) {
return parameterFields.filter((field) => getAllFieldOptions(group, field.key).length > 1);
}
function requiredKeys(group: ProductGroup) {
return visibleFields(group).map((field) => field.key);
}
function createGroupState(): GroupState {
return {
widthMm: null,
lengthM: null,
thicknessMicron: null,
sleeveBrand: null,
isExpanded: false,
};
return state;
}
watch(
@@ -236,70 +216,97 @@ watch(
}
for (const group of groups) {
if (!groupStates[group.key]) {
groupStates[group.key] = createGroupState(group);
}
groupStates[group.key] ??= createGroupState();
}
},
{ immediate: true },
);
function getGroupState(group: ProductGroup): GroupState {
const existing = groupStates[group.key];
if (existing) {
return existing;
}
const created = createGroupState(group);
groupStates[group.key] = created;
return created;
function getGroupState(group: ProductGroup) {
groupStates[group.key] ??= createGroupState();
return groupStates[group.key];
}
function updateField(group: ProductGroup, field: ParamFieldKey, value: ParamValue) {
function matchesProductState(product: ParsedProduct, state: GroupState, keys: ParamFieldKey[]) {
return keys.every((key) => state[key] === null || product[key] === state[key]);
}
function matchingProducts(group: ProductGroup) {
const state = getGroupState(group);
state[field] = value as GroupState[typeof field];
return group.products.filter((product) => matchesProductState(product, state, requiredKeys(group)));
}
function selectedProduct(group: ProductGroup) {
const keys = requiredKeys(group);
const state = getGroupState(group);
if (PARAM_KEYS.some((key) => state[key] === null)) {
if (keys.length === 0) {
return group.products.length === 1 ? group.products[0] : null;
}
if (keys.some((key) => state[key] === null)) {
return null;
}
return group.products.find((product) => matchesState(product, state)) ?? null;
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
return matches.length === 1 ? matches[0] : null;
}
function matchingCount(group: ProductGroup) {
function remainingSelectionCount(group: ProductGroup) {
const state = getGroupState(group);
return group.products.filter((product) => matchesState(product, state)).length;
return requiredKeys(group).filter((key) => state[key] === null).length;
}
function toggleExpanded(group: ProductGroup) {
const state = getGroupState(group);
state.isExpanded = !state.isExpanded;
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 formatOptionLabel(field: ParamFieldKey, value: ParamValue) {
if (field === 'widthMm' || field === 'lengthM') {
return `${value} мм`;
function selectionHeadline(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
return `SKU ${product.sku}`;
}
if (field === 'thicknessMicron') {
return `${value} мкм`;
const remaining = remainingSelectionCount(group);
if (remaining > 0) {
return `Выберите еще ${remaining} ${pluralize(remaining, 'параметр', 'параметра', 'параметров')}`;
}
return String(value);
return 'Комбинация не найдена';
}
function selectedFieldValue(group: ProductGroup, field: ParamFieldKey) {
const value = getGroupState(group)[field];
if (value === null) {
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(' • ');
}
return formatOptionLabel(field, value);
const remaining = remainingSelectionCount(group);
if (remaining > 0) {
return 'Переключатели ниже собирают точную модификацию товара.';
}
return 'Разверните весь список, если нужна ручная проверка вариантов.';
}
function boxQuantityLabel(group: ProductGroup) {
const product = selectedProduct(group);
if (product?.quantityPerBox) {
return product.quantityPerBox;
if (product?.quantityPerBoxOptions.length) {
return product.quantityPerBoxOptions.join(' / ');
}
const values = new Set<string>();
@@ -312,6 +319,49 @@ function boxQuantityLabel(group: ProductGroup) {
return sortParamValues([...values]).join(' / ') || '—';
}
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 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 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;
}
}
function toggleExpanded(group: ProductGroup) {
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
}
function incrementProduct(product: ProductNode) {
if (getQuantity(product.id) === 0) {
addProduct({
@@ -332,26 +382,21 @@ function decrementProduct(productId: string) {
function selectedQty(group: ProductGroup) {
const product = selectedProduct(group);
if (!product) {
return 0;
}
return getQuantity(product.id);
return product ? getQuantity(product.id) : 0;
}
function incrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (!product) {
return;
}
if (product) {
incrementProduct(product);
}
}
function decrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (!product) {
return;
}
if (product) {
decrementProduct(product.id);
}
}
</script>
@@ -380,27 +425,73 @@ function decrementSelected(group: ProductGroup) {
:key="group.key"
class="surface-card rounded-3xl p-4 md:p-5"
>
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-xl font-bold text-[#133826]">{{ group.typeLabel }}</h2>
<span class="badge badge-outline">{{ group.products.length }} вариантов</span>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-[360px_1fr]">
<aside class="rounded-2xl bg-base-100 p-3">
<div class="grid gap-4 xl:grid-cols-6 xl:items-stretch">
<div class="rounded-[28px] bg-base-100 p-3 xl:col-span-1">
<img
:src="createProductCover(group.typeLabel, group.key)"
:alt="`Превью группы ${group.typeLabel}`"
class="h-56 w-full rounded-2xl object-cover"
class="h-full min-h-[220px] w-full rounded-[24px] object-cover"
loading="lazy"
>
</div>
<div class="rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-4">
<div class="grid h-full gap-4 md:grid-cols-2">
<fieldset
v-for="field in visibleFields(group)"
:key="`${group.key}-${field.key}`"
class="rounded-[24px] bg-base-200/50 p-4"
>
<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>
<div class="mt-3 flex flex-wrap gap-2">
<span class="badge badge-neutral">SKU: {{ selectedProduct(group)?.sku ?? '—' }}</span>
<span class="badge badge-outline">Совпадений: {{ matchingCount(group) }}</span>
<input
v-for="option in getAllFieldOptions(group, field.key)"
:key="`${group.key}-${field.key}-${option}`"
class="btn btn-sm rounded-full border-base-300 bg-base-100 text-sm normal-case checked:btn-neutral"
type="radio"
:name="`${group.key}-${field.key}`"
:aria-label="formatOptionLabel(field.key, option)"
:checked="getGroupState(group)[field.key] === option"
:disabled="!isOptionAvailable(group, field.key, option)"
@change="updateField(group, field.key, option)"
>
</div>
</fieldset>
</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>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45">Тип товара</p>
<h2 class="mt-2 text-xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
</div>
<div class="flex flex-wrap gap-2">
<span class="badge badge-outline">{{ group.products.length }} вариантов</span>
<span class="badge badge-outline">Короб: {{ boxQuantityLabel(group) }}</span>
</div>
<div class="mt-3 flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 px-2 py-1">
<div class="rounded-[22px] bg-base-200/70 p-3">
<p class="text-sm font-semibold text-[#163624]">{{ selectionHeadline(group) }}</p>
<p class="mt-1 text-xs leading-5 text-base-content/65">{{ selectionDescription(group) }}</p>
</div>
</div>
<div class="space-y-3">
<div 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"
:disabled="selectedQty(group) === 0"
@@ -408,48 +499,36 @@ function decrementSelected(group: ProductGroup) {
>
-
</button>
<span class="min-w-10 text-center font-semibold">{{ selectedQty(group) }}</span>
<button class="btn btn-square btn-sm" @click="incrementSelected(group)">+</button>
<div class="text-center">
<div class="text-[11px] uppercase tracking-[0.16em] text-base-content/45">В корзине</div>
<div class="text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<span
v-for="field in parameterFields"
:key="`selected-${group.key}-${field.key}`"
class="badge badge-outline"
>
{{ field.label }}: {{ selectedFieldValue(group, field.key) }}
</span>
</div>
</aside>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
<div
v-for="field in parameterFields"
:key="`${group.key}-${field.key}`"
class="rounded-2xl bg-base-100 p-3"
>
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">{{ field.label }}</p>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="option in getAllFieldOptions(group, field.key)"
:key="`${group.key}-${field.key}-${option}`"
class="btn btn-sm"
:class="getGroupState(group)[field.key] === option ? 'btn-neutral' : 'btn-outline'"
@click="updateField(group, field.key, option)"
class="btn btn-square btn-sm"
:disabled="!selectedProduct(group)"
@click="incrementSelected(group)"
>
{{ formatOptionLabel(field.key, option) }}
+
</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>
</div>
<p class="text-sm text-base-content/70">Совпадающих вариантов: {{ matchingCount(group) }}</p>
</div>
</div>
<div v-if="getGroupState(group).isExpanded" class="mt-4 overflow-x-auto rounded-2xl bg-base-100 p-2">
<div
v-if="getGroupState(group).isExpanded"
class="mt-4 overflow-x-auto rounded-[28px] bg-base-100 p-2"
>
<table class="table table-zebra">
<thead>
<tr>
@@ -481,7 +560,13 @@ function decrementSelected(group: ProductGroup) {
<td>{{ product.quantityPerBox ?? '—' }}</td>
<td class="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" :disabled="getQuantity(product.id) === 0" @click="decrementProduct(product.id)">-</button>
<button
class="btn btn-xs btn-square"
:disabled="getQuantity(product.id) === 0"
@click="decrementProduct(product.id)"
>
-
</button>
<span class="text-sm font-semibold">{{ getQuantity(product.id) }}</span>
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
</div>
@@ -502,7 +587,13 @@ function decrementSelected(group: ProductGroup) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M5 7.5L10 12.5L15 7.5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }}</span>
</button>

View File

@@ -404,8 +404,14 @@ export type Product = {
id: Scalars['ID']['output'];
isActive: Scalars['Boolean']['output'];
isCustomizable: Scalars['Boolean']['output'];
lengthM?: Maybe<Scalars['Int']['output']>;
name: Scalars['String']['output'];
productType?: Maybe<Scalars['String']['output']>;
quantityPerBox?: Maybe<Scalars['String']['output']>;
sku: Scalars['String']['output'];
sleeveBrand?: Maybe<Scalars['String']['output']>;
thicknessMicron?: Maybe<Scalars['Int']['output']>;
widthMm?: Maybe<Scalars['Int']['output']>;
};
export type ProductWarehouseBalance = {
@@ -639,7 +645,7 @@ export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, productType?: string | null, widthMm?: number | null, lengthM?: number | null, thicknessMicron?: number | null, sleeveBrand?: string | null, quantityPerBox?: string | null, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type MyMessengerConnectionsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -920,6 +926,12 @@ export const ClientProductsDocument = gql`
sku
name
description
productType
widthMm
lengthM
thicknessMicron
sleeveBrand
quantityPerBox
isCustomizable
availableInWarehouses {
availableQty

View File

@@ -4,6 +4,12 @@ query ClientProducts {
sku
name
description
productType
widthMm
lengthM
thicknessMicron
sleeveBrand
quantityPerBox
isCustomizable
availableInWarehouses {
availableQty

View File

@@ -177,6 +177,12 @@ type Product {
sku: String!
name: String!
description: String
productType: String
widthMm: Int
lengthM: Int
thicknessMicron: Int
sleeveBrand: String
quantityPerBox: String
isCustomizable: Boolean!
isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]!