622 lines
20 KiB
Vue
622 lines
20 KiB
Vue
<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 addButtonLabel(group: ProductGroup) {
|
||
return selectedProduct(group) ? 'В корзину' : 'Выбрать параметры';
|
||
}
|
||
|
||
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-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">
|
||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45">Тип товара</p>
|
||
<h2 class="mt-2 text-2xl font-bold text-[#163624]">{{ group.typeLabel }}</h2>
|
||
</div>
|
||
|
||
<div class="grid 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 class="rounded-[22px] bg-base-200/70 p-3">
|
||
<p v-if="selectedProduct(group)" class="text-sm font-semibold text-[#163624]">{{ selectionHeadline(group) }}</p>
|
||
<p class="text-xs leading-5 text-base-content/65">{{ selectionDescription(group) }}</p>
|
||
<p class="mt-2 text-xs leading-5 text-base-content/65">Короб: {{ boxQuantityLabel(group) }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<button
|
||
class="btn btn-neutral h-11 w-full rounded-full text-sm font-semibold"
|
||
:disabled="!selectedProduct(group)"
|
||
@click="incrementSelected(group)"
|
||
>
|
||
{{ addButtonLabel(group) }}
|
||
</button>
|
||
|
||
<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>
|