From 9c889e200a1ba6d1c1032c22f4894543a3972007 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:52:30 +0700 Subject: [PATCH] refactor(catalog): use structured product toggles --- .../catalog/CatalogConfigurator.vue | 419 +++++++++++------- app/composables/graphql/generated.ts | 14 +- .../catalog/client-products.graphql | 6 + graphql/schema.graphql | 6 + 4 files changed, 280 insertions(+), 165 deletions(-) diff --git a/app/components/catalog/CatalogConfigurator.vue b/app/components/catalog/CatalogConfigurator.vue index f6e6ad2..f4126bf 100644 --- a/app/components/catalog/CatalogConfigurator.vue +++ b/app/components/catalog/CatalogConfigurator.vue @@ -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>({}); 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(() => { 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(() => { const map = new Map(); 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(() => { })); }); -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(); @@ -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); } - incrementProduct(product); } function decrementSelected(group: ProductGroup) { const product = selectedProduct(group); - if (!product) { - return; + if (product) { + decrementProduct(product.id); } - decrementProduct(product.id); } @@ -380,76 +425,110 @@ function decrementSelected(group: ProductGroup) { :key="group.key" class="surface-card rounded-3xl p-4 md:p-5" > -
-

{{ group.typeLabel }}

- {{ group.products.length }} вариантов -
- -
- - -
-
-
+
+
-

{{ field.label }}

-
+
+ {{ field.label }}
+ +
+ +
+
+
+
+ +
+
-
+
@@ -481,7 +560,13 @@ function decrementSelected(group: ProductGroup) {
{{ product.quantityPerBox ?? '—' }}
- + {{ getQuantity(product.id) }}
@@ -502,7 +587,13 @@ function decrementSelected(group: ProductGroup) { fill="none" xmlns="http://www.w3.org/2000/svg" > - + {{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }} diff --git a/app/composables/graphql/generated.ts b/app/composables/graphql/generated.ts index 3a6192f..73a2810 100644 --- a/app/composables/graphql/generated.ts +++ b/app/composables/graphql/generated.ts @@ -404,8 +404,14 @@ export type Product = { id: Scalars['ID']['output']; isActive: Scalars['Boolean']['output']; isCustomizable: Scalars['Boolean']['output']; + lengthM?: Maybe; name: Scalars['String']['output']; + productType?: Maybe; + quantityPerBox?: Maybe; sku: Scalars['String']['output']; + sleeveBrand?: Maybe; + thicknessMicron?: Maybe; + widthMm?: Maybe; }; 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 diff --git a/graphql/operations/catalog/client-products.graphql b/graphql/operations/catalog/client-products.graphql index 19a947f..cb946f7 100644 --- a/graphql/operations/catalog/client-products.graphql +++ b/graphql/operations/catalog/client-products.graphql @@ -4,6 +4,12 @@ query ClientProducts { sku name description + productType + widthMm + lengthM + thicknessMicron + sleeveBrand + quantityPerBox isCustomizable availableInWarehouses { availableQty diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 6339163..462fb0d 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -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!]!