schon/storefront/app/components/cards/product.vue
Alexandr SaVBaD Waltz 9bf600845a feat(storefront): enhance cart and wishlist handling with cookie-based products support
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.
2026-03-02 23:06:13 +03:00

518 lines
No EOL
12 KiB
Vue

<template>
<div
class="card"
:class="{ 'card__list': isList }"
>
<div
class="card__wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name,
})"
>
<icon style="color: #dc2626;" name="mdi:cards-heart" size="16" v-if="isProductInWishlist" />
<icon name="mdi:cards-heart-outline" size="16" v-else />
</div>
<div class="card__wrapper">
<nuxt-link-locale
:to="`/product/${product.slug}`"
class="card__link"
>
<div class="card__block">
<client-only>
<swiper
v-if="images.length"
@swiper="onSwiper"
:modules="[EffectFade, Pagination]"
effect="fade"
:slides-per-view="1"
:pagination="paginationOptions"
class="card__swiper"
>
<swiper-slide
v-for="(img, i) in images"
:key="i"
class="card__swiper-slide"
>
<nuxt-img
:src="img"
:alt="product.name"
loading="lazy"
class="card__swiper-image"
format="webp"
densities="x1"
/>
</swiper-slide>
</swiper>
<div class="card__image-placeholder" />
<div
v-for="(image, idx) in images"
:key="idx"
class="card__block-hover"
:style="{ left: `${(100/ images.length) * idx}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(idx)"
@mouseleave="goTo(0)"
/>
</client-only>
</div>
</nuxt-link-locale>
</div>
<div class="card__content">
<div class="card__content-inner">
<div class="card__brand">{{ product.brand.name }}</div>
<p class="card__name">{{ product.name }}</p>
<el-rate
class="card__rating"
v-model="rating"
size="large"
disabled
/>
<div class="card__price">{{ product.price }} $</div>
</div>
<div class="card__bottom">
<div class="card__bottom-inner">
<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="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
<ui-button
:type="'button'"
class="card__bottom-button"
:style="'secondary'"
@click="buyProduct(product.uuid)"
:isLoading="buyLoading"
>
{{ t('buttons.buyNow') }}
</ui-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IProduct} from '@types';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import {useWishlistOverwrite} from '@composables/wishlist';
import {useOrderOverwrite} from '@composables/orders';
import {useProductBuy} from "@composables/products";
const props = defineProps<{
product: IProduct;
isList?: boolean;
}>();
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const userStore = useUserStore();
const { $appHelpers } = useNuxtApp();
const { overwriteWishlist } = useWishlistOverwrite();
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
const { buyProduct, loading: buyLoading } = useProductBuy();
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const isAuthenticated = computed(() => userStore.isAuthenticated);
const isProductInWishlist = computed(() => {
if (isAuthenticated.value) {
return !!wishlistStore.wishlist?.products?.edges.find(
(el) => el?.node?.uuid === props.product.uuid
);
} else {
return (cookieWishlist.value ?? []).includes(props.product.uuid);
}
});
const isProductInCart = computed(() => {
if (isAuthenticated.value) {
return !!cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product?.uuid
);
} else {
return (cookieCart.value ?? []).some(
(item) => item.productUuid === props.product?.uuid
);
}
});
const productInCartQuantity = computed(() => {
if (isAuthenticated.value) {
const productEdge = cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product.uuid
);
return productEdge?.node.quantity ?? 0;
} else {
const cartItem = (cookieCart.value ?? []).find(
(item) => item.productUuid === props.product.uuid
);
return cartItem?.quantity ?? 0;
}
});
const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
});
const images = computed(() =>
props.product.images.edges.map(e => e.node.image)
);
const paginationOptions = computed(() =>
images.value.length > 1
? {
clickable: true,
bulletClass: 'swiper-pagination-line',
bulletActiveClass: 'swiper-pagination-line--active'
}
: false
);
const swiperRef = ref<any>(null);
function onSwiper(swiper: any) {
swiperRef.value = swiper;
}
function goTo(index: number) {
swiperRef.value?.slideTo(index);
}
</script>
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 1px solid $border;
width: 100%;
background-color: $main;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: stretch;
gap: 50px;
padding: 15px;
& .card__link {
width: fit-content;
padding: 0;
}
& .card__block {
width: 150px;
height: 150px;
}
& .card__content {
width: 100%;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
padding: 0;
&-inner {
align-self: flex-start;
}
}
& .tools {
width: 136px;
}
& .card__bottom {
margin-top: 0;
width: fit-content;
flex-shrink: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
&-inner {
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
&-button {
width: fit-content;
padding-inline: 25px;
}
}
& .card__wrapper {
flex-direction: row;
}
}
@include hover {
border-color: $border_hover;
box-shadow: 0 0 10px 1px $border;
}
&__wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__link {
display: block;
width: 100%;
}
&__block {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
&-hover {
position: absolute;
top: 0;
left: 0;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
background: transparent;
}
}
&__swiper {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
padding-bottom: 10px;
&-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__image {
&-placeholder {
width: 100%;
height: 200px;
background-color: $primary;
}
}
&__content {
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 15px;
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__brand {
font-size: 12px;
font-weight: 400;
letter-spacing: -0.2px;
color: $text;
}
&__price {
font-weight: 600;
font-size: 16px;
letter-spacing: -0.5px;
color: $primary_dark;
}
&__rating {
margin-block: 4px 10px;
}
&__name {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
color: $primary_dark;
font-weight: 500;
font-size: 14px;
letter-spacing: -0.5px;
}
&__quantity {
width: fit-content;
background-color: rgba($secondary, 0.5);
border-radius: $less_border_radius;
padding: 5px 10px;
font-size: 14px;
}
&__wishlist {
cursor: pointer;
width: fit-content;
background-color: $main;
box-shadow: 0 2px 4px 0 $primary_shadow;
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
border-radius: 50%;
padding: 12px;
@include hover {
box-shadow: 0 2px 4px 0 $primary_shadow_hover;
}
& span {
color: $secondary;
display: block;
}
}
&__bottom {
&-inner {
display: flex;
flex-direction: column;
gap: 10px;
}
&-button {
padding-block: 10px !important;
border-radius: $less_border_radius;
}
&-wishlist {
cursor: pointer;
width: 34px;
height: 34px;
flex-shrink: 0;
background-color: $primary;
border-radius: $less_border_radius;
display: grid;
place-items: center;
font-size: 22px;
color: $main;
@include hover {
background-color: $primary_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_hover;
border-right: 1px solid $primary_hover;
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;
}
}
}
}
:deep(.swiper-pagination) {
bottom: 0;
display: flex;
justify-content: center;
gap: 6px;
}
:deep(.swiper-pagination-line) {
display: inline-block;
width: 24px;
height: 2px;
background-color: rgba($primary_dark, 0.3);
border-radius: 0;
opacity: 1;
}
:deep(.swiper-pagination-line--active) {
background-color: $primary_dark;
}
</style>