Introduced `useExactProducts` composable to fetch precise product details for guest cart and wishlist items. Improved cookie-based cart and wishlist fallback handling for unauthenticated users. Updated related components and composables for better synchronization and type safety. - Added `useExactProducts` composable leveraging the `GET_EXACT_PRODUCTS` query. - Enhanced `wishlist.vue` and `cart.vue` for reactive updates on guest state changes. - Improved product synchronization logic in `useOrderSync` and `useWishlistSync`. - Updated translations and fixed minor typos in localization files. Improves user experience by ensuring consistent product details, even for guests. No breaking changes.
714 lines
No EOL
17 KiB
Vue
714 lines
No EOL
17 KiB
Vue
<template>
|
|
<div class="product">
|
|
<div class="container">
|
|
<div class="product__wrapper">
|
|
<div class="product__block">
|
|
<div class="product__images">
|
|
<div class="product__images-gallery">
|
|
<div
|
|
v-for="(image, idx) in images"
|
|
:key="idx"
|
|
@click="selectImage(image)"
|
|
:class="[{ active: image === selectedImage }]"
|
|
>
|
|
<nuxt-img
|
|
:src="image"
|
|
:alt="product?.name"
|
|
format="webp"
|
|
densities="x1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<nuxt-img
|
|
:src="selectedImage"
|
|
:alt="product?.name"
|
|
class="product__images-main"
|
|
format="webp"
|
|
densities="x1"
|
|
/>
|
|
</div>
|
|
<div class="product__main">
|
|
<p class="product__main-brand">{{ product?.brand.name }}</p>
|
|
<h1 class="product__title">{{ product?.name }}</h1>
|
|
<el-rate
|
|
class="product__main-rate"
|
|
:model-value="product?.rating"
|
|
allow-half
|
|
disabled
|
|
/>
|
|
<p class="product__main-price">{{ product?.price }} $</p>
|
|
<p class="product__main-stock">{{ t('cards.product.stock') }} {{ product?.quantity }}</p>
|
|
<div class="product__main-description" v-html="product?.description" />
|
|
<div class="tools" v-if="isProductInCart">
|
|
<button
|
|
class="tools__item tools__item-button"
|
|
@click="overwriteOrder({
|
|
type: 'remove',
|
|
productUuid: product.uuid,
|
|
productName: product.name
|
|
})"
|
|
>
|
|
-
|
|
</button>
|
|
<span class="tools__item tools__item-count" v-text="'X' + productInCartQuantity" />
|
|
<button
|
|
class="tools__item tools__item-button"
|
|
@click="overwriteOrder({
|
|
type: 'add',
|
|
productUuid: product.uuid,
|
|
productName: product.name
|
|
})"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
<ui-button
|
|
v-else
|
|
class="product__main-button"
|
|
@click="overwriteOrder({
|
|
type: 'add',
|
|
productUuid: product.uuid,
|
|
productName: product.name
|
|
})"
|
|
:type="'button'"
|
|
:isLoading="addLoading"
|
|
>
|
|
{{ t('buttons.addToCart') }}
|
|
</ui-button>
|
|
<div class="product__main-buttons">
|
|
<ui-button
|
|
@click="overwriteWishlist({
|
|
type: (isProductInWishlist ? 'remove' : 'add'),
|
|
productUuid: product.uuid,
|
|
productName: product.name,
|
|
})"
|
|
:type="'button'"
|
|
:style="'secondary'"
|
|
>
|
|
<icon name="mdi:cards-heart-outline" size="16" />
|
|
{{ isProductInWishlist ? t('buttons.removeFromWishlist') : t('buttons.addToWishlist') }}
|
|
</ui-button>
|
|
<ui-button
|
|
:type="'button'"
|
|
:style="'secondary'"
|
|
@click="buyProduct(product.uuid)"
|
|
:isLoading="buyLoading"
|
|
>
|
|
{{ t('buttons.buyNow') }}
|
|
</ui-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<client-only>
|
|
<div class="characteristics" id="characteristics" v-if="attributes.length">
|
|
<h6 class="characteristics__title">{{ t('product.characteristics') }}</h6>
|
|
<div class="characteristics__list">
|
|
<div
|
|
class="characteristics__column"
|
|
v-for="group in attributes"
|
|
:key="group.uuid"
|
|
>
|
|
<h6 class="characteristics__column-title">{{ group.name }}</h6>
|
|
<p
|
|
class="characteristics__item"
|
|
v-for="item in group.items"
|
|
:key="item.uuid"
|
|
>
|
|
<span class="characteristics__item-label"><span>{{ item.name }}</span></span>
|
|
<span class="characteristics__item-value">{{ item.valuesStr }}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</client-only>
|
|
<div class="similar">
|
|
<h6 class="similar__title">{{ t('product.similar') }}</h6>
|
|
<div class="similar__inner">
|
|
<div class="similar__button prev" ref="prevButton">
|
|
<icon name="material-symbols:arrow-back-ios-new-rounded" size="30" />
|
|
</div>
|
|
<client-only>
|
|
<swiper
|
|
class="similar__swiper"
|
|
:modules="[Navigation]"
|
|
:spaceBetween="30"
|
|
:breakpoints="{
|
|
200: {
|
|
slidesPerView: 4
|
|
}
|
|
}"
|
|
:navigation="{ prevEl: '.prev', nextEl: '.next' }"
|
|
>
|
|
<swiper-slide
|
|
v-for="prod in products"
|
|
:key="prod.node.uuid"
|
|
>
|
|
<cards-product
|
|
:product="prod.node"
|
|
/>
|
|
</swiper-slide>
|
|
</swiper>
|
|
</client-only>
|
|
<div class="similar__button next" ref="nextButton">
|
|
<icon name="material-symbols:arrow-back-ios-new-rounded" size="30" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {useProductBySlug, useProducts, useProductBuy} from '@composables/products';
|
|
import {usePageTitle} from '@composables/utils';
|
|
import {useScrollTo} from '@composables/scrollTo';
|
|
import { Swiper, SwiperSlide } from 'swiper/vue';
|
|
import {Navigation} from 'swiper/modules';
|
|
import {useWishlistOverwrite} from '@composables/wishlist';
|
|
import {useOrderOverwrite} from '@composables/orders';
|
|
import {useDefaultSeo} from '@composables/seo';
|
|
|
|
const route = useRoute();
|
|
const {t, locale} = useI18n();
|
|
const wishlistStore = useWishlistStore();
|
|
const cartStore = useCartStore();
|
|
const userStore = useUserStore();
|
|
const { $appHelpers } = useNuxtApp();
|
|
|
|
const { setPageTitle } = usePageTitle();
|
|
const { scrollTo } = useScrollTo();
|
|
const { buyProduct, loading: buyLoading } = useProductBuy();
|
|
|
|
const slug = useRouteParams<string>('slug');
|
|
|
|
const { overwriteWishlist } = useWishlistOverwrite();
|
|
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
|
|
const { product, seoMeta } = await useProductBySlug(slug.value);
|
|
|
|
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
|
|
default: () => [],
|
|
path: '/',
|
|
});
|
|
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
|
|
default: () => [],
|
|
path: '/',
|
|
});
|
|
|
|
const isAuthenticated = computed(() => userStore.isAuthenticated);
|
|
|
|
const meta = useDefaultSeo(seoMeta.value || null);
|
|
|
|
if (meta) {
|
|
useSeoMeta({
|
|
title: meta.title || $appHelpers.APP_NAME,
|
|
description: meta.description || meta.title || $appHelpers.APP_NAME,
|
|
ogTitle: meta.og.title || undefined,
|
|
ogDescription: meta.og.description || meta.title || $appHelpers.APP_NAME,
|
|
ogType: meta.og.type || undefined,
|
|
ogUrl: meta.og.url || undefined,
|
|
ogImage: meta.og.image || undefined,
|
|
twitterCard: meta.twitter.card || undefined,
|
|
twitterTitle: meta.twitter.title || undefined,
|
|
twitterDescription: meta.twitter.description || undefined,
|
|
robots: meta.robots,
|
|
});
|
|
|
|
useHead({
|
|
link: [
|
|
meta.canonical ? { rel: 'canonical', href: meta.canonical } : {},
|
|
].filter(Boolean) as any,
|
|
meta: [{ property: 'og:locale', content: locale.value }],
|
|
script: meta.jsonLd.map((obj: any) => ({
|
|
type: 'application/ld+json',
|
|
innerHTML: JSON.stringify(obj),
|
|
})),
|
|
__dangerouslyDisableSanitizersByTagID: Object.fromEntries(
|
|
meta.jsonLd.map((_, i: number) => [`ldjson-${i}`, ['innerHTML']])
|
|
),
|
|
});
|
|
}
|
|
|
|
const { products } = useProducts({ categoriesSlugs: product.value?.category.slug });
|
|
|
|
const isProductInWishlist = computed(() => {
|
|
if (isAuthenticated.value) {
|
|
return !!wishlistStore.wishlist?.products?.edges.find(
|
|
(el) => el?.node?.uuid === product.value.uuid
|
|
);
|
|
} else {
|
|
return (cookieWishlist.value ?? []).includes(product.value.uuid);
|
|
}
|
|
});
|
|
|
|
const isProductInCart = computed(() => {
|
|
if (isAuthenticated.value) {
|
|
return !!cartStore.currentOrder?.orderProducts?.edges.find(
|
|
(prod) => prod.node.product.uuid === product.value?.uuid
|
|
);
|
|
} else {
|
|
return (cookieCart.value ?? []).some(
|
|
(item) => item.productUuid === product.value?.uuid
|
|
);
|
|
}
|
|
});
|
|
|
|
const productInCartQuantity = computed(() => {
|
|
if (isAuthenticated.value) {
|
|
const productEdge = cartStore.currentOrder?.orderProducts?.edges.find(
|
|
(prod) => prod.node.product.uuid === product.value.uuid
|
|
);
|
|
return productEdge?.node.quantity ?? 0;
|
|
} else {
|
|
const cartItem = (cookieCart.value ?? []).find(
|
|
(item) => item.productUuid === product.value.uuid
|
|
);
|
|
return cartItem?.quantity ?? 0;
|
|
}
|
|
});
|
|
|
|
const images = computed<string[]>(() =>
|
|
product.value
|
|
? product.value.images.edges.map(e => e.node.image)
|
|
: []
|
|
);
|
|
|
|
const attributes = computed(() => {
|
|
const edges = product.value?.attributeGroups.edges ?? [];
|
|
|
|
const mainIndex = edges.findIndex(e => e.node.name === 'Основные характеристики');
|
|
const ordered = mainIndex >= 0
|
|
? [edges[mainIndex], ...edges.slice(0, mainIndex), ...edges.slice(mainIndex + 1)]
|
|
: edges;
|
|
|
|
return ordered.map(groupEdge => {
|
|
const { node } = groupEdge
|
|
return {
|
|
uuid: node.uuid,
|
|
name: node.name,
|
|
items: node.attributes.map(attr => ({
|
|
uuid: attr.uuid,
|
|
name: attr.name,
|
|
valuesStr: attr.values.map(v => v.value).join(', ')
|
|
}))
|
|
};
|
|
});
|
|
});
|
|
|
|
const selectedImage = ref<string>(images.value[0] ?? '');
|
|
const selectImage = (image: string) => {
|
|
selectedImage.value = image;
|
|
};
|
|
|
|
const prevButton = ref<HTMLElement | null>(null);
|
|
const nextButton = ref<HTMLElement | null>(null);
|
|
|
|
setPageTitle(product.value?.name ?? 'Product');
|
|
|
|
watch(
|
|
() => route.params.slug,
|
|
async (newSlug) => {
|
|
if (typeof newSlug === 'string') {
|
|
const { product } = await useProductBySlug(newSlug);
|
|
setPageTitle(product.value?.name ?? 'Product');
|
|
}
|
|
}
|
|
);
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.product {
|
|
background-color: $main;
|
|
padding-block: 50px 100px;
|
|
|
|
&__title {
|
|
font-family: "Playfair Display", sans-serif;
|
|
color: $primary_dark;
|
|
font-size: 36px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
&__block {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 50px;
|
|
}
|
|
|
|
&__images {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 26px;
|
|
|
|
&-gallery {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
|
|
& div {
|
|
cursor: pointer;
|
|
width: 75px;
|
|
height: 75px;
|
|
padding: 5px;
|
|
border-radius: $less_border_radius;
|
|
background-color: $main;
|
|
border: 2px solid $main;
|
|
|
|
@include hover {
|
|
box-shadow: 0 0 10px 1px $primary;
|
|
|
|
&.active {
|
|
box-shadow: 0 0 0 0 transparent;
|
|
}
|
|
}
|
|
|
|
&.active {
|
|
border: 2px solid $primary;
|
|
}
|
|
|
|
& img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
border-radius: $less_border_radius;
|
|
}
|
|
}
|
|
}
|
|
|
|
&-main {
|
|
width: 550px;
|
|
border-radius: $less_border_radius;
|
|
object-fit: contain;
|
|
}
|
|
}
|
|
|
|
&__main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
|
|
&-brand {
|
|
color: $primary_dark;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
letter-spacing: -0.15px;
|
|
}
|
|
|
|
&-rate {
|
|
margin-block: 10px 14px;
|
|
}
|
|
|
|
&-price {
|
|
color: $primary_dark;
|
|
font-size: 30px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&-quantity {
|
|
color: $primary_dark;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
letter-spacing: -0.15px;
|
|
}
|
|
|
|
&-description {
|
|
color: $secondary;
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&-buttons {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
|
|
& .button {
|
|
width: 49%;
|
|
}
|
|
}
|
|
|
|
&-button {
|
|
margin-top: 25px;
|
|
}
|
|
}
|
|
|
|
&__info {
|
|
width: 400px;
|
|
flex-shrink: 0;
|
|
border-radius: $less_border_radius;
|
|
background-color: $primary;
|
|
padding: 5px;
|
|
|
|
&-inner {
|
|
border-radius: $less_border_radius;
|
|
border: 2px solid $main;
|
|
padding: 25px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 25px;
|
|
}
|
|
|
|
&-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
|
|
& p {
|
|
color: $main;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
& img {
|
|
width: 75px;
|
|
border-radius: $less_border_radius;
|
|
}
|
|
}
|
|
|
|
&-price {
|
|
width: fit-content;
|
|
background-color: $primary_dark;
|
|
border-radius: $less_border_radius;
|
|
border: 1px solid $main;
|
|
padding: 7px 20px;
|
|
|
|
font-size: 40px;
|
|
font-weight: 700;
|
|
color: $main;
|
|
}
|
|
|
|
&-bottom {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
&-button {
|
|
width: unset !important;
|
|
padding-inline: 25px !important;
|
|
background-color: $main !important;
|
|
color: $primary !important;
|
|
|
|
font-size: 20px !important;
|
|
|
|
@include hover {
|
|
background-color: $main_hover !important;
|
|
}
|
|
}
|
|
|
|
&-wishlist {
|
|
cursor: pointer;
|
|
width: 41px;
|
|
height: 41px;
|
|
flex-shrink: 0;
|
|
background-color: $main;
|
|
border-radius: $less_border_radius;
|
|
display: grid;
|
|
place-items: center;
|
|
|
|
font-size: 22px;
|
|
color: $primary;
|
|
|
|
@include hover {
|
|
background-color: $main_hover;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.tools {
|
|
width: 100%;
|
|
border-radius: $less_border_radius;
|
|
background-color: $primary;
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr 1fr;
|
|
height: 40px;
|
|
|
|
&__item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
&-count {
|
|
border-left: 1px solid $primary;
|
|
border-right: 1px solid $primary;
|
|
|
|
color: $main;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
&-button {
|
|
cursor: pointer;
|
|
background-color: $primary;
|
|
border-radius: $less_border_radius 0 0 $less_border_radius;
|
|
|
|
color: $main;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
|
|
@include hover {
|
|
background-color: $primary_hover;
|
|
color: $main;
|
|
}
|
|
|
|
&:last-child {
|
|
border-radius: 0 $less_border_radius $less_border_radius 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.characteristics {
|
|
margin-top: 100px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 25px;
|
|
padding: 45px 25px;
|
|
border-radius: $default_border_radius;
|
|
border: 1px solid $border;
|
|
|
|
&__title {
|
|
font-family: "Playfair Display", sans-serif;
|
|
color: $primary_dark;
|
|
font-size: 30px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&__list {
|
|
margin-top: 20px;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit,minmax(30%, 30%));
|
|
gap: 40px;
|
|
}
|
|
|
|
&__column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
|
|
&-title {
|
|
margin-bottom: 5px;
|
|
color: $primary_dark;
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
}
|
|
}
|
|
|
|
&__item {
|
|
display: grid;
|
|
grid-template-columns: repeat(2,1fr);
|
|
width: 100%;
|
|
gap: 5px;
|
|
font-size: 14px;
|
|
|
|
&-label {
|
|
position: relative;
|
|
font-weight: 500;
|
|
|
|
& span {
|
|
position: relative;
|
|
z-index: 1;
|
|
background-color: $main;
|
|
padding-bottom: 1px;
|
|
|
|
color: $text;
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&::after {
|
|
border-bottom: 1px dashed $primary;
|
|
content: "";
|
|
min-width: 100%;
|
|
position: absolute;
|
|
right: 0;
|
|
top: 1em;
|
|
}
|
|
}
|
|
|
|
&-value {
|
|
color: $primary_dark;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
letter-spacing: -0.5px;
|
|
|
|
& span {
|
|
&:first-child {
|
|
& span {
|
|
&:first-child {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.similar {
|
|
margin-top: 100px;
|
|
background-color: $main;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 25px;
|
|
padding: 45px 25px;
|
|
border-radius: $default_border_radius;
|
|
border: 1px solid $border;
|
|
|
|
&__title {
|
|
font-family: "Playfair Display", sans-serif;
|
|
color: $primary_dark;
|
|
font-size: 30px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
&__inner {
|
|
display: flex;
|
|
align-items: stretch;
|
|
}
|
|
|
|
&__swiper {
|
|
width: 100%;
|
|
padding-block: 25px;
|
|
padding-inline: 25px;
|
|
}
|
|
|
|
&__button {
|
|
cursor: pointer;
|
|
height: fit-content;
|
|
margin-block: auto;
|
|
flex-shrink: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
padding-inline: 10px;
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
background-color: transparent;
|
|
|
|
@include hover {
|
|
background-color: $link_secondary;
|
|
}
|
|
|
|
& span {
|
|
color: $primary;
|
|
}
|
|
|
|
&.next {
|
|
& span {
|
|
transform: rotate(180deg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style> |