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:
parent
574dc43d06
commit
69c224722e
6 changed files with 215 additions and 56 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Мои Избранные",
|
||||
|
|
|
|||
Loading…
Reference in a new issue