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

496 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 getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
const values = new Set<ParamValue>();
for (const product of group.products) {
const value = product[field];
if (value !== null) {
values.add(value);
}
}
return sortParamValues([...values]);
}
function createGroupState(group: ProductGroup): GroupState {
void group;
const state: GroupState = {
widthMm: null,
lengthM: null,
thicknessMicron: null,
sleeveBrand: null,
quantityPerBox: null,
isExpanded: false,
};
return state;
}
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);
}
}
},
{ immediate: true },
);
function getGroupState(group: ProductGroup): GroupState {
const existing = groupStates[group.key];
if (existing) {
return existing;
}
const created = createGroupState(group);
groupStates[group.key] = created;
return created;
}
function updateField(group: ProductGroup, field: ParamFieldKey, value: ParamValue) {
const state = getGroupState(group);
state[field] = value as GroupState[typeof field];
}
function selectedProduct(group: ProductGroup) {
const state = getGroupState(group);
if (PARAM_KEYS.some((key) => state[key] === null)) {
return null;
}
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 selectedFieldValue(group: ProductGroup, field: ParamFieldKey) {
const value = getGroupState(group)[field];
if (value === null) {
return 'не выбрано';
}
return formatOptionLabel(field, 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 gap-3">
<h2 class="text-xl font-bold text-[#133826]">{{ group.typeLabel }}</h2>
<span class="badge badge-outline">{{ group.products.length }} вариантов</span>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-[360px_1fr]">
<aside class="rounded-2xl bg-base-100 p-3">
<img
:src="createProductCover(group.typeLabel, group.key)"
:alt="`Превью группы ${group.typeLabel}`"
class="h-56 w-full rounded-2xl object-cover"
loading="lazy"
>
<div class="mt-3 flex flex-wrap gap-2">
<span class="badge badge-neutral">SKU: {{ selectedProduct(group)?.sku ?? '—' }}</span>
<span class="badge badge-outline">Совпадений: {{ matchingCount(group) }}</span>
</div>
<div class="mt-3 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>
<div class="mt-3 flex flex-wrap gap-2">
<span
v-for="field in parameterFields"
:key="`selected-${group.key}-${field.key}`"
class="badge badge-outline"
>
{{ field.label }}: {{ selectedFieldValue(group, field.key) }}
</span>
</div>
</aside>
<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 getAllFieldOptions(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>
</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>
<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>