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.
This commit is contained in:
Alexandr SaVBaD Waltz 2026-03-02 23:06:13 +03:00
parent 398e11d748
commit 9bf600845a
15 changed files with 202 additions and 63 deletions

View file

@ -173,7 +173,7 @@ const isProductInCart = computed(() => {
);
} else {
return (cookieCart.value ?? []).some(
(item) => item.product === props.product?.uuid
(item) => item.productUuid === props.product?.uuid
);
}
});
@ -186,7 +186,7 @@ const productInCartQuantity = computed(() => {
return productEdge?.node.quantity ?? 0;
} else {
const cartItem = (cookieCart.value ?? []).find(
(item) => item.product === props.product.uuid
(item) => item.productUuid === props.product.uuid
);
return cartItem?.quantity ?? 0;
}

View file

@ -27,7 +27,7 @@ const {t} = useI18n();
const filteredPosts = computed(() => {
const excludedSlugs = Object.values(docsSlugs);
return props.posts.filter(post => !excludedSlugs.includes(post.node.slug));
return props.posts?.filter(post => !excludedSlugs.includes(post.node.slug));
});
</script>

View file

@ -25,7 +25,7 @@ export function useBrands(args: IBrandArgs = {}) {
brandSearch: args.brandSearch,
});
const pending = ref(false);
const pending = ref<boolean>(false);
const brands = ref<IBrand[]>([]);
const pageInfo = ref<{
hasNextPage: boolean;

View file

@ -155,7 +155,7 @@ export function useOrderOverwrite() {
if (bulkResult?.data?.bulkOrderAction?.order) {
cartStore.setCurrentOrders(bulkResult.data.bulkOrderAction.order);
$notify({
message: t('popup.success.bulkRemoveOrder'),
message: t('popup.success.bulkAddOrder'),
type: 'success',
});
}
@ -172,7 +172,7 @@ export function useOrderOverwrite() {
const current = Array.isArray(cookieCart.value) ? [...cookieCart.value] : [];
const index = current.findIndex(
(item) => item.product === args.productUuid
(item) => item.productUuid === args.productUuid
);
if (index !== -1) {
@ -182,7 +182,7 @@ export function useOrderOverwrite() {
};
} else {
current.push({
product: args.productUuid,
productUuid: args.productUuid,
quantity: 1,
});
}
@ -205,7 +205,7 @@ export function useOrderOverwrite() {
: [];
const index = current.findIndex(
(item) => item.product === args.productUuid
(item) => item.productUuid === args.productUuid
);
if (index !== -1) {
@ -233,7 +233,7 @@ export function useOrderOverwrite() {
case 'removeKind': {
cookieCart.value = (cookieCart.value ?? []).filter(
(item) => item.product.uuid !== args.productUuid
(item) => item.productUuid !== args.productUuid
);
$notify({
@ -258,18 +258,59 @@ export function useOrderOverwrite() {
}
case 'bulk': {
const bulkResult = await bulkMutate({
orderUuid: orderUuid.value,
action: args.bulkAction,
products: args.products,
});
const current = Array.isArray(cookieCart.value)
? [...cookieCart.value]
: [];
if (bulkResult?.data?.bulkOrderAction?.order) {
cartStore.setCurrentOrders(bulkResult.data.bulkOrderAction.order);
$notify({
message: t('popup.success.bulkRemoveOrder'),
type: 'success',
});
if (!args.products?.length) return;
switch (args.bulkAction) {
case 'add': {
args.products.forEach(({ uuid }) => {
const index = current.findIndex(
item => item.productUuid === uuid
);
if (index !== -1) {
current[index] = {
...current[index],
quantity: current[index].quantity + 1,
};
} else {
current.push({
productUuid: uuid,
quantity: 1,
});
}
});
cookieCart.value = current;
$notify({
message: t('popup.success.bulkAddOrder'),
type: 'success',
});
break;
}
case 'remove': {
const uuidsToRemove = args.products.map(p => p.uuid);
cookieCart.value = current.filter(
item => !uuidsToRemove.includes(item.productUuid)
);
$notify({
message: t('popup.success.bulkRemoveOrder'),
type: 'success',
});
break;
}
default:
console.error('Invalid bulkAction');
}
break;

View file

@ -38,13 +38,13 @@ export function useOrderSync() {
const productsToSync = [];
for (const cartItem of cookieCartItems) {
const apiQuantity = apiProductMap.get(cartItem.product.uuid) || 0;
const apiQuantity = apiProductMap.get(cartItem.productUuid) || 0;
const quantityDifference = cartItem.quantity - apiQuantity;
if (quantityDifference > 0) {
for (let i = 0; i < quantityDifference; i++) {
productsToSync.push({
uuid: cartItem.product.uuid,
uuid: cartItem.productUuid,
});
}
}

View file

@ -0,0 +1,37 @@
import { GET_EXACT_PRODUCTS } from '@graphql/queries/standalone/products';
import type {IProduct, IProductExactResponse} from '@types';
export function useExactProducts() {
const products = ref<IProduct[]>([]);
const error = ref<string | null>(null);
const pending = ref<boolean>(false);
const getExactProducts = async (identificators: string[], identificatorType: string) => {
pending.value = true;
const { data, error: mistake } = useAsyncQuery<IProductExactResponse>(GET_EXACT_PRODUCTS, {
identificators: identificators,
identificatorType: identificatorType
});
if (data.value?.retrieveExactProducts) {
products.value = data.value?.retrieveExactProducts;
}
if (mistake.value) {
error.value = mistake.value;
}
pending.value = false;
}
watch(error, (e) => {
if (e) console.error('useExactProducts error:', e);
});
return {
products,
pending,
getExactProducts,
};
}

View file

@ -40,9 +40,7 @@ export function useWishlistSync() {
type: 'bulk',
bulkAction: 'add',
isBulkSync: true,
products: productsToAdd.map((p) => ({
uuid: p,
})),
products: productsToAdd.map(uuid => ({ uuid }))
});
if (bulkResult?.data?.bulkWishlistAction?.wishlist) {

View file

@ -53,7 +53,7 @@ export const BULK_WISHLIST = gql`
mutation bulkWishlistAction(
$wishlistUuid: UUID!,
$action: String!,
$products: [BulkActionOrderProductInput]!
$products: [BulkProductInput]!
) {
bulkWishlistAction(
wishlistUuid: $wishlistUuid

View file

@ -83,3 +83,18 @@ export const GET_PRODUCT_TAGS = gql`
}
${PRODUCT_FRAGMENT}
`;
export const GET_EXACT_PRODUCTS = gql`
query getExactProducts(
$identificators: [String]!,
$identificatorType: String!
) {
retrieveExactProducts(
identificators: $identificators,
identificatorType: $identificatorType
) {
...Product
}
}
${PRODUCT_FRAGMENT}
`;

View file

@ -5,7 +5,10 @@
<div class="cart__top">
<h1 class="cart__top-title">{{ t('cart.title') }}</h1>
<div class="cart__top-inner">
<p>({{ t('cart.items', productsInCartQuantity, { count: productsInCartQuantity }) }})</p>
<client-only>
<p>({{ t('cart.items', productsInCartQuantity, { count: productsInCartQuantity }) }})</p>
</client-only>
<p>{{ totalPrice }}$</p>
<ui-button
:type="'button'"
class="cart__top-button"
@ -16,16 +19,18 @@
</ui-button>
</div>
</div>
<div class="cart__list">
<div class="cart__list-inner" v-if="productsInCart.length">
<cards-product
v-for="product in productsInCart"
:key="product.node.uuid"
:product="product.node.product"
/>
<client-only>
<div class="cart__list">
<div class="cart__list-inner" v-if="productsInCart.length">
<cards-product
v-for="product in productsInCart"
:key="product.node.uuid"
:product="product.node.product"
/>
</div>
<p class="cart__empty" v-else>{{ t('cart.empty') }}</p>
</div>
<p class="cart__empty" v-else>{{ t('cart.empty') }}</p>
</div>
</client-only>
</div>
</div>
</div>
@ -34,6 +39,7 @@
<script setup lang="ts">
import {usePageTitle} from "@composables/utils";
import {useOrderBuy} from "~/composables/orders";
import {useExactProducts} from "@composables/products/useExactProducts";
const {t} = useI18n();
const cartStore = useCartStore();
@ -48,17 +54,36 @@ const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
});
const { buyOrder } = useOrderBuy();
const { products, getExactProducts } = useExactProducts();
const cartUuids = computed<string[]>(() => {
return cookieCart.value.map(item => item.productUuid);
});
const productsInCart = computed(() => {
if (isAuthenticated.value) {
return cartStore.currentOrder ? cartStore.currentOrder.orderProducts.edges : [];
return cartStore.currentOrder
? cartStore.currentOrder.orderProducts.edges
: [];
} else {
return cookieCart.value.map(product => ({
node: {
product: item.product,
quantity: item.quantity
}
}));
return products.value.map(product => {
const cartItem = cookieCart.value.find(
item => item.productUuid === product.uuid
);
return {
node: {
product: product,
quantity: cartItem?.quantity ?? 1
}
};
});
}
});
watchEffect(async () => {
if (!isAuthenticated.value && cartUuids.value.length) {
await getExactProducts(cartUuids.value, 'uuid');
}
});
@ -66,8 +91,8 @@ const totalPrice = computed(() => {
if (isAuthenticated.value) {
return cartStore.currentOrder ? cartStore.currentOrder.totalPrice : 0;
} else {
return cookieCart.value.reduce((acc, item) => {
return acc + (item.product.price * item.quantity);
return productsInCart.value.reduce((acc, item) => {
return acc + (item.node.product.price * item.node.quantity);
}, 0);
}
});

View file

@ -248,7 +248,7 @@ const isProductInCart = computed(() => {
);
} else {
return (cookieCart.value ?? []).some(
(item) => item.product.uuid === product.value?.uuid
(item) => item.productUuid === product.value?.uuid
);
}
});
@ -261,7 +261,7 @@ const productInCartQuantity = computed(() => {
return productEdge?.node.quantity ?? 0;
} else {
const cartItem = (cookieCart.value ?? []).find(
(item) => item.product === product.value.uuid
(item) => item.productUuid === product.value.uuid
);
return cartItem?.quantity ?? 0;
}

View file

@ -5,7 +5,9 @@
<div class="wishlist__top">
<h1 class="wishlist__top-title">{{ t('wishlist.title') }}</h1>
<div class="wishlist__top-inner">
<p>({{ t('wishlist.items', productsInWishlist.length, { count: productsInWishlist.length }) }})</p>
<client-only>
<p>({{ t('wishlist.items', productsInWishlist.length, { count: productsInWishlist.length }) }})</p>
</client-only>
<ui-button
:type="'button'"
class="wishlist__top-button"
@ -17,16 +19,18 @@
</ui-button>
</div>
</div>
<div class="wishlist__list">
<div class="wishlist__list-inner" v-if="productsInWishlist.length">
<cards-product
v-for="product in productsInWishlist"
:key="product.node.uuid"
:product="product.node"
/>
<client-only>
<div class="wishlist__list">
<div class="wishlist__list-inner" v-if="productsInWishlist.length">
<cards-product
v-for="product in productsInWishlist"
:key="product.node.uuid"
:product="product.node"
/>
</div>
<p class="wishlist__empty" v-else>{{ t('wishlist.empty') }}</p>
</div>
<p class="wishlist__empty" v-else>{{ t('wishlist.empty') }}</p>
</div>
</client-only>
</div>
</div>
</div>
@ -36,6 +40,7 @@
import {usePageTitle} from '@composables/utils';
import {useWishlistOverwrite} from '@composables/wishlist';
import {useOrderOverwrite} from "@composables/orders";
import {useExactProducts} from "@composables/products/useExactProducts";
const {t} = useI18n();
const wishlistStore = useWishlistStore();
@ -51,24 +56,36 @@ const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
const { overwriteWishlist } = useWishlistOverwrite();
const { overwriteOrder, bulkLoading } = useOrderOverwrite();
const { products, getExactProducts } = useExactProducts();
const productsInWishlist = computed(() => {
if (isAuthenticated.value) {
return wishlistStore.wishlist ? wishlistStore.wishlist.products.edges : [];
return wishlistStore.wishlist
? wishlistStore.wishlist.products.edges
: [];
} else {
return cookieWishlist.value.map(product => ({
node: product
return products.value.map(p => ({
node: p
}));
}
});
watchEffect(async () => {
if (!isAuthenticated.value && cookieWishlist.value?.length) {
await getExactProducts(cookieWishlist.value, 'uuid');
}
});
const totalPrice = computed(() => {
return productsInWishlist.value.reduce((acc, p) => acc + p.node.price, 0);
});
const selectedProducts = ref<{ uuid: string }[]>([]);
const productsUuid = computed<boolean>(() => {
return productsInWishlist.value.map(p => ({ uuid: p.node.uuid }));
const productsUuid = computed<{ uuid: string }[]>(() => {
return productsInWishlist.value.map(p => ({
uuid: p.node.uuid
}));
});
function toggleUuid(uuid: string, checked: boolean) {

View file

@ -79,6 +79,7 @@
"removeAllFromWishlist": "You have successfully emptied the wishlist!",
"bulkRemoveWishlist": "Selected items have been successfully removed from the wishlist!",
"bulkRemoveOrder": "Selected items have been successfully removed from the cart!",
"bulkAddOrder": "Selected items have been successfully added to the cart!",
"avatarUpload": "You have successfully uploaded an avatar!",
"userUpdate": "Profile successfully updated!",
"emailUpdate": "Check your inbox for a confirmation link to complete your email update.",

View file

@ -79,6 +79,7 @@
"removeAllFromWishlist": "Список избранного успешно очищен!",
"bulkRemoveWishlist": "Выбранные товары удалены из избранного!",
"bulkRemoveOrder": "Выбранные товары удалены из корзины!",
"bulkAddOrder": "Выбранные товары добавленны в корзину!",
"avatarUpload": "Аватар успешно загружен!",
"userUpdate": "Профиль успешно обновлен!",
"emailUpdate": "Проверьте вашу почту для перехода по ссылке подтверждения и завершения обновления email.",
@ -338,7 +339,7 @@
"wishlist": {
"title": "Мои Избранные",
"items": "нет товаров | {count} товар | {count} товара | {count} товаров",
"empty": "Список выших избранных пуст"
"empty": "Список ваших избранных пуст"
},
"shop": {
"title": "Магазин",

View file

@ -32,3 +32,7 @@ export interface IBuyProductResponse {
};
};
}
export interface IProductExactResponse {
retrieveExactProducts: IProduct[];
}