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

558 lines
19 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(group: ProductGroup): GroupState {
const firstProduct = group.products[0];
return {
widthMm: firstProduct?.widthMm ?? null,
lengthM: firstProduct?.lengthM ?? null,
thicknessMicron: firstProduct?.thicknessMicron ?? null,
sleeveBrand: firstProduct?.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(group);
}
},
{ immediate: true },
);
function getGroupState(group: ProductGroup) {
groupStates[group.key] ??= createGroupState(group);
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 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];
const resolved = selectedProduct(group);
if (resolved) {
return;
}
const fallback = group.products.find((product) => product[field] === value) ?? group.products[0];
if (!fallback) {
return;
}
state.widthMm = fallback.widthMm ?? null;
state.lengthM = fallback.lengthM ?? null;
state.thicknessMicron = fallback.thicknessMicron ?? null;
state.sleeveBrand = fallback.sleeveBrand ?? null;
}
function articleLabel(group: ProductGroup) {
return selectedProduct(group)?.sku ?? '—';
}
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-start">
<div class="rounded-[28px] bg-base-100 p-3 xl:col-span-1">
<img
:src="createProductCover(group.typeLabel, group.key)"
:alt="`Превью группы ${group.typeLabel}`"
class="aspect-square 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="mb-4">
<h2 class="text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
</div>
<div class="space-y-4">
<div
v-for="field in visibleFields(group)"
:key="`${group.key}-${field.key}`"
class="border-b border-base-200 pb-4 last:border-b-0 last:pb-0"
>
<p class="text-sm font-semibold text-[#163624]">{{ field.label }}</p>
<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>
</div>
</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">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45">Артикул</p>
<p class="text-xl font-bold text-[#163624]">{{ articleLabel(group) }}</p>
<p class="text-xs leading-5 text-base-content/65">Короб: {{ boxQuantityLabel(group) }}</p>
</div>
<div class="space-y-3">
<button
v-if="selectedQty(group) === 0"
class="btn btn-success h-11 w-full rounded-full border-0 text-sm font-semibold text-success-content"
:disabled="!selectedProduct(group)"
@click="incrementSelected(group)"
>
В корзину
</button>
<div v-else 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>
</div>
</div>
</aside>
</div>
<div
v-if="getGroupState(group).isExpanded"
class="mt-4 overflow-x-auto rounded-[28px] bg-base-100"
>
<table class="table border-separate border-spacing-0">
<thead>
<tr>
<th class="border-b border-base-300">Фото</th>
<th class="border-b border-base-300">Артикул</th>
<th class="border-b border-base-300">Ширина</th>
<th class="border-b border-base-300">Длина</th>
<th class="border-b border-base-300">Толщина</th>
<th class="border-b border-base-300">Втулка</th>
<th class="border-b border-base-300">Короб</th>
<th class="border-b border-base-300 text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="product in group.products" :key="`${group.key}-${product.id}`">
<td class="border-b border-base-200">
<img
:src="createProductCover(product.name, product.sku)"
:alt="`Превью ${product.sku}`"
class="h-12 w-12 rounded-lg object-cover"
loading="lazy"
>
</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.lengthM ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.thicknessMicron ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.sleeveBrand ?? '—' }}</td>
<td class="border-b border-base-200">{{ product.quantityPerBox ?? '—' }}</td>
<td class="border-b border-base-200 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>