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 { } else {
return (cookieCart.value ?? []).some( 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; return productEdge?.node.quantity ?? 0;
} else { } else {
const cartItem = (cookieCart.value ?? []).find( const cartItem = (cookieCart.value ?? []).find(
(item) => item.product === props.product.uuid (item) => item.productUuid === props.product.uuid
); );
return cartItem?.quantity ?? 0; return cartItem?.quantity ?? 0;
} }

View file

@ -27,7 +27,7 @@ const {t} = useI18n();
const filteredPosts = computed(() => { const filteredPosts = computed(() => {
const excludedSlugs = Object.values(docsSlugs); 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> </script>

View file

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

View file

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

View file

@ -38,13 +38,13 @@ export function useOrderSync() {
const productsToSync = []; const productsToSync = [];
for (const cartItem of cookieCartItems) { 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; const quantityDifference = cartItem.quantity - apiQuantity;
if (quantityDifference > 0) { if (quantityDifference > 0) {
for (let i = 0; i < quantityDifference; i++) { for (let i = 0; i < quantityDifference; i++) {
productsToSync.push({ 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', type: 'bulk',
bulkAction: 'add', bulkAction: 'add',
isBulkSync: true, isBulkSync: true,
products: productsToAdd.map((p) => ({ products: productsToAdd.map(uuid => ({ uuid }))
uuid: p,
})),
}); });
if (bulkResult?.data?.bulkWishlistAction?.wishlist) { if (bulkResult?.data?.bulkWishlistAction?.wishlist) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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