Split catalog into list and detail pages

This commit is contained in:
Ruslan Bakiev
2026-04-09 18:49:25 +07:00
parent 21e40d3fa1
commit 76ab87620e
5 changed files with 278 additions and 113 deletions

View File

@@ -8,6 +8,10 @@ import {
} from '~/composables/graphql/generated';
import { useClientCart } from '~/composables/useClientCart';
const props = defineProps<{
productTypeSlug: string;
}>();
type ProductNode = ClientProductsQuery['clientProducts'][number];
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
@@ -35,7 +39,6 @@ type GroupState = {
quantityPerBox: string | null;
colorTag: string | null;
labelTag: string | null;
isExpanded: boolean;
};
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
@@ -75,7 +78,6 @@ const coverPresets = [
const productsQuery = useQuery(ClientProductsDocument);
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
const search = ref('');
const groupStates = reactive<Record<string, GroupState>>({});
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
@@ -169,27 +171,9 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
const parsedProducts = computed<ParsedProduct[]>(() => {
const list = productsQuery.result.value?.clientProducts ?? [];
const query = search.value.trim().toLowerCase();
return list
.map(hydrateProduct)
.filter((product) => {
if (!query) {
return true;
}
return [
product.name,
product.sku,
product.productTypeLabel,
String(product.widthMm ?? ''),
String(product.lengthM ?? ''),
String(product.thicknessMicron ?? ''),
normalizeText(product.sleeveBrand),
normalizeText(product.quantityPerBox),
...product.normalizedTags,
].some((part) => part.toLowerCase().includes(query));
})
.sort(compareProducts);
});
@@ -278,6 +262,8 @@ function visibleFields(group: ProductGroup) {
});
}
const selectedGroup = computed(() => productGroups.value.find((group) => group.key === props.productTypeSlug) ?? null);
function requiredKeys(group: ProductGroup) {
return visibleFields(group).map((field) => field.key);
}
@@ -293,7 +279,6 @@ function createGroupState(group: ProductGroup): GroupState {
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
colorTag: firstProduct?.colorTags[0] ?? null,
labelTag: firstProduct?.labelTags[0] ?? null,
isExpanded: false,
};
}
@@ -472,21 +457,6 @@ function articleLabel(group: ProductGroup) {
return selectedProduct(group)?.sku ?? '—';
}
function variantCountLabel(count: number) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) {
return `${count} вариант`;
}
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
return `${count} варианта`;
}
return `${count} вариантов`;
}
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
return null;
@@ -558,10 +528,6 @@ function customizationDetails(group: ProductGroup) {
return details;
}
function toggleExpanded(group: ProductGroup) {
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
}
function incrementProduct(product: ProductNode) {
if (getQuantity(product.id) === 0) {
addProduct({
@@ -602,26 +568,21 @@ function decrementSelected(group: ProductGroup) {
<template>
<section class="space-y-5">
<UiSectionSearchHero
v-model="search"
title="Каталог"
search-placeholder="Поиск по артикулу, типу товара или параметрам"
/>
<div class="flex items-center gap-3">
<NuxtLink to="/products" class="btn btn-ghost rounded-full px-3">
Назад к каталогу
</NuxtLink>
</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 v-else-if="selectedGroup" class="space-y-4">
<article class="surface-card rounded-3xl p-4 md:p-5">
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
<div class="p-3 xl:col-span-1">
<img
:src="createProductCover(group.typeLabel, group.key)"
:alt="`Превью группы ${group.typeLabel}`"
:src="createProductCover(selectedGroup.typeLabel, selectedGroup.key)"
:alt="`Превью группы ${selectedGroup.typeLabel}`"
class="aspect-square w-full rounded-[24px] object-cover"
loading="lazy"
>
@@ -629,17 +590,17 @@ function decrementSelected(group: ProductGroup) {
<div class="p-4 md:p-5 xl:col-span-4">
<div class="mb-4">
<h2 class="text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
<h2 class="text-2xl font-bold text-[#163624]">{{ selectedGroup.typeLabel }}</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-[#5d7468]">
Выберите параметры внутри карточки, а ниже при необходимости можно открыть полный список вариантов по этой позиции.
Выберите параметры внутри карточки, сразу посмотрите ограничения и ниже изучите полный список вариантов по этой позиции.
</p>
<div v-if="customizationDetails(group).length" class="mt-4 rounded-[24px] bg-[#eef8f1] p-4">
<div v-if="customizationDetails(selectedGroup).length" class="mt-4 rounded-[24px] bg-[#eef8f1] p-4">
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#15613d]">Под заказ</p>
<div class="mt-3 flex flex-wrap gap-2">
<span
v-for="note in customizationDetails(group)"
:key="`${group.key}-${note}`"
v-for="note in customizationDetails(selectedGroup)"
:key="`${selectedGroup.key}-${note}`"
class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#15613d]"
>
{{ note }}
@@ -650,8 +611,8 @@ function decrementSelected(group: ProductGroup) {
<div class="space-y-3">
<div
v-for="field in visibleFields(group)"
:key="`${group.key}-${field.key}`"
v-for="field in visibleFields(selectedGroup)"
:key="`${selectedGroup.key}-${field.key}`"
class="rounded-[22px] border border-base-200 bg-[#fbfcfb] p-4"
>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_260px] xl:items-start">
@@ -660,13 +621,13 @@ function decrementSelected(group: ProductGroup) {
<div class="mt-3 flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(group, field.key)"
:key="`${group.key}-${field.key}-${option}`"
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
:key="`${selectedGroup.key}-${field.key}-${option}`"
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
:class="[
getGroupState(group)[field.key] === option
getGroupState(selectedGroup)[field.key] === option
? 'bg-neutral text-neutral-content'
: isOptionAvailable(group, field.key, option)
: isOptionAvailable(selectedGroup, field.key, option)
? 'bg-base-100 text-base-content hover:bg-base-200'
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
'cursor-pointer',
@@ -675,9 +636,9 @@ function decrementSelected(group: ProductGroup) {
<input
type="radio"
class="sr-only"
:name="`${group.key}-${field.key}`"
:checked="getGroupState(group)[field.key] === option"
@change="updateField(group, field.key, option)"
:name="`${selectedGroup.key}-${field.key}`"
:checked="getGroupState(selectedGroup)[field.key] === option"
@change="updateField(selectedGroup, field.key, option)"
>
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
@@ -685,7 +646,7 @@ function decrementSelected(group: ProductGroup) {
</div>
<div class="rounded-[18px] bg-white p-3 text-sm leading-6 text-[#5d7468]">
{{ fieldHelperText(group, field.key) }}
{{ fieldHelperText(selectedGroup, field.key) }}
</div>
</div>
</div>
@@ -698,14 +659,14 @@ function decrementSelected(group: ProductGroup) {
<div class="space-y-3">
<div class="rounded-[22px] bg-[#f5faf7] px-4 py-3 text-center text-sm font-medium text-[#4c6a5a]">
Артикул: <span class="font-semibold text-[#163624]">{{ articleLabel(group) }}</span>
Артикул: <span class="font-semibold text-[#163624]">{{ articleLabel(selectedGroup) }}</span>
</div>
<button
v-if="selectedQty(group) === 0"
v-if="selectedQty(selectedGroup) === 0"
class="btn h-11 w-full rounded-full border-0 bg-[#139957] text-sm font-semibold text-white hover:bg-[#0d854a]"
:disabled="!selectedProduct(group)"
@click="incrementSelected(group)"
:disabled="!selectedProduct(selectedGroup)"
@click="incrementSelected(selectedGroup)"
>
В корзину
</button>
@@ -714,13 +675,13 @@ function decrementSelected(group: ProductGroup) {
<div class="flex items-center justify-between gap-2">
<button
class="btn btn-square btn-sm"
:disabled="selectedQty(group) === 0"
@click="decrementSelected(group)"
:disabled="selectedQty(selectedGroup) === 0"
@click="decrementSelected(selectedGroup)"
>
-
</button>
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(group)" @click="incrementSelected(group)">
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(selectedGroup)" @click="incrementSelected(selectedGroup)">
+
</button>
</div>
@@ -730,10 +691,7 @@ function decrementSelected(group: ProductGroup) {
</aside>
</div>
<div
v-if="getGroupState(group).isExpanded"
class="mt-4 overflow-x-auto rounded-[28px] bg-white"
>
<div class="mt-4 overflow-x-auto rounded-[28px] bg-white">
<table class="table border-separate border-spacing-0 bg-white [&_tbody_tr:hover]:bg-white [&_tbody_tr]:bg-white [&_td]:bg-white [&_th]:bg-white [&_thead_tr]:bg-white">
<thead>
<tr>
@@ -746,7 +704,7 @@ function decrementSelected(group: ProductGroup) {
</tr>
</thead>
<tbody>
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
<tr v-for="product in selectedGroup.products" :key="`${selectedGroup.key}-${product.id}`">
<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>
@@ -776,34 +734,8 @@ function decrementSelected(group: ProductGroup) {
</tbody>
</table>
</div>
<button
class="btn btn-ghost mt-3 w-full justify-center gap-2"
@click="toggleExpanded(group)"
>
<svg
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': getGroupState(group).isExpanded }"
viewBox="0 0 20 20"
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"
/>
</svg>
<span>
{{ getGroupState(group).isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' }}
({{ variantCountLabel(group.products.length) }})
</span>
</button>
</article>
</div>
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
</section>
</template>