Files
web-frontend/app/components/catalog/CatalogConfigurator.vue

491 lines
16 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' | '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>