feat(catalog): unify home with catalog and add clear qty controls

This commit is contained in:
Ruslan Bakiev
2026-04-03 12:17:05 +07:00
parent 71d2b176e9
commit f977896647
6 changed files with 500 additions and 601 deletions

View File

@@ -0,0 +1,490 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated';
import { useClientCart } from '~/composables/useClientCart';
type ProductNode = ClientProductsQuery['clientProducts'][number];
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox';
type ParamValue = number | string;
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: 'Количество в коробе' },
];
const coverPresets = [
['#d9f5e6', '#9ce8c1', '#6fd09d'],
['#eaf9ef', '#b3e8cb', '#76c89f'],
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
];
const { result, loading, error } = useQuery(ClientProductsDocument);
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 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 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<ParsedProduct[]>(() => {
const list = result.value?.clientProducts ?? [];
const query = search.value.trim().toLowerCase();
return list
.map((product) => ({ ...product, ...parseProductMeta(product) }))
.filter((product) => {
if (!query) {
return true;
}
return [product.name, product.sku, product.productTypeLabel, product.sleeveBrand ?? '']
.some((part) => part.toLowerCase().includes(query));
})
.sort(compareProducts);
});
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);
if (existing) {
existing.push(product);
} else {
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<ParamValue>();
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 keys = new Set(groups.map((group) => group.key));
for (const key of Object.keys(groupStates)) {
if (!keys.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 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);
if (!product) {
return 0;
}
return getQuantity(product.id);
}
function incrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (!product) {
return;
}
incrementProduct(product);
}
function decrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (!product) {
return;
}
decrementProduct(product.id);
}
</script>
<template>
<section class="space-y-5">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
<div class="surface-card rounded-3xl p-4 md:p-5">
<label class="form-control">
<span class="label-text">Поиск</span>
<input
v-model="search"
type="text"
class="input input-bordered w-full"
placeholder="Тип товара, SKU или параметр"
>
</label>
</div>
<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="productGroups.length" class="space-y-4">
<article
v-for="group in productGroups"
:key="group.key"
class="surface-card rounded-3xl p-4 md:p-5"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex 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>
<button class="btn btn-ghost btn-sm" @click="toggleExpanded(group)">
{{ getGroupState(group).isExpanded ? 'Свернуть таблицу' : 'Показать все варианты' }}
</button>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-[1fr_340px]">
<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 getFieldOptions(group, getGroupState(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)"
>
{{ formatOptionLabel(field.key, option) }}
</button>
</div>
</div>
</div>
<p class="text-sm text-base-content/70">Совпадающих вариантов: {{ matchingCount(group) }}</p>
</div>
<aside class="rounded-2xl bg-base-100 p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">Выбранная позиция</p>
<template v-if="selectedProduct(group)">
<div class="mt-2 flex items-start gap-3">
<img
:src="createProductCover(selectedProduct(group)!.name, selectedProduct(group)!.sku)"
:alt="`Превью ${selectedProduct(group)!.sku}`"
class="h-16 w-16 rounded-xl object-cover"
loading="lazy"
>
<div class="min-w-0">
<p class="text-sm font-semibold text-[#133826]">{{ selectedProduct(group)?.name }}</p>
<p class="mt-1 text-xs text-base-content/65">SKU: {{ selectedProduct(group)?.sku }}</p>
</div>
</div>
<div class="mt-4 flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 px-2 py-1">
<button
class="btn btn-square btn-sm"
:disabled="selectedQty(group) === 0"
@click="decrementSelected(group)"
>
-
</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>
</template>
<p v-else class="mt-2 text-sm text-base-content/70">
Нет точного совпадения. Измените параметры или раскройте полный список вариантов.
</p>
</aside>
</div>
<div v-if="getGroupState(group).isExpanded" class="mt-4 overflow-x-auto rounded-2xl bg-base-100 p-2">
<table class="table table-zebra">
<thead>
<tr>
<th>Фото</th>
<th>SKU</th>
<th>Ширина</th>
<th>Длина</th>
<th>Толщина</th>
<th>Втулка</th>
<th>Короб</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
<td>
<img
:src="createProductCover(product.name, product.sku)"
:alt="`Превью ${product.sku}`"
class="h-12 w-12 rounded-lg object-cover"
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">
<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>
<span class="text-sm font-semibold">{{ getQuantity(product.id) }}</span>
<button class="btn btn-xs btn-square" @click="incrementProduct(product)">+</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
</section>
</template>

View File

@@ -8,7 +8,7 @@ const route = useRoute();
const { totalItems } = useClientCart();
const centerCapsule: NavItem[] = [
{ to: '/products', label: 'Каталог' },
{ to: '/', label: 'Каталог' },
{ to: '/orders', label: 'Мои заказы' },
];
@@ -18,6 +18,9 @@ const rightCapsule: NavItem[] = [
];
function isActive(path: string) {
if (path === '/') {
return route.path === '/' || route.path.startsWith('/products');
}
if (path === '/orders') {
return route.path === '/orders' || route.path.startsWith('/orders/');
}