Split catalog into list and detail pages
This commit is contained in:
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
import { useClientCart } from '~/composables/useClientCart';
|
import { useClientCart } from '~/composables/useClientCart';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
productTypeSlug: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||||
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||||||
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
|
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
|
||||||
@@ -35,7 +39,6 @@ type GroupState = {
|
|||||||
quantityPerBox: string | null;
|
quantityPerBox: string | null;
|
||||||
colorTag: string | null;
|
colorTag: string | null;
|
||||||
labelTag: string | null;
|
labelTag: string | null;
|
||||||
isExpanded: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
|
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
|
||||||
@@ -75,7 +78,6 @@ const coverPresets = [
|
|||||||
|
|
||||||
const productsQuery = useQuery(ClientProductsDocument);
|
const productsQuery = useQuery(ClientProductsDocument);
|
||||||
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||||
const search = ref('');
|
|
||||||
const groupStates = reactive<Record<string, GroupState>>({});
|
const groupStates = reactive<Record<string, GroupState>>({});
|
||||||
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
||||||
|
|
||||||
@@ -169,27 +171,9 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
|
|||||||
|
|
||||||
const parsedProducts = computed<ParsedProduct[]>(() => {
|
const parsedProducts = computed<ParsedProduct[]>(() => {
|
||||||
const list = productsQuery.result.value?.clientProducts ?? [];
|
const list = productsQuery.result.value?.clientProducts ?? [];
|
||||||
const query = search.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
return list
|
return list
|
||||||
.map(hydrateProduct)
|
.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);
|
.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) {
|
function requiredKeys(group: ProductGroup) {
|
||||||
return visibleFields(group).map((field) => field.key);
|
return visibleFields(group).map((field) => field.key);
|
||||||
}
|
}
|
||||||
@@ -293,7 +279,6 @@ function createGroupState(group: ProductGroup): GroupState {
|
|||||||
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
|
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
|
||||||
colorTag: firstProduct?.colorTags[0] ?? null,
|
colorTag: firstProduct?.colorTags[0] ?? null,
|
||||||
labelTag: firstProduct?.labelTags[0] ?? null,
|
labelTag: firstProduct?.labelTags[0] ?? null,
|
||||||
isExpanded: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,21 +457,6 @@ function articleLabel(group: ProductGroup) {
|
|||||||
return selectedProduct(group)?.sku ?? '—';
|
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) {
|
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
|
||||||
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
|
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
|
||||||
return null;
|
return null;
|
||||||
@@ -558,10 +528,6 @@ function customizationDetails(group: ProductGroup) {
|
|||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded(group: ProductGroup) {
|
|
||||||
getGroupState(group).isExpanded = !getGroupState(group).isExpanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
function incrementProduct(product: ProductNode) {
|
function incrementProduct(product: ProductNode) {
|
||||||
if (getQuantity(product.id) === 0) {
|
if (getQuantity(product.id) === 0) {
|
||||||
addProduct({
|
addProduct({
|
||||||
@@ -602,26 +568,21 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="space-y-5">
|
<section class="space-y-5">
|
||||||
<UiSectionSearchHero
|
<div class="flex items-center gap-3">
|
||||||
v-model="search"
|
<NuxtLink to="/products" class="btn btn-ghost rounded-full px-3">
|
||||||
title="Каталог"
|
← Назад к каталогу
|
||||||
search-placeholder="Поиск по артикулу, типу товара или параметрам"
|
</NuxtLink>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</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="error" class="alert alert-error">{{ error.message }}</div>
|
||||||
|
<div v-else-if="selectedGroup" class="space-y-4">
|
||||||
<div v-else-if="productGroups.length" class="space-y-4">
|
<article class="surface-card rounded-3xl p-4 md:p-5">
|
||||||
<article
|
|
||||||
v-for="group in productGroups"
|
|
||||||
:key="group.key"
|
|
||||||
class="surface-card rounded-3xl p-4 md:p-5"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
|
<div class="grid gap-4 xl:grid-cols-6 xl:items-start">
|
||||||
<div class="p-3 xl:col-span-1">
|
<div class="p-3 xl:col-span-1">
|
||||||
<img
|
<img
|
||||||
:src="createProductCover(group.typeLabel, group.key)"
|
:src="createProductCover(selectedGroup.typeLabel, selectedGroup.key)"
|
||||||
:alt="`Превью группы ${group.typeLabel}`"
|
:alt="`Превью группы ${selectedGroup.typeLabel}`"
|
||||||
class="aspect-square w-full rounded-[24px] object-cover"
|
class="aspect-square w-full rounded-[24px] object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
>
|
>
|
||||||
@@ -629,17 +590,17 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
|
|
||||||
<div class="p-4 md:p-5 xl:col-span-4">
|
<div class="p-4 md:p-5 xl:col-span-4">
|
||||||
<div class="mb-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 class="mt-2 max-w-3xl text-sm leading-6 text-[#5d7468]">
|
||||||
Выберите параметры внутри карточки, а ниже при необходимости можно открыть полный список вариантов по этой позиции.
|
Выберите параметры внутри карточки, сразу посмотрите ограничения и ниже изучите полный список вариантов по этой позиции.
|
||||||
</p>
|
</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>
|
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#15613d]">Под заказ</p>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<span
|
<span
|
||||||
v-for="note in customizationDetails(group)"
|
v-for="note in customizationDetails(selectedGroup)"
|
||||||
:key="`${group.key}-${note}`"
|
:key="`${selectedGroup.key}-${note}`"
|
||||||
class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#15613d]"
|
class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#15613d]"
|
||||||
>
|
>
|
||||||
{{ note }}
|
{{ note }}
|
||||||
@@ -650,8 +611,8 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="field in visibleFields(group)"
|
v-for="field in visibleFields(selectedGroup)"
|
||||||
:key="`${group.key}-${field.key}`"
|
:key="`${selectedGroup.key}-${field.key}`"
|
||||||
class="rounded-[22px] border border-base-200 bg-[#fbfcfb] p-4"
|
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">
|
<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">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<label
|
<label
|
||||||
v-for="option in getAllFieldOptions(group, field.key)"
|
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
|
||||||
:key="`${group.key}-${field.key}-${option}`"
|
:key="`${selectedGroup.key}-${field.key}-${option}`"
|
||||||
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
class="btn btn-sm rounded-full text-sm normal-case shadow-none transition"
|
||||||
:class="[
|
:class="[
|
||||||
getGroupState(group)[field.key] === option
|
getGroupState(selectedGroup)[field.key] === option
|
||||||
? 'bg-neutral text-neutral-content'
|
? '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-base-100 text-base-content hover:bg-base-200'
|
||||||
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
: 'bg-[#eef1f4] text-[#7b8591] hover:bg-[#e3e7eb]',
|
||||||
'cursor-pointer',
|
'cursor-pointer',
|
||||||
@@ -675,9 +636,9 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
:name="`${group.key}-${field.key}`"
|
:name="`${selectedGroup.key}-${field.key}`"
|
||||||
:checked="getGroupState(group)[field.key] === option"
|
:checked="getGroupState(selectedGroup)[field.key] === option"
|
||||||
@change="updateField(group, field.key, option)"
|
@change="updateField(selectedGroup, field.key, option)"
|
||||||
>
|
>
|
||||||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -685,7 +646,7 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-[18px] bg-white p-3 text-sm leading-6 text-[#5d7468]">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -698,14 +659,14 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="rounded-[22px] bg-[#f5faf7] px-4 py-3 text-center text-sm font-medium text-[#4c6a5a]">
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<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]"
|
class="btn h-11 w-full rounded-full border-0 bg-[#139957] text-sm font-semibold text-white hover:bg-[#0d854a]"
|
||||||
:disabled="!selectedProduct(group)"
|
:disabled="!selectedProduct(selectedGroup)"
|
||||||
@click="incrementSelected(group)"
|
@click="incrementSelected(selectedGroup)"
|
||||||
>
|
>
|
||||||
В корзину
|
В корзину
|
||||||
</button>
|
</button>
|
||||||
@@ -714,13 +675,13 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-square btn-sm"
|
class="btn btn-square btn-sm"
|
||||||
:disabled="selectedQty(group) === 0"
|
:disabled="selectedQty(selectedGroup) === 0"
|
||||||
@click="decrementSelected(group)"
|
@click="decrementSelected(selectedGroup)"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-10 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
|
<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(group)" @click="incrementSelected(group)">
|
<button class="btn btn-square btn-sm" :disabled="!selectedProduct(selectedGroup)" @click="incrementSelected(selectedGroup)">
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,10 +691,7 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="mt-4 overflow-x-auto rounded-[28px] bg-white">
|
||||||
v-if="getGroupState(group).isExpanded"
|
|
||||||
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">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -746,7 +704,7 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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.sku }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
|
<td class="border-b border-base-200">{{ product.widthMm ?? '—' }}</td>
|
||||||
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
|
<td class="border-b border-base-200">{{ product.lengthM ?? '—' }}</td>
|
||||||
@@ -776,34 +734,8 @@ function decrementSelected(group: ProductGroup) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
|
||||||
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
222
app/components/catalog/CatalogProductTypeList.vue
Normal file
222
app/components/catalog/CatalogProductTypeList.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
CatalogProductTypeSettingsDocument,
|
||||||
|
ClientProductsDocument,
|
||||||
|
type CatalogProductTypeSettingsQuery,
|
||||||
|
type ClientProductsQuery,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
|
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||||
|
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||||||
|
type ProductTypeCard = {
|
||||||
|
key: string;
|
||||||
|
typeLabel: string;
|
||||||
|
productCount: number;
|
||||||
|
widthCount: number;
|
||||||
|
lengthCount: number;
|
||||||
|
customizationDetails: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const productsQuery = useQuery(ClientProductsDocument);
|
||||||
|
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||||||
|
const search = ref('');
|
||||||
|
|
||||||
|
const loading = computed(() => productsQuery.loading.value || catalogSettingsQuery.loading.value);
|
||||||
|
const error = computed(() => productsQuery.error.value || catalogSettingsQuery.error.value);
|
||||||
|
|
||||||
|
const catalogSettingsByType = computed<Record<string, CatalogProductTypeSettingNode>>(() => (
|
||||||
|
Object.fromEntries(
|
||||||
|
(catalogSettingsQuery.result.value?.catalogProductTypeSettings ?? [])
|
||||||
|
.map((setting) => [setting.productType, setting]),
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
function normalizeText(value: string | null | undefined) {
|
||||||
|
return String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyTypeLabel(value: string) {
|
||||||
|
return normalizeText(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9а-яё]+/gi, '-')
|
||||||
|
.replaceAll(/-+/g, '-')
|
||||||
|
.replaceAll(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProductCover(name: string, sku: string) {
|
||||||
|
const coverPresets = [
|
||||||
|
['#d9f5e6', '#9ce8c1', '#6fd09d'],
|
||||||
|
['#eaf9ef', '#b3e8cb', '#76c89f'],
|
||||||
|
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||||||
|
];
|
||||||
|
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 formatLengthRange(setting: CatalogProductTypeSettingNode) {
|
||||||
|
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${setting.customLengthMinM}-${setting.customLengthMaxM} м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customizationDetails(typeLabel: string) {
|
||||||
|
const setting = catalogSettingsByType.value[typeLabel];
|
||||||
|
if (!setting) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: string[] = [];
|
||||||
|
const lengthRange = formatLengthRange(setting);
|
||||||
|
|
||||||
|
if (setting.allowCustomLength && lengthRange) {
|
||||||
|
details.push(`Длина под заказ ${lengthRange}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.allowCustomSleeveBrand) {
|
||||||
|
details.push('Втулка с логотипом');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.allowCustomLabel) {
|
||||||
|
details.push('Нанесение надписи');
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypeCards = computed<ProductTypeCard[]>(() => {
|
||||||
|
const products = productsQuery.result.value?.clientProducts ?? [];
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
const grouped = new Map<string, ProductNode[]>();
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
const typeLabel = normalizeText(product.productType) || 'Без типа';
|
||||||
|
const existing = grouped.get(typeLabel);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(product);
|
||||||
|
} else {
|
||||||
|
grouped.set(typeLabel, [product]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.entries()]
|
||||||
|
.map(([typeLabel, items]) => {
|
||||||
|
const widthCount = new Set(items.map((item) => item.widthMm).filter((value) => value != null)).size;
|
||||||
|
const lengthCount = new Set(items.map((item) => item.lengthM).filter((value) => value != null)).size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: slugifyTypeLabel(typeLabel),
|
||||||
|
typeLabel,
|
||||||
|
productCount: items.length,
|
||||||
|
widthCount,
|
||||||
|
lengthCount,
|
||||||
|
customizationDetails: customizationDetails(typeLabel),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((card) => {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
card.typeLabel,
|
||||||
|
...card.customizationDetails,
|
||||||
|
String(card.productCount),
|
||||||
|
].some((part) => part.toLowerCase().includes(query));
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.typeLabel.localeCompare(b.typeLabel, 'ru'));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="space-y-5">
|
||||||
|
<UiSectionSearchHero
|
||||||
|
v-model="search"
|
||||||
|
title="Каталог"
|
||||||
|
search-placeholder="Поиск по типу товара"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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="productTypeCards.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="card in productTypeCards"
|
||||||
|
:key="card.key"
|
||||||
|
:to="`/products/${card.key}`"
|
||||||
|
class="surface-card block rounded-3xl p-4 transition hover:-translate-y-0.5 hover:shadow-[0_22px_42px_rgba(18,56,36,0.12)]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="createProductCover(card.typeLabel, card.key)"
|
||||||
|
:alt="`Превью ${card.typeLabel}`"
|
||||||
|
class="aspect-[4/3] w-full rounded-[24px] object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-[#163624]">{{ card.typeLabel }}</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[#5d7468]">
|
||||||
|
Откройте карточку товара, чтобы выбрать параметры, посмотреть ограничения и сразу добавить нужный вариант в корзину.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="rounded-full bg-[#f2f7f4] px-3 py-1 text-xs font-semibold text-[#355947]">
|
||||||
|
{{ card.productCount }} вариантов
|
||||||
|
</span>
|
||||||
|
<span v-if="card.widthCount" class="rounded-full bg-[#f2f7f4] px-3 py-1 text-xs font-semibold text-[#355947]">
|
||||||
|
{{ card.widthCount }} ширин
|
||||||
|
</span>
|
||||||
|
<span v-if="card.lengthCount" class="rounded-full bg-[#f2f7f4] px-3 py-1 text-xs font-semibold text-[#355947]">
|
||||||
|
{{ card.lengthCount }} длин
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="card.customizationDetails.length" class="rounded-[20px] bg-[#eef8f1] p-3">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#15613d]">Под заказ</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="detail in card.customizationDetails"
|
||||||
|
:key="`${card.key}-${detail}`"
|
||||||
|
class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#15613d]"
|
||||||
|
>
|
||||||
|
{{ detail }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-flex items-center gap-2 text-sm font-semibold text-[#139957]">
|
||||||
|
<span>Открыть карточку</span>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CatalogConfigurator />
|
<CatalogProductTypeList />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
import CatalogProductTypeList from '~/components/catalog/CatalogProductTypeList.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CatalogConfigurator />
|
<CatalogProductTypeList />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
11
app/pages/products/[slug].vue
Normal file
11
app/pages/products/[slug].vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CatalogConfigurator from '~/components/catalog/CatalogConfigurator.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const productTypeSlug = computed(() => String(route.params.slug ?? ''));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CatalogConfigurator :product-type-slug="productTypeSlug" />
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user