Files
web-frontend/app/components/catalog/CatalogConfigurator.vue
2026-04-03 15:08:36 +07:00

618 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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';
type ParamValue = number | string;
type ParsedProduct = ProductNode & {
productTypeLabel: string;
quantityPerBoxOptions: string[];
};
type ProductGroup = {
key: string;
typeLabel: string;
products: ParsedProduct[];
};
type GroupState = {
widthMm: number | null;
lengthM: number | null;
thicknessMicron: number | null;
sleeveBrand: string | null;
isExpanded: boolean;
};
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: 'Втулка' },
];
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 | 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) {
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 hydrateProduct(product: ProductNode): ParsedProduct {
return {
...product,
productTypeLabel: normalizeText(product.productType) || 'Без типа',
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
};
}
function productSortValue(value: number | null | undefined) {
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 = normalizeText(a.sleeveBrand).localeCompare(normalizeText(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(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),
].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 existing = map.get(product.productTypeLabel);
if (existing) {
existing.push(product);
} else {
map.set(product.productTypeLabel, [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 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 getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
const values = new Set<ParamValue>();
for (const product of group.products) {
const value = product[field];
if (value !== null && value !== undefined) {
values.add(value);
}
}
return sortParamValues([...values]);
}
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,
};
}
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) {
groupStates[group.key] ??= createGroupState();
}
},
{ immediate: true },
);
function getGroupState(group: ProductGroup) {
groupStates[group.key] ??= createGroupState();
return groupStates[group.key];
}
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);
return group.products.filter((product) => matchesProductState(product, state, requiredKeys(group)));
}
function selectedProduct(group: ProductGroup) {
const keys = requiredKeys(group);
const state = getGroupState(group);
if (keys.length === 0) {
return group.products.length === 1 ? group.products[0] : null;
}
if (keys.some((key) => state[key] === null)) {
return null;
}
const matches = group.products.filter((product) => matchesProductState(product, state, keys));
return matches.length === 1 ? matches[0] : null;
}
function remainingSelectionCount(group: ProductGroup) {
const state = getGroupState(group);
return requiredKeys(group).filter((key) => state[key] === null).length;
}
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 selectionHeadline(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
return `SKU ${product.sku}`;
}
const remaining = remainingSelectionCount(group);
if (remaining > 0) {
return `Выберите еще ${remaining} ${pluralize(remaining, 'параметр', 'параметра', 'параметров')}`;
}
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(' • ');
}
const remaining = remainingSelectionCount(group);
if (remaining > 0) {
return 'Переключатели ниже собирают точную модификацию товара.';
}
return 'Разверните весь список, если нужна ручная проверка вариантов.';
}
function boxQuantityLabel(group: ProductGroup) {
const product = selectedProduct(group);
if (product?.quantityPerBoxOptions.length) {
return product.quantityPerBoxOptions.join(' / ');
}
const values = new Set<string>();
for (const item of group.products) {
for (const option of item.quantityPerBoxOptions) {
values.add(option);
}
}
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({
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);
return product ? getQuantity(product.id) : 0;
}
function incrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
incrementProduct(product);
}
}
function decrementSelected(group: ProductGroup) {
const product = selectedProduct(group);
if (product) {
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="grid gap-4 xl:grid-cols-6 xl:items-stretch">
<div class="rounded-[28px] bg-base-100 p-3 xl:col-span-1">
<img
:src="createProductCover(group.typeLabel, group.key)"
:alt="`Превью группы ${group.typeLabel}`"
class="h-full min-h-[220px] w-full rounded-[24px] object-cover"
loading="lazy"
>
</div>
<div class="rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-4">
<div class="grid h-full gap-4 md:grid-cols-2">
<fieldset
v-for="field in visibleFields(group)"
:key="`${group.key}-${field.key}`"
class="rounded-[24px] bg-base-200/50 p-4"
>
<div class="flex items-center justify-between gap-3">
<legend class="text-sm font-semibold text-[#163624]">{{ field.label }}</legend>
<button
v-if="getGroupState(group)[field.key] !== null"
class="btn btn-ghost btn-xs rounded-full px-2"
@click="clearField(group, field.key)"
>
Сбросить
</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<label
v-for="option in getAllFieldOptions(group, field.key)"
:key="`${group.key}-${field.key}-${option}`"
class="btn btn-sm rounded-full border text-sm normal-case shadow-none transition"
:class="[
getGroupState(group)[field.key] === option
? 'border-neutral bg-neutral text-neutral-content'
: 'border-base-300 bg-base-100 text-base-content hover:border-neutral/30 hover:bg-base-200',
!isOptionAvailable(group, field.key, option)
? 'pointer-events-none opacity-35'
: 'cursor-pointer',
]"
>
<input
type="radio"
class="sr-only"
:name="`${group.key}-${field.key}`"
:checked="getGroupState(group)[field.key] === option"
:disabled="!isOptionAvailable(group, field.key, option)"
@change="updateField(group, field.key, option)"
>
<span>{{ formatOptionLabel(field.key, option) }}</span>
</label>
</div>
</fieldset>
</div>
</div>
<aside class="rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-1">
<div class="flex h-full flex-col justify-between gap-4">
<div class="space-y-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45">Тип товара</p>
<h2 class="mt-2 text-xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
</div>
<div class="flex flex-wrap gap-2">
<span class="badge badge-outline">{{ group.products.length }} вариантов</span>
<span class="badge badge-outline">Короб: {{ boxQuantityLabel(group) }}</span>
</div>
<div class="rounded-[22px] bg-base-200/70 p-3">
<p class="text-sm font-semibold text-[#163624]">{{ selectionHeadline(group) }}</p>
<p class="mt-1 text-xs leading-5 text-base-content/65">{{ selectionDescription(group) }}</p>
</div>
</div>
<div class="space-y-3">
<div class="rounded-[22px] border border-base-300 bg-base-100 px-2 py-1">
<div class="flex items-center justify-between gap-2">
<button
class="btn btn-square btn-sm"
:disabled="selectedQty(group) === 0"
@click="decrementSelected(group)"
>
-
</button>
<div class="text-center">
<div class="text-[11px] uppercase tracking-[0.16em] text-base-content/45">В корзине</div>
<div class="text-lg font-semibold text-[#163624]">{{ selectedQty(group) }}</div>
</div>
<button
class="btn btn-square btn-sm"
:disabled="!selectedProduct(group)"
@click="incrementSelected(group)"
>
+
</button>
</div>
</div>
<button
class="btn btn-ghost btn-sm w-full rounded-full"
:disabled="requiredKeys(group).every((key) => getGroupState(group)[key] === null)"
@click="clearSelection(group)"
>
Сбросить выбор
</button>
</div>
</div>
</aside>
</div>
<div
v-if="getGroupState(group).isExpanded"
class="mt-4 overflow-x-auto rounded-[28px] 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>
<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 ? 'Свернуть все варианты' : 'Развернуть все варианты' }}</span>
</button>
</article>
</div>
<div v-else class="alert surface-card border-0">По текущему запросу товары не найдены.</div>
</section>
</template>