diff --git a/app/pages/products.vue b/app/pages/products.vue index e8bcdbd..224ba22 100644 --- a/app/pages/products.vue +++ b/app/pages/products.vue @@ -3,61 +3,286 @@ import { useQuery } from '@vue/apollo-composable'; import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated'; import { useClientCart } from '~/composables/useClientCart'; -const { result, loading, error } = useQuery(ClientProductsDocument); -const search = ref(''); -const stockFilter = ref<'ALL' | 'CUSTOM' | 'STANDARD'>('ALL'); -const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart(); +type ProductNode = ClientProductsQuery['clientProducts'][number]; +type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox'; +type ParamValue = number | string; -const coverPresets = [ - ['#e9fbe5', '#acfcd5', '#7be9aa'], - ['#f5fff7', '#d9f5e6', '#8bd8b0'], - ['#fef4ed', '#ffe5d8', '#ffd1b8'], +type ParsedProduct = ProductNode & { + productTypeLabel: string; + widthMm: number | null; + lengthM: number | null; + thicknessMicron: number | null; + sleeveBrand: string | null; + quantityPerBox: string | null; +}; + +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; + isExpanded: boolean; +}; + +const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox']; + +const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [ + { key: 'widthMm', label: 'Ширина, мм' }, + { key: 'lengthM', label: 'Длина, м' }, + { key: 'thicknessMicron', label: 'Толщина, мкм' }, + { key: 'sleeveBrand', label: 'Бренд втулки' }, + { key: 'quantityPerBox', label: 'Количество в коробе' }, ]; -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 { result, loading, error } = useQuery(ClientProductsDocument); +const search = ref(''); +const groupStates = reactive>({}); +const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart(); - const svg = ` - - - - - - - - - - - - - - ${firstLetter} - - `.trim(); - - return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +function normalizeText(value: string) { + return value.replaceAll(/\s+/g, ' ').trim(); } -const filteredProducts = computed(() => { +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); + + 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: quantityMatch?.[1]?.trim() ?? null, + }; +} + +function productSortValue(value: number | null) { + 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 = (a.sleeveBrand ?? '').localeCompare(b.sleeveBrand ?? '', 'ru'); + if (bySleeve !== 0) { + return bySleeve; + } + + return a.sku.localeCompare(b.sku, 'ru'); +} + +const parsedProducts = computed(() => { const list = result.value?.clientProducts ?? []; - const normalizedSearch = search.value.trim().toLowerCase(); + const query = search.value.trim().toLowerCase(); - return list.filter((product) => { - const matchSearch = !normalizedSearch - || product.name.toLowerCase().includes(normalizedSearch) - || product.sku.toLowerCase().includes(normalizedSearch); + return list + .map((product) => { + const parsedMeta = parseProductMeta(product); + return { + ...product, + ...parsedMeta, + }; + }) + .filter((product) => { + if (!query) { + return true; + } - const matchType = stockFilter.value === 'ALL' - || (stockFilter.value === 'CUSTOM' && product.isCustomizable) - || (stockFilter.value === 'STANDARD' && !product.isCustomizable); - - return matchSearch && matchType; - }); + return [ + product.name, + product.sku, + product.productTypeLabel, + product.sleeveBrand ?? '', + ].some((part) => part.toLowerCase().includes(query)); + }) + .sort(compareProducts); }); -function addProductToCart(product: ClientProductsQuery['clientProducts'][number]) { +const productGroups = computed(() => { + const map = new Map(); + + for (const product of parsedProducts.value) { + const key = product.productTypeLabel; + const existing = map.get(key); + if (existing) { + existing.push(product); + continue; + } + + map.set(key, [product]); + } + + return [...map.entries()] + .sort((a, b) => a[0].localeCompare(b[0], 'ru')) + .map(([typeLabel, products]) => ({ + key: typeLabel.toLowerCase().replaceAll(/\s+/g, '-'), + typeLabel, + products: [...products].sort(compareProducts), + })); +}); + +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') { + return a - b; + } + return String(a).localeCompare(String(b), 'ru'); + }); +} + +function getFieldOptions(group: ProductGroup, state: GroupState, field: ParamFieldKey) { + const values = new Set(); + + for (const product of group.products) { + if (!matchesState(product, state, field)) { + continue; + } + + const value = product[field]; + if (value !== null) { + values.add(value); + } + } + + return sortParamValues([...values]); +} + +function createGroupState(group: ProductGroup): GroupState { + const state: GroupState = { + widthMm: null, + lengthM: null, + thicknessMicron: null, + sleeveBrand: null, + quantityPerBox: null, + isExpanded: false, + }; + + for (const key of PARAM_KEYS) { + const options = getFieldOptions(group, state, key); + state[key] = (options[0] as GroupState[typeof key] | undefined) ?? null; + } + + return state; +} + +function normalizeState(group: ProductGroup, state: GroupState) { + for (const key of PARAM_KEYS) { + const options = getFieldOptions(group, state, key); + if (options.length === 0) { + state[key] = null; + continue; + } + + const current = state[key]; + if (current === null || !options.some((option) => option === current)) { + state[key] = (options[0] as GroupState[typeof key] | undefined) ?? null; + } + } +} + +watch( + productGroups, + (groups) => { + const currentKeys = new Set(groups.map((group) => group.key)); + + for (const key of Object.keys(groupStates)) { + if (!currentKeys.has(key)) { + delete groupStates[key]; + } + } + + for (const group of groups) { + if (!groupStates[group.key]) { + groupStates[group.key] = createGroupState(group); + } + normalizeState(group, groupStates[group.key]); + } + }, + { immediate: true }, +); + +function getGroupState(group: ProductGroup) { + return groupStates[group.key]; +} + +function updateField(group: ProductGroup, field: ParamFieldKey, value: ParamValue) { + const state = getGroupState(group); + state[field] = value as GroupState[typeof field]; + normalizeState(group, state); +} + +function selectedProduct(group: ProductGroup) { + const state = getGroupState(group); + return group.products.find((product) => matchesState(product, state)) ?? null; +} + +function matchingCount(group: ProductGroup) { + const state = getGroupState(group); + return group.products.filter((product) => matchesState(product, state)).length; +} + +function toggleExpanded(group: ProductGroup) { + const state = getGroupState(group); + state.isExpanded = !state.isExpanded; +} + +function formatOptionLabel(field: ParamFieldKey, value: ParamValue) { + if (field === 'widthMm' || field === 'lengthM') { + return `${value} мм`; + } + if (field === 'thicknessMicron') { + return `${value} мкм`; + } + return String(value); +} + +function addProductToCart(product: ProductNode) { addProduct({ id: product.id, name: product.name, @@ -80,83 +305,142 @@ function decrementProduct(productId: string) {

Каталог

-
- - - -
+
Загрузка каталога...
{{ error.message }}
-
-
-
-
-
Кастом
-

Конструктор скотча

-

- Отдельная карточка под индивидуальную конфигурацию. Параметры добавим следующим шагом. +

+
+
+
+

{{ group.typeLabel }}

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

{{ field.label }}

+
+ +
+
+
+ +

+ Совпадающих вариантов: {{ matchingCount(group) }}

- + +
-
-
-
- -
-
-

{{ product.name }}

-

SKU: {{ product.sku }}

-
- +
+ + + + + + + + + + + + + + + + + + + + + + + +
SKUШиринаДлинаТолщинаВтулкаКоробДействие
{{ product.sku }}{{ product.widthMm ?? '—' }}{{ product.lengthM ?? '—' }}{{ product.thicknessMicron ?? '—' }}{{ product.sleeveBrand ?? '—' }}{{ product.quantityPerBox ?? '—' }} + -
- - {{ getQuantity(product.id) }} - -
- +
+ + {{ getQuantity(product.id) }} + +
+
- Ничего не найдено по текущим параметрам. + По текущему запросу товары не найдены.