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.
This commit is contained in:
Alexandr SaVBaD Waltz 2026-03-03 15:23:11 +03:00
parent 574dc43d06
commit 69c224722e
6 changed files with 215 additions and 56 deletions

View file

@ -103,7 +103,7 @@ const encodedCompanyAddress = computed(() => {
gap: 14px;
& h6 {
color: $link_primary;
color: #d2d0d0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.5px;

View file

@ -10,10 +10,12 @@ export function useOrderBuy() {
const { mutate, loading, error } = useMutation<IBuyOrderResponse>(BUY_CART);
async function buyOrder() {
async function buyOrder(promocodeUuid?: string) {
const result = await mutate({
orderUuid: orderUuid.value,
forcePayment: true,
forceBalance: false,
promocodeUuid: promocodeUuid
});
if (result?.data?.buyOrder?.transaction?.process?.url) {

View file

@ -88,11 +88,15 @@ export const BULK_CART = gql`
export const BUY_CART = gql`
mutation buyOrder(
$orderUuid: String!,
$forcePayment: Boolean!
$forcePayment: Boolean,
$forceBalance: Boolean,
$promocodeUuid: String,
) {
buyOrder(
orderUuid: $orderUuid
forcePayment: $forcePayment
forceBalance: $forceBalance
promocodeUuid: $promocodeUuid
) {
transaction {
amount

View file

@ -2,35 +2,61 @@
<div class="cart">
<div class="container">
<div class="cart__wrapper">
<div class="cart__top">
<h1 class="cart__top-title">{{ t('cart.title') }}</h1>
<div class="cart__top-inner">
<client-only>
<p>({{ t('cart.items', productsInCartQuantity, { count: productsInCartQuantity }) }})</p>
</client-only>
<p>{{ totalPrice }}$</p>
<ui-button
:type="'button'"
class="cart__top-button"
@click="buyOrder"
>
<icon name="material-symbols:add" size="20" />
{{ t('buttons.checkout') }}
</ui-button>
</div>
</div>
<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 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>
<p class="cart__empty" v-else>{{ t('cart.empty') }}</p>
</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>
</client-only>
<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>
@ -44,10 +70,13 @@ 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: '/',
@ -56,6 +85,20 @@ const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
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);
});
@ -121,20 +164,15 @@ setPageTitle(t('breadcrumbs.cart'));
&__wrapper {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 50px;
}
&__top {
&__main {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
&-inner {
display: flex;
align-items: center;
gap: 35px;
}
flex-direction: column;
gap: 50px;
&-title {
color: $primary_dark;
@ -143,18 +181,6 @@ setPageTitle(t('breadcrumbs.cart'));
font-weight: 600;
letter-spacing: -0.5px;
}
& p {
color: $text;
font-size: 18px;
font-weight: 400;
letter-spacing: -0.5px;
}
&-button {
width: fit-content;
padding-inline: 25px;
}
}
&__list {
@ -165,6 +191,119 @@ setPageTitle(t('breadcrumbs.cart'));
}
}
&__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;

View file

@ -48,7 +48,8 @@
"newPassword": "New password",
"confirmPassword": "Confirm password",
"confirmNewPassword": "Confirm new password",
"brandsSearch": "Search brands by name..."
"brandsSearch": "Search brands by name...",
"promocode": "Enter promocode"
},
"checkboxes": {
"remember": "Remember me",
@ -332,9 +333,15 @@
"text": "Discover the latest trends, style inspiration, and fashion insights from our editorial team."
},
"cart": {
"totalPrice": "Total price",
"title": "My Cart",
"items": "no items | {count} item | {count} items",
"empty": "Your cart is empty."
"empty": "Your cart is empty.",
"checkout": "Checkout",
"promocode": {
"apply": "Apply a promocode",
"empty": "You don't have any promocodes"
}
},
"wishlist": {
"title": "My Wishlist",

View file

@ -48,7 +48,8 @@
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите пароль",
"confirmNewPassword": "Подтвердите новый пароль",
"brandsSearch": "Поиск брендов по названию..."
"brandsSearch": "Поиск брендов по названию...",
"promocode": "Введите промокод"
},
"checkboxes": {
"remember": "Запомнить меня",
@ -332,9 +333,15 @@
"text": "Узнавайте о последних трендах, источниках вдохновения для стиля и модных инсайтах от нашей редакции."
},
"cart": {
"totalPrice": "Итог",
"title": "Моя корзина",
"items": "нет товаров | {count} товар | {count} товара | {count} товаров",
"empty": "Ваша корзина пуста"
"empty": "Ваша корзина пуста",
"checkout": "Оформление",
"promocode": {
"apply": "Примените промокод",
"empty": "У вас нет промокодов"
}
},
"wishlist": {
"title": "Мои Избранные",