schon/storefront/app/pages/cart.vue
Alexandr SaVBaD Waltz 69c224722e feat(storefront): add promocode support in cart with UI and logic updates
Implemented promocode application feature in the cart, allowing users to select and apply discounts during checkout. Updated GraphQL mutation, cart logic, and UI to support this functionality.

- Enhanced `cart.vue` with a new promocode selection section, including dropdown and styling.
- Modified `buyOrder` mutation to accept `promocodeUuid` and `forceBalance` parameters.
- Updated translations (`en-gb.json` and `ru-ru.json`) to include promocode-related strings.

Improves user experience by enabling discount application directly in the cart. No breaking changes.
2026-03-03 15:23:11 +03:00

313 lines
No EOL
7.7 KiB
Vue

<template>
<div class="cart">
<div class="container">
<div class="cart__wrapper">
<div class="cart__main">
<h1 class="cart__main-title">{{ t('cart.title') }}</h1>
<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>
</client-only>
</div>
<div class="cart__checkout">
<h2>{{ t('cart.checkout') }}</h2>
<client-only>
<p>{{ t('cart.items', productsInCartQuantity, { count: productsInCartQuantity }) }}</p>
</client-only>
<h6>{{ t('cart.totalPrice') }}: <span>{{ totalPrice }}$</span></h6>
<div class="cart__checkout-promo">
<button class="current" ref="button" @click="setMenuVisible(!isMenuVisible)">
<span v-if="selectedPromo">{{ selectedPromo.node.code }}</span>
<span v-else>{{ t('cart.promocode.apply') }}</span>
</button>
<transition name="fromTop">
<div class="menu" ref="menu" v-if="isMenuVisible">
<div class="menu__list" v-if="promocodes.length">
<div
class="menu__list-item"
v-for="promo of promocodes"
:key="promo.node.uuid"
@click="selectPromo(promo)"
>
<span class="code">{{ promo.node.code }}</span>
<span class="value">
<span v-if="promo.node.discountType === 'amount'">{{ promo.node.discount + '$' }}</span>
<span v-else>{{ promo.node.discount + '%' }}</span>
</span>
</div>
</div>
<p class="empty" v-else>{{ t('cart.promocode.empty') }}</p>
</div>
</transition>
</div>
<ui-button
:type="'button'"
class="cart__checkout-button"
@click="buyOrder(selectedPromo.node.uuid)"
>
<icon name="material-symbols:shopping-cart-checkout" size="20" />
{{ t('buttons.checkout') }}
</ui-button>
</div>
</div>
</div>
</div>
</template>
<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();
const userStore = useUserStore();
const promocodeStore = usePromocodeStore();
const { $appHelpers } = useNuxtApp();
const isAuthenticated = computed(() => userStore.isAuthenticated);
const promocodes = computed(() => promocodeStore.promocodes);
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const { buyOrder } = useOrderBuy();
const { products, getExactProducts } = useExactProducts();
const menu = ref(null);
const button = ref(null);
const isMenuVisible = ref(false);
const setMenuVisible = (state) => {
isMenuVisible.value = state;
};
onClickOutside(menu, () => setMenuVisible(false), { ignore: [button] });
const selectedPromo = ref(null);
const selectPromo = (promo) => {
setMenuVisible(false);
selectedPromo.value = promo;
};
const cartUuids = computed<string[]>(() => {
return cookieCart.value.map(item => item.productUuid);
});
const productsInCart = computed(() => {
if (isAuthenticated.value) {
return cartStore.currentOrder
? cartStore.currentOrder.orderProducts.edges
: [];
} else {
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');
}
});
const totalPrice = computed(() => {
if (isAuthenticated.value) {
return cartStore.currentOrder ? cartStore.currentOrder.totalPrice : 0;
} else {
return productsInCart.value.reduce((acc, item) => {
return acc + (item.node.product.price * item.node.quantity);
}, 0);
}
});
const productsInCartQuantity = computed(() => {
if (isAuthenticated.value) {
let count = 0;
cartStore.currentOrder?.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
return count;
} else {
return cookieCart.value.reduce((acc, item) => acc + item.quantity, 0);
}
});
const { setPageTitle } = usePageTitle();
setPageTitle(t('breadcrumbs.cart'));
</script>
<style lang="scss" scoped>
.cart {
padding-block: 50px 100px;
background-color: $main;
&__wrapper {
display: flex;
align-items: flex-start;
gap: 50px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
&-title {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 36px;
font-weight: 600;
letter-spacing: -0.5px;
}
}
&__list {
&-inner {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 25px;
}
}
&__checkout {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 15px;
border: 1px solid $border;
border-radius: $default_border_radius;
padding: 20px;
& h2 {
margin-bottom: 20px;
color: $primary;
font-size: 24px;
font-weight: 700;
}
& p {
color: $text;
font-size: 18px;
font-weight: 400;
letter-spacing: -0.5px;
}
& h6 {
color: $primary;
font-size: 18px;
font-weight: 500;
& span {
font-weight: 700;
}
}
&-promo {
width: 100%;
position: relative;
& .current {
width: 100%;
border-radius: $default_border_radius;
padding: 8px 12px;
font-size: 16px;
border: 1px solid $border;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
color: $text;
}
& .menu {
position: absolute;
z-index: 2;
left: 0;
width: 100%;
top: 115%;
background-color: $main;
padding: 5px 0;
border-radius: $default_border_radius;
border: 1px solid $border;
max-height: 300px;
overflow-y: auto;
&__list {
width: 100%;
display: flex;
flex-direction: column;
align-items: start;
&-item {
cursor: pointer;
width: 100%;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
color: $text;
@include hover {
background-color: $link_secondary;
}
& .code {
font-weight: 500;
}
& .value {
font-weight: 700;
color: $primary;
}
}
}
& .empty {
padding: 4px 12px;
font-size: 14px;
font-weight: 500;
color: $text;
}
}
}
&-button {
margin-top: 20px;
padding-inline: 25px;
}
}
&__empty {
font-weight: 500;
font-size: 18px;
color: $primary;
}
}
</style>