feat(catalog): group products by type with parameter selectors
This commit is contained in:
@@ -3,61 +3,286 @@ import { useQuery } from '@vue/apollo-composable';
|
|||||||
import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated';
|
import { ClientProductsDocument, type ClientProductsQuery } from '~/composables/graphql/generated';
|
||||||
import { useClientCart } from '~/composables/useClientCart';
|
import { useClientCart } from '~/composables/useClientCart';
|
||||||
|
|
||||||
const { result, loading, error } = useQuery(ClientProductsDocument);
|
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||||||
const search = ref('');
|
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox';
|
||||||
const stockFilter = ref<'ALL' | 'CUSTOM' | 'STANDARD'>('ALL');
|
type ParamValue = number | string;
|
||||||
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
|
||||||
|
|
||||||
const coverPresets = [
|
type ParsedProduct = ProductNode & {
|
||||||
['#e9fbe5', '#acfcd5', '#7be9aa'],
|
productTypeLabel: string;
|
||||||
['#f5fff7', '#d9f5e6', '#8bd8b0'],
|
widthMm: number | null;
|
||||||
['#fef4ed', '#ffe5d8', '#ffd1b8'],
|
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: 'Количество в коробе' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function createProductCover(name: string, sku: string) {
|
const { result, loading, error } = useQuery(ClientProductsDocument);
|
||||||
const seed = `${name}${sku}`.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
const search = ref('');
|
||||||
const [start, middle, finish] = coverPresets[seed % coverPresets.length];
|
const groupStates = reactive<Record<string, GroupState>>({});
|
||||||
const firstLetter = name.trim().charAt(0).toUpperCase() || 'P';
|
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
||||||
|
|
||||||
const svg = `
|
function normalizeText(value: string) {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
return value.replaceAll(/\s+/g, ' ').trim();
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="${start}" />
|
|
||||||
<stop offset="56%" stop-color="${middle}" />
|
|
||||||
<stop offset="100%" stop-color="${finish}" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="640" height="480" fill="url(#g)" rx="38" />
|
|
||||||
<g opacity="0.18">
|
|
||||||
<circle cx="520" cy="66" r="100" fill="#0d854a" />
|
|
||||||
<circle cx="80" cy="440" r="100" fill="#0d854a" />
|
|
||||||
</g>
|
|
||||||
<text x="50%" y="53%" text-anchor="middle" fill="#0f2f20" font-family="Manrope, sans-serif" font-size="186" font-weight="700">${firstLetter}</text>
|
|
||||||
</svg>
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredProducts = computed(() => {
|
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 list = result.value?.clientProducts ?? [];
|
||||||
const normalizedSearch = search.value.trim().toLowerCase();
|
const query = search.value.trim().toLowerCase();
|
||||||
|
|
||||||
return list.filter((product) => {
|
return list
|
||||||
const matchSearch = !normalizedSearch
|
.map((product) => {
|
||||||
|| product.name.toLowerCase().includes(normalizedSearch)
|
const parsedMeta = parseProductMeta(product);
|
||||||
|| product.sku.toLowerCase().includes(normalizedSearch);
|
return {
|
||||||
|
...product,
|
||||||
|
...parsedMeta,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((product) => {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const matchType = stockFilter.value === 'ALL'
|
return [
|
||||||
|| (stockFilter.value === 'CUSTOM' && product.isCustomizable)
|
product.name,
|
||||||
|| (stockFilter.value === 'STANDARD' && !product.isCustomizable);
|
product.sku,
|
||||||
|
product.productTypeLabel,
|
||||||
return matchSearch && matchType;
|
product.sleeveBrand ?? '',
|
||||||
});
|
].some((part) => part.toLowerCase().includes(query));
|
||||||
|
})
|
||||||
|
.sort(compareProducts);
|
||||||
});
|
});
|
||||||
|
|
||||||
function addProductToCart(product: ClientProductsQuery['clientProducts'][number]) {
|
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);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 currentKeys = new Set(groups.map((group) => group.key));
|
||||||
|
|
||||||
|
for (const key of Object.keys(groupStates)) {
|
||||||
|
if (!currentKeys.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 addProductToCart(product: ProductNode) {
|
||||||
addProduct({
|
addProduct({
|
||||||
id: product.id,
|
id: product.id,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
@@ -80,83 +305,142 @@ function decrementProduct(productId: string) {
|
|||||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-4 md:p-5">
|
<div class="surface-card rounded-3xl p-4 md:p-5">
|
||||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label-text">Поиск</span>
|
<span class="label-text">Поиск</span>
|
||||||
<input
|
<input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
placeholder="Название или SKU"
|
placeholder="Тип товара, SKU или параметр"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control md:min-w-56">
|
|
||||||
<span class="label-text">Фильтр</span>
|
|
||||||
<select v-model="stockFilter" class="select select-bordered w-full">
|
|
||||||
<option value="ALL">Все товары</option>
|
|
||||||
<option value="CUSTOM">Только кастомные</option>
|
|
||||||
<option value="STANDARD">Только стандартные</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</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="error" class="alert alert-error">{{ error.message }}</div>
|
||||||
|
|
||||||
<div v-else-if="filteredProducts.length > 0" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div v-else-if="productGroups.length" class="space-y-4">
|
||||||
<article class="surface-card overflow-hidden rounded-3xl p-4">
|
<article
|
||||||
<div class="flex h-full flex-col justify-between gap-4">
|
v-for="group in productGroups"
|
||||||
<div>
|
:key="group.key"
|
||||||
<div class="badge badge-outline">Кастом</div>
|
class="surface-card rounded-3xl p-4 md:p-5"
|
||||||
<h2 class="mt-3 text-lg font-bold text-[#133826]">Конструктор скотча</h2>
|
>
|
||||||
<p class="mt-1 text-sm text-base-content/75">
|
<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_320px]">
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-disabled w-full">Скоро</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
<aside class="rounded-2xl bg-base-100 p-4">
|
||||||
v-for="(product, index) in filteredProducts"
|
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">Выбранная позиция</p>
|
||||||
:key="product.id"
|
|
||||||
class="surface-card product-card-anim overflow-hidden rounded-3xl p-3"
|
<template v-if="selectedProduct(group)">
|
||||||
:style="{ animationDelay: `${index * 55}ms` }"
|
<p class="mt-2 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 class="mt-4">
|
||||||
|
<button
|
||||||
|
v-if="selectedProduct(group) && getQuantity(selectedProduct(group)!.id) === 0"
|
||||||
|
class="btn w-full border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
|
||||||
|
@click="selectedProduct(group) && addProductToCart(selectedProduct(group)!)"
|
||||||
>
|
>
|
||||||
<figure class="overflow-hidden rounded-2xl">
|
Добавить в корзину
|
||||||
<img
|
</button>
|
||||||
:src="createProductCover(product.name, product.sku)"
|
|
||||||
:alt="`Изображение товара ${product.name}`"
|
<div
|
||||||
class="h-48 w-full object-cover transition duration-300 hover:scale-105"
|
v-else-if="selectedProduct(group)"
|
||||||
loading="lazy"
|
class="flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 px-2 py-1"
|
||||||
>
|
>
|
||||||
</figure>
|
<button class="btn btn-square btn-sm" @click="decrementProduct(selectedProduct(group)!.id)">-</button>
|
||||||
<div class="px-1 pb-2 pt-3">
|
<span class="min-w-8 text-center font-semibold">{{ getQuantity(selectedProduct(group)!.id) }}</span>
|
||||||
<h2 class="text-lg font-bold text-[#133826]">{{ product.name }}</h2>
|
<button class="btn btn-square btn-sm" @click="incrementProduct(selectedProduct(group)!.id)">+</button>
|
||||||
<p class="text-xs text-base-content/65">SKU: {{ product.sku }}</p>
|
</div>
|
||||||
<div class="mt-3">
|
</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>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>{{ 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">
|
||||||
<button
|
<button
|
||||||
v-if="getQuantity(product.id) === 0"
|
v-if="getQuantity(product.id) === 0"
|
||||||
class="btn w-full border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
|
class="btn btn-sm border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
|
||||||
@click="addProductToCart(product)"
|
@click="addProductToCart(product)"
|
||||||
>
|
>
|
||||||
В корзину
|
В корзину
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-else class="flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 px-2 py-1">
|
<div v-else class="ml-auto flex w-28 items-center justify-between rounded-xl border border-base-300 px-1 py-1">
|
||||||
<button class="btn btn-square btn-sm" @click="decrementProduct(product.id)">-</button>
|
<button class="btn btn-xs btn-square" @click="decrementProduct(product.id)">-</button>
|
||||||
<span class="min-w-8 text-center font-semibold">{{ getQuantity(product.id) }}</span>
|
<span class="text-sm font-semibold">{{ getQuantity(product.id) }}</span>
|
||||||
<button class="btn btn-square btn-sm" @click="incrementProduct(product.id)">+</button>
|
<button class="btn btn-xs btn-square" @click="incrementProduct(product.id)">+</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="alert surface-card border-0">
|
<div v-else class="alert surface-card border-0">
|
||||||
Ничего не найдено по текущим параметрам.
|
По текущему запросу товары не найдены.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user