891 lines
30 KiB
Vue
891 lines
30 KiB
Vue
<script setup lang="ts">
|
||
import { useQuery } from '@vue/apollo-composable';
|
||
import {
|
||
CatalogProductTypeSettingsDocument,
|
||
ClientProductsDocument,
|
||
type CatalogProductTypeSettingsQuery,
|
||
type ClientProductsQuery,
|
||
} from '~/composables/graphql/generated';
|
||
import { useClientCart } from '~/composables/useClientCart';
|
||
|
||
const props = defineProps<{
|
||
productTypeSlug: string;
|
||
}>();
|
||
|
||
type ProductNode = ClientProductsQuery['clientProducts'][number];
|
||
type CatalogProductTypeSettingNode = CatalogProductTypeSettingsQuery['catalogProductTypeSettings'][number];
|
||
type ParamFieldKey = 'widthMm' | 'lengthM' | 'thicknessMicron' | 'sleeveBrand' | 'quantityPerBox' | 'colorTag' | 'labelTag';
|
||
type ParamValue = number | string;
|
||
|
||
type ParsedProduct = ProductNode & {
|
||
productTypeLabel: string;
|
||
quantityPerBoxOptions: string[];
|
||
normalizedTags: string[];
|
||
colorTags: string[];
|
||
labelTags: string[];
|
||
};
|
||
|
||
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;
|
||
colorTag: string | null;
|
||
labelTag: string | null;
|
||
};
|
||
|
||
const COLOR_TAGS = ['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'];
|
||
const LABEL_TAGS = ['хрупкое', 'подарок', 'акция'];
|
||
const PARAM_KEYS: ParamFieldKey[] = ['widthMm', 'lengthM', 'thicknessMicron', 'sleeveBrand', 'quantityPerBox', 'colorTag', 'labelTag'];
|
||
const DEFAULT_CATALOG_PRODUCT_TYPE_SETTING: CatalogProductTypeSettingNode = {
|
||
productType: '',
|
||
showQuantityPerBox: false,
|
||
allowCustomLength: false,
|
||
customLengthMinM: null,
|
||
customLengthMaxM: null,
|
||
customLengthStepM: null,
|
||
allowCustomSleeveBrand: false,
|
||
allowCustomLabel: false,
|
||
widthOptionsMm: [],
|
||
lengthOptionsM: [],
|
||
thicknessOptionsMicron: [],
|
||
sleeveOptions: [],
|
||
colorOptions: [],
|
||
labelOptions: [],
|
||
};
|
||
const parameterFields: Array<{ key: ParamFieldKey; label: string }> = [
|
||
{ key: 'widthMm', label: 'Ширина' },
|
||
{ key: 'lengthM', label: 'Длина' },
|
||
{ key: 'thicknessMicron', label: 'Толщина' },
|
||
{ key: 'sleeveBrand', label: 'Втулка' },
|
||
{ key: 'quantityPerBox', label: 'Короб' },
|
||
{ key: 'colorTag', label: 'Цвет' },
|
||
{ key: 'labelTag', label: 'Надпись' },
|
||
];
|
||
|
||
const coverPresets = [
|
||
['#d9f5e6', '#9ce8c1', '#6fd09d'],
|
||
['#eaf9ef', '#b3e8cb', '#76c89f'],
|
||
['#e8f5ec', '#b2e0c6', '#7dd0a9'],
|
||
];
|
||
|
||
const productsQuery = useQuery(ClientProductsDocument);
|
||
const catalogSettingsQuery = useQuery(CatalogProductTypeSettingsDocument);
|
||
const groupStates = reactive<Record<string, GroupState>>({});
|
||
const { addProduct, getQuantity, incrementQuantity, decrementQuantity } = useClientCart();
|
||
|
||
const loading = computed(() => productsQuery.loading.value || catalogSettingsQuery.loading.value);
|
||
const error = computed(() => productsQuery.error.value || catalogSettingsQuery.error.value);
|
||
|
||
const catalogSettingsByType = computed<Record<string, CatalogProductTypeSettingNode>>(() => (
|
||
Object.fromEntries(
|
||
(catalogSettingsQuery.result.value?.catalogProductTypeSettings ?? [])
|
||
.map((setting) => [setting.productType, setting]),
|
||
)
|
||
));
|
||
|
||
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 {
|
||
const normalizedTags = product.tags.map((tag) => normalizeText(tag)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'ru'));
|
||
|
||
return {
|
||
...product,
|
||
productTypeLabel: normalizeText(product.productType) || 'Без типа',
|
||
quantityPerBoxOptions: splitBoxValues(product.quantityPerBox),
|
||
normalizedTags,
|
||
colorTags: normalizedTags.filter((tag) => COLOR_TAGS.includes(tag)),
|
||
labelTags: normalizedTags.filter((tag) => LABEL_TAGS.includes(tag)),
|
||
};
|
||
}
|
||
|
||
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 = productsQuery.result.value?.clientProducts ?? [];
|
||
|
||
return list
|
||
.map(hydrateProduct)
|
||
.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(/[^a-z0-9а-яё]+/gi, '-')
|
||
.replaceAll(/-+/g, '-')
|
||
.replaceAll(/^-|-$/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) {
|
||
if (field === 'quantityPerBox') {
|
||
for (const option of product.quantityPerBoxOptions) {
|
||
values.add(option);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (field === 'colorTag') {
|
||
for (const tag of product.colorTags) {
|
||
values.add(tag);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (field === 'labelTag') {
|
||
for (const tag of product.labelTags) {
|
||
values.add(tag);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const value = product[field];
|
||
if (value !== null && value !== undefined) {
|
||
values.add(value);
|
||
}
|
||
}
|
||
|
||
return sortParamValues([...values]);
|
||
}
|
||
|
||
function groupCatalogSetting(group: ProductGroup) {
|
||
return catalogSettingsByType.value[group.typeLabel] ?? {
|
||
...DEFAULT_CATALOG_PRODUCT_TYPE_SETTING,
|
||
productType: group.typeLabel,
|
||
};
|
||
}
|
||
|
||
function visibleFields(group: ProductGroup) {
|
||
return parameterFields.filter((field) => {
|
||
if (field.key === 'quantityPerBox') {
|
||
return false;
|
||
}
|
||
|
||
return getAllFieldOptions(group, field.key).length > 1;
|
||
});
|
||
}
|
||
|
||
const selectedGroup = computed(() => productGroups.value.find((group) => group.key === props.productTypeSlug) ?? null);
|
||
const currentGroupIndex = computed(() => productGroups.value.findIndex((group) => group.key === props.productTypeSlug));
|
||
const previousGroup = computed(() => {
|
||
if (currentGroupIndex.value <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return productGroups.value[currentGroupIndex.value - 1] ?? null;
|
||
});
|
||
const nextGroup = computed(() => {
|
||
if (currentGroupIndex.value < 0 || currentGroupIndex.value >= productGroups.value.length - 1) {
|
||
return null;
|
||
}
|
||
|
||
return productGroups.value[currentGroupIndex.value + 1] ?? null;
|
||
});
|
||
|
||
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,
|
||
quantityPerBox: firstProduct?.quantityPerBoxOptions[0] ?? null,
|
||
colorTag: firstProduct?.colorTags[0] ?? null,
|
||
labelTag: firstProduct?.labelTags[0] ?? null,
|
||
};
|
||
}
|
||
|
||
watch(
|
||
productGroups,
|
||
(groups) => {
|
||
const keys = new Set(groups.map((group) => group.key));
|
||
|
||
for (const key of Object.keys(groupStates)) {
|
||
if (!keys.has(key)) {
|
||
Reflect.deleteProperty(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) => {
|
||
if (state[key] === null) {
|
||
return true;
|
||
}
|
||
|
||
if (key === 'quantityPerBox') {
|
||
return product.quantityPerBoxOptions.includes(String(state[key]));
|
||
}
|
||
|
||
if (key === 'colorTag') {
|
||
return product.colorTags.includes(String(state[key]));
|
||
}
|
||
|
||
if (key === 'labelTag') {
|
||
return product.labelTags.includes(String(state[key]));
|
||
}
|
||
|
||
return product[key] === state[key];
|
||
});
|
||
}
|
||
|
||
function selectedProduct(group: ProductGroup) {
|
||
const keys = requiredKeys(group);
|
||
const state = getGroupState(group);
|
||
|
||
if (keys.length === 0) {
|
||
return 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[0] ?? null;
|
||
}
|
||
|
||
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 productHasOption(product: ParsedProduct, field: ParamFieldKey, option: ParamValue) {
|
||
if (field === 'quantityPerBox') {
|
||
return product.quantityPerBoxOptions.includes(String(option));
|
||
}
|
||
|
||
if (field === 'colorTag') {
|
||
return product.colorTags.includes(String(option));
|
||
}
|
||
|
||
if (field === 'labelTag') {
|
||
return product.labelTags.includes(String(option));
|
||
}
|
||
|
||
return product[field] === option;
|
||
}
|
||
|
||
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 pickBestProductForOption(group: ProductGroup, field: ParamFieldKey, option: ParamValue) {
|
||
const state = getGroupState(group);
|
||
const candidates = group.products.filter((product) => productHasOption(product, field, option));
|
||
|
||
if (candidates.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return [...candidates].sort((a, b) => {
|
||
let aScore = 0;
|
||
let bScore = 0;
|
||
|
||
for (const key of PARAM_KEYS) {
|
||
if (key === field) {
|
||
continue;
|
||
}
|
||
|
||
const selectedValue = state[key];
|
||
if (selectedValue === null) {
|
||
continue;
|
||
}
|
||
|
||
if (productHasOption(a, key, selectedValue)) {
|
||
aScore += 1;
|
||
}
|
||
if (productHasOption(b, key, selectedValue)) {
|
||
bScore += 1;
|
||
}
|
||
}
|
||
|
||
if (aScore !== bScore) {
|
||
return bScore - aScore;
|
||
}
|
||
|
||
return compareProducts(a, b);
|
||
})[0];
|
||
}
|
||
|
||
function applyProductToState(state: GroupState, product: ParsedProduct, preferredBoxOption: ParamValue | null = null) {
|
||
state.widthMm = product.widthMm ?? null;
|
||
state.lengthM = product.lengthM ?? null;
|
||
state.thicknessMicron = product.thicknessMicron ?? null;
|
||
state.sleeveBrand = product.sleeveBrand ?? null;
|
||
state.colorTag = product.colorTags[0] ?? null;
|
||
state.labelTag = product.labelTags[0] ?? null;
|
||
|
||
if (preferredBoxOption !== null && product.quantityPerBoxOptions.includes(String(preferredBoxOption))) {
|
||
state.quantityPerBox = String(preferredBoxOption);
|
||
return;
|
||
}
|
||
|
||
state.quantityPerBox = product.quantityPerBoxOptions[0] ?? null;
|
||
}
|
||
|
||
function updateField(group: ProductGroup, field: ParamFieldKey, value: ParamValue) {
|
||
const state = getGroupState(group);
|
||
const fallback = pickBestProductForOption(group, field, value);
|
||
if (!fallback) {
|
||
return;
|
||
}
|
||
|
||
applyProductToState(state, fallback, field === 'quantityPerBox' ? value : state.quantityPerBox);
|
||
|
||
if (field === 'quantityPerBox') {
|
||
state.quantityPerBox = String(value);
|
||
return;
|
||
}
|
||
|
||
state[field] = value as GroupState[typeof field];
|
||
}
|
||
|
||
function articleLabel(group: ProductGroup) {
|
||
return selectedProduct(group)?.sku ?? '—';
|
||
}
|
||
|
||
function formatLengthRange(setting: CatalogProductTypeSettingNode) {
|
||
if (!setting.customLengthMinM || !setting.customLengthMaxM || !setting.customLengthStepM) {
|
||
return null;
|
||
}
|
||
|
||
return `${setting.customLengthMinM}-${setting.customLengthMaxM} м, шаг ${setting.customLengthStepM} м`;
|
||
}
|
||
|
||
function fieldHelperText(group: ProductGroup, field: ParamFieldKey) {
|
||
const setting = groupCatalogSetting(group);
|
||
|
||
if (field === 'widthMm') {
|
||
return 'Ширина рулона.';
|
||
}
|
||
|
||
if (field === 'lengthM') {
|
||
const customRange = formatLengthRange(setting);
|
||
if (setting.allowCustomLength && customRange) {
|
||
return `Стандартная длина или своя: ${customRange}.`;
|
||
}
|
||
|
||
return 'Длина рулона в метрах.';
|
||
}
|
||
|
||
if (field === 'thicknessMicron') {
|
||
return 'Толщина материала.';
|
||
}
|
||
|
||
if (field === 'sleeveBrand') {
|
||
if (setting.allowCustomSleeveBrand) {
|
||
return 'Можно выбрать стандартную втулку или сделать с логотипом.';
|
||
}
|
||
|
||
return 'Тип втулки внутри рулона.';
|
||
}
|
||
|
||
if (field === 'colorTag') {
|
||
return 'Цвет ленты.';
|
||
}
|
||
|
||
if (field === 'labelTag') {
|
||
if (setting.allowCustomLabel) {
|
||
return 'Стандартная маркировка или своя надпись.';
|
||
}
|
||
|
||
return 'Готовая надпись или маркировка.';
|
||
}
|
||
|
||
return 'Параметр товара.';
|
||
}
|
||
|
||
function customizationDetails(group: ProductGroup) {
|
||
const setting = groupCatalogSetting(group);
|
||
const details: string[] = [];
|
||
const customRange = formatLengthRange(setting);
|
||
|
||
if (setting.allowCustomLength && customRange) {
|
||
details.push(`Длина под заказ: ${customRange}.`);
|
||
}
|
||
|
||
if (setting.allowCustomSleeveBrand) {
|
||
details.push('Втулка под заказ: можно сделать с вашим логотипом.');
|
||
}
|
||
|
||
if (setting.allowCustomLabel) {
|
||
details.push('Надпись под заказ: можно нанести вашу маркировку или текст.');
|
||
}
|
||
|
||
return details;
|
||
}
|
||
|
||
function selectedSummary(group: ProductGroup) {
|
||
return visibleFields(group)
|
||
.map((field) => {
|
||
const value = getGroupState(group)[field.key];
|
||
if (value === null) {
|
||
return null;
|
||
}
|
||
|
||
return `${field.label}: ${formatOptionLabel(field.key, value)}`;
|
||
})
|
||
.filter((item): item is string => Boolean(item));
|
||
}
|
||
|
||
function productBadges(product: ParsedProduct) {
|
||
const badges: string[] = [];
|
||
|
||
if (product.widthMm !== null) {
|
||
badges.push(`Ширина: ${product.widthMm} мм`);
|
||
}
|
||
if (product.lengthM !== null) {
|
||
badges.push(`Длина: ${product.lengthM} м`);
|
||
}
|
||
if (product.thicknessMicron !== null) {
|
||
badges.push(`Толщина: ${product.thicknessMicron} мкм`);
|
||
}
|
||
if (product.sleeveBrand) {
|
||
badges.push(`Втулка: ${product.sleeveBrand}`);
|
||
}
|
||
if (product.colorTags[0]) {
|
||
badges.push(`Цвет: ${product.colorTags[0]}`);
|
||
}
|
||
if (product.labelTags[0]) {
|
||
badges.push(`Надпись: ${product.labelTags[0]}`);
|
||
}
|
||
|
||
return badges;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
function productDetailPath(group: ProductGroup) {
|
||
return `/products/${group.key}`;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="space-y-5">
|
||
<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="selectedGroup" class="relative mx-auto max-w-[1380px] px-1 pb-10 sm:px-0">
|
||
<NuxtLink
|
||
v-if="previousGroup"
|
||
:to="productDetailPath(previousGroup)"
|
||
class="absolute left-0 top-28 z-10 hidden w-44 -translate-x-[72%] rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:-translate-x-[76%] hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
|
||
>
|
||
<img
|
||
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
|
||
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
|
||
class="aspect-square w-full rounded-[20px] object-cover"
|
||
loading="lazy"
|
||
>
|
||
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ previousGroup.typeLabel }}</p>
|
||
</NuxtLink>
|
||
|
||
<NuxtLink
|
||
v-if="nextGroup"
|
||
:to="productDetailPath(nextGroup)"
|
||
class="absolute right-0 top-28 z-10 hidden w-44 translate-x-[72%] rounded-[28px] border border-[#e6efe9] bg-white p-3 shadow-[0_20px_40px_rgba(18,56,36,0.08)] transition hover:translate-x-[76%] hover:shadow-[0_28px_48px_rgba(18,56,36,0.12)] 2xl:block"
|
||
>
|
||
<img
|
||
:src="createProductCover(nextGroup.typeLabel, nextGroup.key)"
|
||
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
|
||
class="aspect-square w-full rounded-[20px] object-cover"
|
||
loading="lazy"
|
||
>
|
||
<p class="mt-3 text-sm font-semibold leading-5 text-[#163624]">{{ nextGroup.typeLabel }}</p>
|
||
</NuxtLink>
|
||
|
||
<header class="mb-5 flex items-center gap-4 px-3 sm:px-6 xl:px-8">
|
||
<NuxtLink
|
||
to="/products"
|
||
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#dce9e1] bg-white text-xl text-[#163624] shadow-[0_10px_24px_rgba(18,56,36,0.06)] transition hover:-translate-y-0.5"
|
||
aria-label="Назад к списку товаров"
|
||
>
|
||
←
|
||
</NuxtLink>
|
||
|
||
<div class="min-w-0">
|
||
<h1 class="text-3xl font-bold leading-tight text-[#163624] md:text-[2.5rem]">{{ selectedGroup.typeLabel }}</h1>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="mb-5 grid gap-3 px-3 sm:px-6 2xl:hidden">
|
||
<NuxtLink
|
||
v-if="previousGroup"
|
||
:to="productDetailPath(previousGroup)"
|
||
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
|
||
>
|
||
<img
|
||
:src="createProductCover(previousGroup.typeLabel, previousGroup.key)"
|
||
:alt="`Перейти к товару ${previousGroup.typeLabel}`"
|
||
class="h-16 w-16 rounded-2xl object-cover"
|
||
loading="lazy"
|
||
>
|
||
<span class="text-sm font-semibold text-[#163624]">{{ previousGroup.typeLabel }}</span>
|
||
</NuxtLink>
|
||
|
||
<NuxtLink
|
||
v-if="nextGroup"
|
||
:to="productDetailPath(nextGroup)"
|
||
class="flex items-center gap-3 rounded-[24px] border border-[#e6efe9] bg-white p-3 shadow-[0_14px_30px_rgba(18,56,36,0.06)]"
|
||
>
|
||
<img
|
||
:src="createProductCover(nextGroup.typeLabel, nextGroup.key)"
|
||
:alt="`Перейти к товару ${nextGroup.typeLabel}`"
|
||
class="h-16 w-16 rounded-2xl object-cover"
|
||
loading="lazy"
|
||
>
|
||
<span class="text-sm font-semibold text-[#163624]">{{ nextGroup.typeLabel }}</span>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div class="grid gap-5 px-3 sm:px-6 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.15fr)_320px] xl:px-8">
|
||
<div class="space-y-4">
|
||
<div class="overflow-hidden rounded-[32px] border border-[#e6efe9] bg-white p-4 shadow-[0_20px_40px_rgba(18,56,36,0.06)]">
|
||
<img
|
||
:src="createProductCover(selectedGroup.typeLabel, articleLabel(selectedGroup))"
|
||
:alt="selectedGroup.typeLabel"
|
||
class="aspect-[5/4] w-full rounded-[26px] object-cover"
|
||
loading="lazy"
|
||
>
|
||
</div>
|
||
|
||
<div
|
||
v-if="selectedSummary(selectedGroup).length"
|
||
class="rounded-[28px] border border-[#e6efe9] bg-white p-4 shadow-[0_18px_36px_rgba(18,56,36,0.05)]"
|
||
>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="item in selectedSummary(selectedGroup)"
|
||
:key="`${selectedGroup.key}-${item}`"
|
||
class="rounded-full border border-[#dce9e1] bg-[#f8fbf9] px-3 py-1.5 text-sm font-medium text-[#355947]"
|
||
>
|
||
{{ item }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="customizationDetails(selectedGroup).length"
|
||
class="rounded-[28px] border border-[#dce9e1] bg-[#f7fbf8] p-4"
|
||
>
|
||
<div class="space-y-2">
|
||
<p
|
||
v-for="note in customizationDetails(selectedGroup)"
|
||
:key="`${selectedGroup.key}-${note}`"
|
||
class="text-sm leading-6 text-[#456555]"
|
||
>
|
||
{{ note }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<article
|
||
v-for="field in visibleFields(selectedGroup)"
|
||
:key="`${selectedGroup.key}-${field.key}`"
|
||
class="rounded-[28px] border border-[#e6efe9] bg-white p-5 shadow-[0_18px_36px_rgba(18,56,36,0.05)]"
|
||
>
|
||
<p class="text-base font-semibold text-[#163624]">{{ field.label }}</p>
|
||
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
<label
|
||
v-for="option in getAllFieldOptions(selectedGroup, field.key)"
|
||
:key="`${selectedGroup.key}-${field.key}-${option}`"
|
||
class="cursor-pointer rounded-2xl border px-4 py-2 text-sm font-medium transition"
|
||
:class="[
|
||
getGroupState(selectedGroup)[field.key] === option
|
||
? 'border-[#163624] bg-[#163624] text-white shadow-[0_12px_24px_rgba(22,54,36,0.18)]'
|
||
: isOptionAvailable(selectedGroup, field.key, option)
|
||
? 'border-[#dce9e1] bg-white text-[#163624] hover:border-[#163624]'
|
||
: 'border-[#e6eaee] bg-[#f3f5f7] text-[#8a949d]',
|
||
]"
|
||
>
|
||
<input
|
||
type="radio"
|
||
class="sr-only"
|
||
:name="`${selectedGroup.key}-${field.key}`"
|
||
:checked="getGroupState(selectedGroup)[field.key] === option"
|
||
@change="updateField(selectedGroup, field.key, option)"
|
||
>
|
||
<span>{{ formatOptionLabel(field.key, option) }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<p class="mt-3 text-sm leading-6 text-[#607569]">{{ fieldHelperText(selectedGroup, field.key) }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<aside class="self-start xl:sticky xl:top-24">
|
||
<div class="rounded-[30px] border border-[#e6efe9] bg-white p-5 shadow-[0_24px_48px_rgba(18,56,36,0.08)]">
|
||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[#789182]">Артикул</p>
|
||
<p class="mt-2 text-2xl font-bold leading-tight text-[#163624]">{{ articleLabel(selectedGroup) }}</p>
|
||
|
||
<div
|
||
v-if="selectedSummary(selectedGroup).length"
|
||
class="mt-5 rounded-[24px] border border-[#edf4ef] bg-[#f8fbf9] p-4"
|
||
>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="item in selectedSummary(selectedGroup)"
|
||
:key="`${selectedGroup.key}-summary-${item}`"
|
||
class="rounded-full bg-white px-3 py-1.5 text-xs font-semibold text-[#355947]"
|
||
>
|
||
{{ item }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
v-if="selectedQty(selectedGroup) === 0"
|
||
class="btn mt-5 h-12 w-full rounded-full border-0 bg-[#139957] px-6 text-base font-semibold text-white hover:bg-[#0d854a]"
|
||
:disabled="!selectedProduct(selectedGroup)"
|
||
@click="incrementSelected(selectedGroup)"
|
||
>
|
||
В корзину
|
||
</button>
|
||
|
||
<div
|
||
v-else
|
||
class="mt-5 flex items-center justify-between rounded-[24px] border border-[#dce9e1] bg-[#f8fbf9] px-2 py-2"
|
||
>
|
||
<button
|
||
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||
:disabled="selectedQty(selectedGroup) === 0"
|
||
@click="decrementSelected(selectedGroup)"
|
||
>
|
||
-
|
||
</button>
|
||
<div class="min-w-12 text-center text-lg font-semibold text-[#163624]">{{ selectedQty(selectedGroup) }}</div>
|
||
<button
|
||
class="btn btn-square border-0 bg-white text-[#163624] shadow-none hover:bg-white"
|
||
:disabled="!selectedProduct(selectedGroup)"
|
||
@click="incrementSelected(selectedGroup)"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<div class="mt-8 px-3 sm:px-6 xl:px-8">
|
||
<div class="rounded-[32px] border border-[#e6efe9] bg-white p-5 shadow-[0_20px_40px_rgba(18,56,36,0.06)]">
|
||
<p class="text-base font-semibold text-[#163624]">Доступные варианты</p>
|
||
|
||
<div class="mt-4 space-y-3">
|
||
<article
|
||
v-for="product in selectedGroup.products"
|
||
:key="`${selectedGroup.key}-${product.id}`"
|
||
class="flex flex-col gap-4 rounded-[24px] border border-[#edf4ef] bg-[#fbfcfb] p-4 lg:flex-row lg:items-center lg:justify-between"
|
||
>
|
||
<div class="min-w-0">
|
||
<p class="text-base font-semibold text-[#163624]">{{ product.sku }}</p>
|
||
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
<span
|
||
v-for="badge in productBadges(product)"
|
||
:key="`${product.id}-${badge}`"
|
||
class="rounded-full border border-[#dce9e1] bg-white px-3 py-1.5 text-xs font-medium text-[#355947]"
|
||
>
|
||
{{ badge }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex shrink-0 items-center justify-end">
|
||
<button
|
||
v-if="getQuantity(product.id) === 0"
|
||
class="btn h-10 rounded-full border-0 bg-[#139957] px-5 text-sm font-semibold text-white hover:bg-[#0d854a]"
|
||
@click="incrementProduct(product)"
|
||
>
|
||
В корзину
|
||
</button>
|
||
<div v-else class="ml-auto flex w-32 items-center justify-between rounded-[20px] border border-[#dce9e1] bg-white px-2 py-2">
|
||
<button
|
||
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||
:disabled="getQuantity(product.id) === 0"
|
||
@click="decrementProduct(product.id)"
|
||
>
|
||
-
|
||
</button>
|
||
<span class="min-w-8 text-center text-sm font-semibold text-[#163624]">{{ getQuantity(product.id) }}</span>
|
||
<button
|
||
class="btn btn-xs btn-square border-0 bg-[#f3f7f4] text-[#163624] shadow-none hover:bg-[#eaf2ec]"
|
||
@click="incrementProduct(product)"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="alert surface-card border-0">Такой тип товара не найден.</div>
|
||
</section>
|
||
</template>
|