schon/storefront/app/pages/cart.vue
Alexandr SaVBaD Waltz 52e559dae0 feat(cart): add address and promocode selection to checkout
Implemented billing and shipping address selection in the cart UI, along with improved promocode dropdown. Updated GraphQL mutation to accept new address fields for more comprehensive order handling.

- Replaced the old promocode selection implementation with `el-select` components.
- Introduced billing and shipping address fields with selectable options.
- Enhanced form validation to ensure all required fields are populated before checkout.
- Updated translations (`en-gb.json`, `ru-ru.json`) with new field labels.
- Adjusted SCSS for consistent styling of dropdowns.

Improves user experience by streamlining and enhancing the checkout process. No breaking changes introduced.
2026-03-10 11:44:09 +03:00

384 lines
No EOL
10 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>-->
<el-select
v-model="billingAddress"
:placeholder="t('fields.billingAddress')"
size="small"
>
<el-option
v-for="item in addresses"
:key="item.node.uuid"
:label="item.node.addressLine.split(' ').find(el => el.includes('name'))?.split('=')[1]?.split('_').join(' ')"
:value="item.node.uuid"
/>
</el-select>
<el-select
v-model="shippingAddress"
:placeholder="t('fields.shippingAddress')"
size="small"
>
<el-option
v-for="item in addresses"
:key="item.node.uuid"
:label="item.node.addressLine.split(' ').find(el => el.includes('name'))?.split('=')[1]?.split('_').join(' ')"
:value="item.node.uuid"
/>
</el-select>
<el-select
v-model="promo"
:placeholder="t('fields.promocode')"
size="small"
>
<el-option
v-for="item in promocodes"
:key="item.node.uuid"
:label="item.node.code"
:value="item.node.uuid"
>
<span>
{{ item.node.code }}
<b>
{{
item.node.discountType === 'amount'
? item.node.discount + '$'
: item.node.discount + '%'
}}
</b>
</span>
</el-option>
</el-select>
<ui-button
:type="'button'"
class="cart__checkout-button"
:isDisabled="!isFormValid"
@click="handleBuy"
>
<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";
import {useValidators} from "@composables/rules";
const {t} = useI18n();
const cartStore = useCartStore();
const userStore = useUserStore();
const promocodeStore = usePromocodeStore();
const addressesStore = useAddressesStore();
const { $appHelpers } = useNuxtApp();
const isAuthenticated = computed(() => userStore.isAuthenticated);
const addresses = computed(() => addressesStore.addresses);
const promocodes = computed(() => promocodeStore.promocodes);
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const { required, isEmail } = useValidators();
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 promo = ref<string>('');
const billingAddress = ref<string>('');
const shippingAddress = ref<string>('');
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();
const isFormValid = computed(() => {
return (
required(billingAddress.value) === true &&
required(shippingAddress.value) === true
);
});
const handleBuy = async () => {
await buyOrder({
promocodeUuid: promo.value,
billingAddress: billingAddress.value,
shippingAddress: shippingAddress.value
})
}
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>