Features: 1) Add useWishlistOverwrite composable for wishlist mutations, including adding, removing, and bulk actions; 2) Introduce new localized UI texts for cart and wishlist operations; 3) Enhance filtering logic with parseAttributesString and route query synchronization;

Fixes: 1) Replace `ElNotification` calls with `useNotification` utility across all authentication and user-related composables; 2) Add missing semicolons in multiple index exports and styled components; 3) Resolve issues with reactivity in `useStore` composable by renaming and restructuring product variables;

Extra: 1) Refactor localized strings and translations for better readability and maintenance; 2) Tweak styles including scoped styles, z-index adjustments, and SCSS mixins; 3) Remove unused components and imports to streamline storefront layout.
This commit is contained in:
Alexandr SaVBaD Waltz 2025-07-06 19:49:26 +03:00
parent 53df1f5b88
commit 761fecf67f
75 changed files with 2336 additions and 375 deletions

View file

@ -23,6 +23,7 @@ import {useRefresh} from "~/composables/auth";
import {useLanguages} from "~/composables/languages";
import {useCompanyInfo} from "~/composables/company";
import {useCategories} from "~/composables/categories";
import {useNotification} from "~/composables/notification";
const { locale } = useI18n();
const route = useRoute();
@ -32,7 +33,13 @@ const switchLocalePath = useSwitchLocalePath();
const showBreadcrumbs = computed(() => {
const name = typeof route.name === 'string' ? route.name : '';
return !['index', 'brand', 'search'].some(prefix => name.startsWith(prefix));
return ![
'index',
'brand',
'search',
'profile',
'activate-user'
].some(prefix => name.startsWith(prefix));
});
const activeState = computed(() => appStore.activeState);
@ -66,7 +73,7 @@ watch(
{ immediate: true }
)
let stopWatcher: () => void;
let stopWatcher: VoidFunction = () => {}
onMounted( async () => {
refreshInterval = setInterval(async () => {

View file

@ -2,4 +2,8 @@
@use "modules/transitions";
@use "global/mixins";
@use "global/variables";
// UI
@use "ui/collapse";
@use "ui/notification";
@use "ui/rating";

View file

@ -58,6 +58,10 @@ button:focus-visible {
--el-skeleton-to-color: #c3c3c7 !important;
}
.el-badge__content {
border: none !important;
}
@media (max-width: 1680px) {
.container {
max-width: 1200px;

View file

@ -36,6 +36,6 @@
flex-direction: column;
gap: 5px;
}
.el-icon {
.el-collapse .el-icon {
display: none !important;
}

View file

@ -0,0 +1,39 @@
@use "../global/variables" as *;
.el-notification {
border: 2px solid $accent !important;
transition: all 0.3s ease !important;
&__progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background-color: $accentDark;
animation: progress-animation linear forwards;
}
&__button {
margin-top: 10px;
padding: 6px 12px;
background-color: $accent;
border-radius: $default_border_radius;
color: #fff;
border: none;
cursor: pointer;
}
.el-notification__closeBtn {
color: $accent !important;
}
}
@keyframes progress-animation {
0% {
width: 100%;
}
100% {
width: 0;
}
}

View file

@ -0,0 +1,17 @@
@use "../global/variables" as *;
.el-rate .el-rate__icon.is-active {
color: $accent !important;
}
.el-rate .el-rate__icon {
color: #9a9a9a !important;
font-size: 20px !important;
}
.white .el-rate__icon.is-active {
color: $white !important;
font-size: 24px !important;
}
.el-rate .el-rate__icon {
font-size: 24px !important;
}

View file

@ -70,7 +70,6 @@ const setBlock = (state: boolean) => {
}
// TODO: add loading state
// TODO: fix displaying main part (children categories)
const blockRef = ref(null)
onClickOutside(blockRef, () => setBlock(false))
@ -148,7 +147,7 @@ const setActiveCategory = (category: ICategory) => {
&__block {
display: grid;
grid-template-columns: 20% 80%;
max-height: 60vh;
max-height: 50vh;
}
&__columns {

View file

@ -13,37 +13,33 @@
<base-header-catalog />
<base-header-search />
<div class="header__actions">
<nuxt-link-locale to="/wishlist" class="header__actions-item">
<div>
<!-- <ui-counter>0</ui-counter>-->
<!-- <skeletons-ui-counter />-->
<Icon name="mdi:cards-heart-outline" size="28" />
</div>
<nuxt-link-locale to="/profile/wishlist" class="header__actions-item">
<el-badge :value="productsInWishlistQuantity" type="primary">
<icon name="mdi:cards-heart-outline" size="28" />
</el-badge>
<p>{{ t('header.actions.wishlist') }}</p>
</nuxt-link-locale>
<nuxt-link-locale to="/cart" class="header__actions-item">
<div>
<!-- <ui-counter>0</ui-counter>-->
<!-- <skeletons-ui-counter />-->
<Icon name="ph:shopping-cart-light" size="28" />
</div>
<nuxt-link-locale to="/profile/cart" class="header__actions-item">
<el-badge :value="productsInCartQuantity" type="primary">
<icon name="ph:shopping-cart-light" size="28" />
</el-badge>
<p>{{ t('header.actions.cart') }}</p>
</nuxt-link-locale>
<client-only>
<nuxt-link-locale
to="/"
to="/profile/settings"
class="header__actions-item"
v-if="isAuthenticated"
>
<Icon name="material-symbols-light:person-outline-rounded" size="32" />
<p @click="logout">{{ t('header.actions.profile') }}</p>
<icon name="material-symbols-light:person-outline-rounded" size="32" />
<p>{{ t('header.actions.profile') }}</p>
</nuxt-link-locale>
<div
class="header__actions-item"
@click="appStore.setActiveState('login')"
v-else
>
<Icon name="material-symbols-light:person-outline-rounded" size="32" />
<icon name="material-symbols-light:person-outline-rounded" size="32" />
<p>{{ t('header.actions.login') }}</p>
</div>
<template #fallback>
@ -51,7 +47,7 @@
class="header__actions-item"
@click="appStore.setActiveState('login')"
>
<Icon name="material-symbols-light:person-outline-rounded" size="32" />
<icon name="material-symbols-light:person-outline-rounded" size="32" />
<p>{{ t('header.actions.login') }}</p>
</div>
</template>
@ -64,20 +60,34 @@
<script setup lang="ts">
import {useLogout} from "~/composables/auth";
const { t } = useI18n()
const appStore = useAppStore()
const { t } = useI18n();
const appStore = useAppStore();
const userStore = useUserStore();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const isAuthenticated = computed(() => userStore.isAuthenticated)
const isAuthenticated = computed(() => userStore.isAuthenticated);
const { logout } = useLogout()
const productsInCartQuantity = computed(() => {
let count = 0;
cartStore.currentOrder?.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
return count;
});
const productsInWishlistQuantity = computed(() => {
return wishlistStore.wishlist ? wishlistStore.wishlist.products.edges.length : 0;
});
const { logout } = useLogout();
</script>
<style lang="scss" scoped>
.header {
box-shadow: 0 1px 2px #0000001a;
position: fixed;
z-index: 2;
z-index: 3;
top: 0;
left: 0;
width: 100vw;

View file

@ -17,11 +17,11 @@
@click="clearSearch"
v-if="query"
>
<Icon name="gridicons:cross" size="16" />
<icon name="gridicons:cross" size="16" />
</button>
<div class="search__tools-line" v-if="query"></div>
<button type="submit">
<Icon name="tabler:search" size="16" />
<icon name="tabler:search" size="16" />
</button>
</div>
</form>
@ -29,22 +29,23 @@
<skeletons-header-search v-if="loading" />
<div
class="search__results-inner"
v-for="(blocks, item) in filteredSearchResults"
:key="item"
v-for="(blocks, category) in filteredSearchResults"
:key="category"
>
<div class="search__results-title">
<p>{{ getBlockTitle(item) }}:</p>
<p>{{ getBlockTitle(category) }}:</p>
</div>
<div
class="search__item"
v-for="item in blocks"
:key="item.uuid"
@click.stop="goTo(category, item)"
>
<div class="search__item-left">
<Icon name="ic:twotone-search" size="18" />
<icon name="ic:twotone-search" size="18" />
<p>{{ item.name }}</p>
</div>
<Icon name="line-md:external-link" size="18" />
<icon name="line-md:external-link" size="18" />
</div>
</div>
<div class="search__results-empty" v-if="!hasResults && query && !loading">
@ -90,6 +91,29 @@ function submitSearch() {
toggleSearch(false);
}
}
function goTo(category: string, item: any) {
let path = "/";
console.log('c', category)
console.log(item)
switch (category) {
case "products":
path = `/product/${item.slug}`;
break;
case "categories":
path = `/catalog/${item.slug}`;
break;
case "brands":
path = `/brand/${item.uuid}`;
break;
case "posts":
path = "/";
break;
}
toggleSearch(false);
router.push(path);
}
</script>
<style lang="scss" scoped>

View file

@ -1,7 +1,7 @@
<template>
<div
class="card"
:class="{ 'card__list': productView === 'list' }"
:class="{ 'card__list': isList }"
>
<div class="card__wrapper">
<nuxt-link-locale
@ -10,7 +10,7 @@
>
<div class="card__block">
<client-only>
<Swiper
<swiper
v-if="images.length"
@swiper="onSwiper"
:modules="[EffectFade, Pagination]"
@ -19,7 +19,7 @@
:pagination="paginationOptions"
class="card__swiper"
>
<SwiperSlide
<swiper-slide
v-for="(img, i) in images"
:key="i"
class="card__swiper-slide"
@ -29,16 +29,18 @@
:alt="product.name"
loading="lazy"
class="card__swiper-image"
format="webp"
densities="x1"
/>
</SwiperSlide>
</Swiper>
</swiper-slide>
</swiper>
<div class="card__image-placeholder" />
<div
v-for="(_, i) in images"
:key="i"
v-for="(image, idx) in images"
:key="idx"
class="card__block-hover"
:style="{ left: `${(100/ images.length) * i}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(i)"
:style="{ left: `${(100/ images.length) * idx}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(idx)"
@mouseleave="goTo(0)"
/>
</client-only>
@ -50,19 +52,46 @@
<el-rate
v-model="rating"
size="large"
allow-half
disabled
/>
<div class="card__quantity">{{ t('cards.product.stock') }} {{ product.quantity }}</div>
</div>
</div>
<div class="card__bottom">
<ui-button class="card__bottom-button">
<ui-button
class="card__bottom-button"
v-if="isProductInCart"
@click="overwriteOrder({
type: 'remove',
productUuid: product.uuid,
productName: product.name
})"
:isLoading="removeLoading"
>
{{ t('buttons.removeFromCart') }}
</ui-button>
<ui-button
v-else
class="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
<div class="card__bottom-wishlist">
<Icon name="mdi:cards-heart-outline" size="28" />
<!-- <Icon name="mdi:cards-heart" size="28" />-->
<div
class="card__bottom-wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name
})"
>
<icon name="mdi:cards-heart" size="28" v-if="isProductInWishlist" />
<icon name="mdi:cards-heart-outline" size="28" v-else />
</div>
</div>
</div>
@ -70,28 +99,36 @@
<script setup lang="ts">
import type {IProduct} from "~/types/app/products";
import { useAppConfig } from '~/composables/config';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-fade';
import 'swiper/css/pagination'
import {useWishlistOverwrite} from "~/composables/wishlist";
import {useOrderOverwrite} from "~/composables/orders/useOrderOverwrite";
const props = defineProps<{
product: IProduct;
isList?: boolean;
}>();
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const { COOKIES_PRODUCT_VIEW_KEY } = useAppConfig()
const { overwriteWishlist } = useWishlistOverwrite();
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
const productView = useCookie<string>(
COOKIES_PRODUCT_VIEW_KEY as string,
{
default: () => 'grid',
path: '/',
}
)
const isProductInWishlist = computed(() => {
const el = wishlistStore.wishlist?.products?.edges.find(
(el) => el?.node?.uuid === props.product.uuid
);
return !!el;
});
const isProductInCart = computed(() => {
return cartStore.currentOrder?.orderProducts?.edges.find((prod) => prod.node.product.uuid === props.product?.uuid);
});
const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
@ -171,7 +208,7 @@ function goTo(index: number) {
}
@include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4);
box-shadow: 0 0 20px 2px rgba($accentDark, 0.4);
}
&__wrapper {
@ -248,6 +285,10 @@ function goTo(index: number) {
}
&__quantity {
width: fit-content;
background-color: rgba($accent, 0.2);
border-radius: $default_border_radius;
padding: 5px 10px;
font-size: 14px;
}

View file

@ -0,0 +1,79 @@
<template>
<nav class="nav">
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('settings') }]"
to="/profile/settings"
>
<icon name="ic:baseline-settings" size="20" />
{{ t('profile.settings.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('orders') }]"
to="/profile/orders"
>
<icon name="material-symbols:order-approve-rounded" size="20" />
{{ t('profile.orders.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('wishlist') }]"
to="/profile/wishlist"
>
<icon name="mdi:cards-heart-outline" size="20" />
{{ t('profile.wishlist.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('cart') }]"
to="/profile/cart"
>
<icon name="ph:shopping-cart-light" size="20" />
{{ t('profile.cart.title') }}
</nuxt-link-locale>
</nav>
</template>
<script setup lang="ts">
const {t} = useI18n();
const route = useRoute();
</script>
<style lang="scss" scoped>
.nav {
background-color: $white;
border-radius: $default_border_radius;
padding-block: 15px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
position: sticky;
top: 141px;
width: max-content;
height: fit-content;
&__item {
cursor: pointer;
padding: 5px 30px 5px 20px;
border-left: 2px solid $white;
display: flex;
align-items: center;
gap: 10px;
transition: 0.2s;
color: $accent;
font-weight: 600;
@include hover {
color: $accentDark;
}
&.active {
border-color: $accent;
color: $accentDark;
}
}
}
</style>

View file

@ -3,7 +3,7 @@
<div class="filter__wrapper" ref="filtersRef">
<div class="filter__top">
<h2>{{ t('store.filters.title') }}</h2>
<Icon
<icon
name="line-md:close"
size="30"
@click="closeFilters"
@ -19,7 +19,7 @@
<template #title="{ isActive }">
<div :class="['filter__collapse-title', { 'is-active': isActive }]">
{{ attribute.attributeName }}
<Icon
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filter__collapse-icon"
@ -31,7 +31,7 @@
:id="attribute.attributeName + '-all'"
v-model="selectedAllMap[attribute.attributeName]"
@change="toggleAll(attribute.attributeName)"
:isFilter="true"
:isAccent="true"
>
{{ t('store.filters.all') }}
</ui-checkbox>
@ -40,7 +40,7 @@
:key="idx"
:id="attribute.attributeName + idx"
v-model="selectedMap[attribute.attributeName][value]"
:isFilter="true"
:isAccent="true"
>
{{ value }}
</ui-checkbox>
@ -67,6 +67,7 @@
<script setup lang="ts">
import type {IStoreFilters} from "~/types";
import {useFilters} from "~/composables/store";
import {useRouteQuery} from "@vueuse/router";
const appStore = useAppStore();
const { t } = useI18n();
@ -81,7 +82,17 @@ const emit = defineEmits<{
(e: 'close'): void;
}>();
const { selectedMap, selectedAllMap, collapse, toggleAll, resetFilters, applyFilters } = useFilters(
const attributesQuery = useRouteQuery<string>('attributes', '');
const {
selectedMap,
selectedAllMap,
collapse,
toggleAll,
resetFilters,
applyFilters,
parseAttributesString
} = useFilters(
toRef(props, 'filterableAttributes')
);
@ -110,6 +121,27 @@ watch(
},
{ immediate: true }
);
watch(
() => attributesQuery.value,
(attrStr) => {
resetFilters();
if (!attrStr) return;
const initial = parseAttributesString(attrStr);
Object.entries(initial).forEach(([key, vals]) => {
vals.forEach(val => {
if (selectedMap[key] && selectedMap[key][val] !== undefined) {
selectedMap[key][val] = true;
}
})
if (selectedMap[key]) {
selectedAllMap[key] = Object.values(selectedMap[key]).every(v => v);
}
});
},
{ immediate: true }
);
</script>
<style scoped lang="scss">

View file

@ -23,6 +23,7 @@
v-for="product in products"
:key="product.node.uuid"
:product="product.node"
:isList="productView === 'list'"
/>
<skeletons-cards-product
v-if="pending"
@ -38,6 +39,7 @@
<script setup lang="ts">
import {useFilters, useStore} from "~/composables/store";
import {useRouteQuery} from "@vueuse/router";
import {useRouteParams} from "@vueuse/router";
import {useCategoryBySlug} from "~/composables/categories";
import {useAppConfig} from '~/composables/config';
@ -69,7 +71,7 @@ watch(
{ immediate: true }
);
const { pending, products, pageInfo, prodVars } = await useStore(
const { pending, products, pageInfo, variables } = await useStore(
slug.value,
attributes.value,
orderBy.value,
@ -86,29 +88,31 @@ function onFilterToggle() {
}
function onFiltersChange(newFilters: Record<string, string[]>) {
attributes.value = buildAttributesString(newFilters);
const attrString = buildAttributesString(newFilters);
attributes.value = attrString;
variables.attributes = attrString;
}
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage) {
prodVars.productAfter = pageInfo.value.endCursor;
if (isIntersecting && pageInfo.value?.hasNextPage && !pending.value) {
variables.productAfter = pageInfo.value.endCursor;
}
},
);
watch(orderBy, newVal => {
prodVars.orderBy = newVal || '';
variables.orderBy = newVal || '';
});
watch(attributes, newVal => {
prodVars.attributes = newVal || '';
variables.attributes = newVal;
});
watch(minPrice, newVal => {
prodVars.minPrice = newVal || 0;
variables.minPrice = newVal || 0;
});
watch(maxPrice, newVal => {
prodVars.maxPrice = newVal || 500000;
variables.maxPrice = newVal || 500000;
});
</script>

View file

@ -24,14 +24,14 @@
:class="{ active: productView === 'list' }"
@click="setView('list')"
>
<Icon name="material-symbols:view-list-sharp" size="16" />
<icon name="material-symbols:view-list-sharp" size="16" />
</button>
<button
class="top__view-button"
:class="{ active: productView === 'grid' }"
@click="setView('grid')"
>
<Icon name="material-symbols:grid-view" size="16" />
<icon name="material-symbols:grid-view" size="16" />
</button>
</div>
</div>
@ -41,7 +41,7 @@
@click="$emit('toggle-filter')"
>
{{ t('store.filters.title') }}
<Icon name="line-md:filter" size="16" />
<icon name="line-md:filter" size="16" />
</button>
</div>
</div>
@ -95,7 +95,7 @@ watch(select, value => {
margin-bottom: 20px;
width: 100%;
position: relative;
z-index: 1;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;

View file

@ -29,6 +29,7 @@ const { breadcrumbs } = useBreadcrumbs()
<style scoped lang="scss">
.breadcrumbs {
padding: 15px 250px 15px 50px;
line-height: 140%;
&__link {
cursor: pointer !important;

View file

@ -1,5 +1,5 @@
<template>
<label class="checkbox" :class="{ isFilter }">
<label class="checkbox" :class="{ isAccent }">
<input
:id="id"
class="checkbox__input"
@ -16,9 +16,9 @@
<script setup lang="ts">
const props = defineProps<{
id: string,
id?: string,
modelValue: boolean,
isFilter: boolean
isAccent?: boolean
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
@ -37,14 +37,15 @@ function onChange(e: Event) {
gap: 5px;
cursor: pointer;
&.isFilter {
&.isAccent {
& .checkbox__block {
border: 2px solid $accent;
border: 2px solid $accentDark;
border-radius: $default_border_radius;
}
& .checkbox__label {
color: $accent;
color: $accentDark;
font-weight: 600;
}
}

View file

@ -1,27 +0,0 @@
<template>
<div class="counter">
<slot></slot>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.counter {
position: absolute !important;
top: -10px;
right: -15px;
background-color: $accent;
border-radius: 50%;
width: 20px;
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: 14px;
font-weight: 600;
color: $white;
}
</style>

View file

@ -10,74 +10,75 @@
class="block__input"
>
<button
@click.prevent="setPasswordVisible"
@click.prevent="togglePasswordVisible"
class="block__eyes"
v-if="type === 'password' && modelValue"
v-if="type === 'password' && String(modelValue).length > 0"
>
<Icon v-if="isPasswordVisible === 'password'" name="mdi:eye-off-outline" />
<Icon v-else name="mdi:eye-outline" />
<icon v-if="isPasswordVisible === 'password'" name="mdi:eye-off-outline" />
<icon v-else name="mdi:eye-outline" />
</button>
</div>
<p v-if="!validate" class="block__error">{{ errorMessage }}</p>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
const $emit = defineEmits();
type Rule = (value: string) => boolean | string;
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
type: string,
placeholder: string,
isError?: boolean,
error?: string,
modelValue?: [string, number],
rules?: array,
numberOnly: boolean
rules?: Rule[],
numberOnly?: boolean
}>();
const isPasswordVisible = ref<string>(props.type);
const setPasswordVisible = () => {
if (isPasswordVisible.value === 'password') {
isPasswordVisible.value = 'text';
return;
}
isPasswordVisible.value = 'password';
};
const isPasswordVisible = ref(props.type);
const isValid = ref(true);
const errorMessage = ref('');
const onlyNumbersKeydown = (event) => {
function togglePasswordVisible() {
isPasswordVisible.value =
isPasswordVisible.value === 'password' ? 'text' : 'password';
}
const onlyNumbersKeydown = (event: KeyboardEvent) => {
if (!/^\d$/.test(event.key) &&
!['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
}
};
const validate = ref<boolean>(true);
const errorMessage = ref<string>('');
const onInput = (e: Event) => {
let value = e.target.value;
function onInput(e: Event) {
const target = e.target as HTMLInputElement;
let value = target.value;
if (props.numberOnly) {
const newValue = value.replace(/\D/g, '');
if (newValue !== value) {
e.target.value = newValue;
value = newValue;
const digitsOnly = value.replace(/\D/g, '');
if (digitsOnly !== value) {
target.value = digitsOnly;
value = digitsOnly;
}
}
let result = true;
let valid = true;
errorMessage.value = '';
props.rules?.forEach((rule) => {
result = rule((e.target).value);
if (!result) {
const result = rule(value);
if (result !== true) {
valid = false;
errorMessage.value = String(result);
result = false;
}
})
validate.value = result;
isValid.value = valid;
return $emit('update:modelValue', (e.target).value);
};
emit('update:modelValue', props.numberOnly ? Number(value) : value);
}
</script>
<style lang="scss" scoped>
@ -98,7 +99,6 @@ const onInput = (e: Event) => {
width: 100%;
padding: 6px 12px;
border: 1px solid #e0e0e0;
//border: 1px solid #b2b2b2;
border-radius: $default_border_radius;
background-color: $white;

View file

@ -6,38 +6,42 @@
@input="onInput"
class="block__textarea"
/>
<p v-if="!validate" class="block__error">{{ errorMessage }}</p>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
const $emit = defineEmits();
type Rule = (value: string) => boolean | string;
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
placeholder: string,
isError?: boolean,
error?: string,
modelValue?: [string, number],
rules?: array
modelValue?: string,
rules?: Rule[]
}>();
const validate = ref<boolean>(true)
const errorMessage = ref<string>('')
const isValid = ref(true);
const errorMessage = ref('');
const onInput = (e: Event) => {
let result = true
const target = e.target as HTMLTextAreaElement;
const value = target.value;
props.rules?.forEach((rule) => {
result = rule((e.target).value)
isValid.value = true;
errorMessage.value = '';
if (!result) {
errorMessage.value = String(result)
result = false
props.rules?.forEach(rule => {
const result = rule(value);
if (result !== true) {
isValid.value = false;
errorMessage.value = String(result);
}
})
});
validate.value = result
return $emit('update:modelValue', (e.target).value)
}
emit('update:modelValue', value);
};
</script>
<style lang="scss" scoped>

View file

@ -1,6 +1,6 @@
export * from './useLogin'
export * from './useRefresh'
export * from './useRegister'
export * from './useLogout'
export * from './usePasswordReset'
export * from './useNewPassword'
export * from './useLogin';
export * from './useRefresh';
export * from './useRegister';
export * from './useLogout';
export * from './usePasswordReset';
export * from './useNewPassword';

View file

@ -8,6 +8,7 @@ import { usePendingOrder } from '~/composables/orders';
import { useUserStore } from '~/stores/user';
import { useAppStore } from '~/stores/app';
import {DEFAULT_LOCALE} from "~/config/constants";
import {useNotification} from "~/composables/notification";
export function useLogin() {
const { t } = useI18n();
@ -57,7 +58,10 @@ export function useLogin() {
userStore.setUser(authData.user);
cookieAccess.value = authData.accessToken
ElNotification({ message: t('popup.success.login'), type: 'success' });
useNotification(
t('popup.success.login'),
'success'
);
if (authData.user.language !== cookieLocale.value) {
await checkAndRedirect(authData.user.language);
@ -78,11 +82,11 @@ export function useLogin() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
});
return {

View file

@ -2,6 +2,7 @@ import {NEW_PASSWORD} from "@/graphql/mutations/auth.js";
import {isGraphQLError} from "~/utils/error";
import type {INewPasswordResponse} from "~/types";
import { useRouteQuery } from '@vueuse/router';
import {useNotification} from "~/composables/notification";
export function useNewPassword() {
const {t} = useI18n();
@ -25,10 +26,10 @@ export function useNewPassword() {
});
if (result?.data?.confirmResetPassword.success) {
ElNotification({
message: t('popup.success.newPassword'),
type: 'success'
})
useNotification(
t('popup.success.newPassword'),
'success'
);
await router.push({path: '/'})
@ -45,11 +46,11 @@ export function useNewPassword() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
});
return {

View file

@ -1,6 +1,7 @@
import {RESET_PASSWORD} from "@/graphql/mutations/auth.js";
import {isGraphQLError} from "~/utils/error";
import type {IPasswordResetResponse} from "~/types";
import {useNotification} from "~/composables/notification";
export function usePasswordReset() {
const {t} = useI18n();
@ -16,10 +17,10 @@ export function usePasswordReset() {
});
if (result?.data?.resetPassword.success) {
ElNotification({
message: t('popup.success.reset'),
type: 'success'
})
useNotification(
t('popup.success.reset'),
'success'
);
appStore.unsetActiveState();
}
@ -34,11 +35,11 @@ export function usePasswordReset() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
});
return {

View file

@ -6,6 +6,7 @@ import { usePendingOrder } from '~/composables/orders';
import { useUserStore } from '~/stores/user';
import { isGraphQLError } from '~/utils/error';
import {DEFAULT_LOCALE} from "~/config/constants";
import {useNotification} from "~/composables/notification";
export function useRefresh() {
const { t } = useI18n();
@ -62,11 +63,11 @@ export function useRefresh() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
})
return {

View file

@ -2,6 +2,7 @@ import {REGISTER} from "@/graphql/mutations/auth.js";
import {useMailClient} from "@/composables/utils";
import {isGraphQLError} from "~/utils/error";
import type {IRegisterResponse} from "~/types";
import {useNotification} from "~/composables/notification";
export function useRegister() {
const {t} = useI18n();
@ -31,20 +32,13 @@ export function useRegister() {
if (result?.data?.createUser?.success) {
detectMailClient(email);
ElNotification({
message: h('div', [
useNotification(
h('div', [
h('p', t('popup.success.register')),
mailClientUrl.value ? h(
'button',
{
style: {
marginTop: '10px',
padding: '6px 12px',
backgroundColor: '#000000',
color: '#fff',
border: 'none',
cursor: 'pointer',
},
class: 'el-notification__button',
onClick: () => {
openMailClient()
}
@ -52,27 +46,27 @@ export function useRegister() {
t('buttons.goEmail')
) : ''
]),
type: 'success'
})
'success'
);
appStore.unsetActiveState();
}
}
watch(error, (err) => {
if (!err) return
console.error('useRegister error:', err)
let message = t('popup.errors.defaultError')
if (!err) return;
console.error('useRegister error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
})
'error',
t('popup.errors.main')
);
})
return {

View file

@ -1,2 +1,2 @@
export * from './useBrands'
export * from './useBrandByUuid'
export * from './useBrands';
export * from './useBrandByUuid';

View file

@ -1 +1 @@
export * from './useBreadcrumbs'
export * from './useBreadcrumbs';

View file

@ -1,3 +1,3 @@
export * from './useCategories'
export * from './useCategoryTags'
export * from './useCategoryBySlug'
export * from './useCategories';
export * from './useCategoryTags';
export * from './useCategoryBySlug';

View file

@ -1 +1 @@
export * from './useCompanyInfo'
export * from './useCompanyInfo';

View file

@ -1 +1 @@
export * from './useAppConfig'
export * from './useAppConfig';

View file

@ -1 +1 @@
export * from './useContactUs'
export * from './useContactUs';

View file

@ -1,6 +1,7 @@
import {isGraphQLError} from "~/utils/error";
import type {IContactUsResponse} from "~/types";
import {CONTACT_US} from "~/graphql/mutations/contact";
import {useNotification} from "~/composables/notification";
export function useContactUs() {
const {t} = useI18n();
@ -23,10 +24,10 @@ export function useContactUs() {
});
if (result?.data?.contactUs.received) {
ElNotification({
message: t('popup.success.contactUs'),
type: 'success'
})
useNotification(
t('popup.success.contactUs'),
'success'
);
}
}
@ -39,11 +40,11 @@ export function useContactUs() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
});
return {

View file

@ -1,3 +1,3 @@
export * from './useLocaleRedirect'
export * from './useLanguage'
export * from './useLanguageSwitch'
export * from './useLocaleRedirect';
export * from './useLanguage';
export * from './useLanguageSwitch';

View file

@ -0,0 +1 @@
export * from './useNotification';

View file

@ -0,0 +1,26 @@
export function useNotification(
message: string,
type: string,
title?: string
) {
const duration = 5000;
const createProgressBar = (duration: number, message: string) => {
return h('div', [
h('p', message),
h('div', {
class: 'el-notification__progress',
style: {
animationDuration: `${duration}ms`
}
})
]);
};
ElNotification({
title: title,
duration,
message: createProgressBar(duration, message),
type: type
} as import('element-plus').NotificationOptions);
}

View file

@ -1 +1 @@
export * from './usePendingOrder'
export * from './usePendingOrder';

View file

@ -0,0 +1,176 @@
import type {
IAddToOrderResponse,
IBulkOrderResponse, IRemoveAllFromOrderResponse,
IRemoveFromOrderResponse, IRemoveKindFromOrderResponse,
} from "~/types";
import {
ADD_TO_CART,
BULK_CART,
REMOVE_ALL_FROM_CART,
REMOVE_FROM_CART,
REMOVE_KIND_FROM_CART
} from "~/graphql/mutations/cart";
import {useNotification} from "~/composables/notification";
import {isGraphQLError} from "~/utils/error";
interface IOverwriteOrderArguments {
type: string,
productUuid?: string,
productName?: string,
bulkAction?: string,
products?: {
uuid: string
}[]
}
export function useOrderOverwrite () {
const {t} = useI18n();
const cartStore = useCartStore();
const orderUuid = computed(() => cartStore.currentOrder?.uuid);
const {
mutate: addMutate,
loading: addLoading,
error: addError
} = useMutation<IAddToOrderResponse>(ADD_TO_CART);
const {
mutate: removeMutate,
loading: removeLoading,
error: removedError
} = useMutation<IRemoveFromOrderResponse>(REMOVE_FROM_CART);
const {
mutate: removeKindMutate,
loading: removeKindLoading,
error: removedKindError
} = useMutation<IRemoveKindFromOrderResponse>(REMOVE_KIND_FROM_CART);
const {
mutate: removeAllMutate,
loading: removeAllLoading,
error: removedAllError
} = useMutation<IRemoveAllFromOrderResponse>(REMOVE_ALL_FROM_CART);
const {
mutate: bulkMutate,
loading: bulkLoading,
error: bulkError
} = useMutation<IBulkOrderResponse>(BULK_CART);
async function overwriteOrder (
args: IOverwriteOrderArguments
) {
switch (args.type) {
case "add":
const addResult = await addMutate({
orderUuid: orderUuid.value,
productUuid: args.productUuid
});
if (addResult?.data?.addOrderProduct?.order) {
cartStore.setCurrentOrders(addResult.data.addOrderProduct.order);
useNotification(
t('popup.success.addToCart', { product: args.productName }),
'success'
);
}
break;
case "remove":
const removeResult = await removeMutate({
orderUuid: orderUuid.value,
productUuid: args.productUuid
});
if (removeResult?.data?.removeOrderProduct?.order) {
cartStore.setCurrentOrders(removeResult.data.removeOrderProduct.order);
useNotification(
t('popup.success.removeFromCart', { product: args.productName }),
'success'
);
}
break;
case "removeKind":
const removeKindResult = await removeKindMutate({
orderUuid: orderUuid.value,
productUuid: args.productUuid
});
if (removeKindResult?.data?.removeOrderProductsOfAKind?.order) {
cartStore.setCurrentOrders(removeKindResult.data.removeOrderProductsOfAKind.order);
useNotification(
t('popup.success.removeFromCart', { product: args.productName }),
'success'
);
}
break;
case "removeAll":
const removeAllResult = await removeAllMutate({
orderUuid: orderUuid.value,
productUuid: args.productUuid
});
if (removeAllResult?.data?.removeAllOrderProducts?.order) {
cartStore.setCurrentOrders(removeAllResult.data.removeAllOrderProducts.order);
useNotification(
t('popup.success.removeAllFromCart', { product: args.productName }),
'success'
);
}
break;
case "bulk":
const bulkResult = await bulkMutate({
orderUuid: orderUuid.value,
action: args.bulkAction,
products: args.products
});
if (bulkResult?.data?.bulkOrderAction?.order) {
cartStore.setCurrentOrders(bulkResult.data.bulkOrderAction.order);
useNotification(
t('popup.success.bulkRemoveWishlist'),
'success'
);
}
break;
default:
console.error('No type provided for overwriteOrder');
}
}
watch(addError || removedError || removedKindError || removedAllError || bulkError, (err) => {
if (!err) return;
console.error('useOrderOverwrite error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification(
message,
'error',
t('popup.errors.main')
);
});
return{
addLoading,
removeLoading,
removeKindLoading,
removeAllLoading,
bulkLoading,
overwriteOrder
};
}

View file

@ -1,3 +1,3 @@
export * from './useProducts'
export * from './useProductBySlug'
export * from './useProductTags'
export * from './useProducts';
export * from './useProductBySlug';
export * from './useProductTags';

View file

@ -1,26 +1,26 @@
import { GET_PRODUCT_BY_SLUG } from '~/graphql/queries/standalone/products'
import type { IProduct, IProductResponse } from '~/types'
import { GET_PRODUCT_BY_SLUG } from '~/graphql/queries/standalone/products';
import type { IProduct, IProductResponse } from '~/types';
export async function useProductBySlug(slug: string) {
const product = useState<IProduct | null>('currentProduct', () => null)
const product = useState<IProduct | null>('currentProduct', () => null);
const { data, error } = await useAsyncQuery<IProductResponse>(
GET_PRODUCT_BY_SLUG,
{ slug }
)
);
const result = data.value?.products?.edges[0]?.node ?? null
const result = data.value?.products?.edges[0]?.node ?? null;
if (result) {
product.value = result
product.value = result;
}
watch(error, (err) => {
if (err) {
console.error('useProductBySlug error:', err)
console.error('useProductBySlug error:', err);
}
})
});
return {
product
}
};
}

View file

@ -1 +1 @@
export * from './useFormValidation'
export * from './useFormValidation';

View file

@ -0,0 +1 @@
export * from './useScrollTo';

View file

@ -0,0 +1,33 @@
export function useScrollTo () {
const router = useRouter();
function scroll (id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
} else {
console.error('Element not found:', id);
}
}
function scrollTo (
id: string,
routePath?: string
) {
if (routePath) {
router.push({
path: routePath
}).then(() => {
scroll(id);
});
} else {
setTimeout(() => {
scroll(id);
}, 100);
}
}
return {
scrollTo
};
}

View file

@ -1,3 +1,3 @@
export * from './useSearch'
export * from './useSearchCombined'
export * from './useSearchUi'
export * from './useSearch';
export * from './useSearchCombined';
export * from './useSearchUi';

View file

@ -1,6 +1,7 @@
import {SEARCH} from "~/graphql/mutations/search";
import type {ISearchResponse, ISearchResults} from "~/types";
import {isGraphQLError} from "~/utils/error";
import {useNotification} from "~/composables/notification";
export function useSearch() {
const {t} = useI18n();
@ -37,11 +38,11 @@ export function useSearch() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
});
return {

View file

@ -1,2 +1,2 @@
export * from './useStore'
export * from './useFilters'
export * from './useStore';
export * from './useFilters';

View file

@ -79,6 +79,29 @@ export function useFilters(filterableAttributes: Ref<IStoreFilters[]>) {
.join(';');
}
function parseAttributesString(str: string): Record<string, string[]> {
const result: Record<string, string[]> = {};
if (!str) return result;
str.split(';').forEach(entry => {
const [name, expr] = entry.split('=');
if (!name || !expr) return;
if (expr.startsWith('in-')) {
try {
result[name] = JSON.parse(expr.slice(3));
} catch {
result[name] = [];
}
}
else if (expr.startsWith('icontains-')) {
result[name] = [expr.slice('icontains-'.length)];
}
});
return result;
}
return {
selectedMap,
selectedAllMap,
@ -86,6 +109,7 @@ export function useFilters(filterableAttributes: Ref<IStoreFilters[]>) {
toggleAll,
resetFilters,
applyFilters,
buildAttributesString
buildAttributesString,
parseAttributesString
};
}

View file

@ -19,7 +19,7 @@ export async function useStore(
maxPrice?: number,
productAfter?: string
) {
const prodVars = reactive<ProdVars>({
const variables = reactive<ProdVars>({
first: 15,
categoriesSlugs: slug,
attributes,
@ -29,7 +29,10 @@ export async function useStore(
productAfter
});
const { pending, data, error, refresh } = await useAsyncQuery<IProductResponse>(GET_PRODUCTS, prodVars);
const { pending, data, error, refresh } = await useAsyncQuery<IProductResponse>(
GET_PRODUCTS,
variables
);
const products = ref(data.value?.products.edges ?? []);
const pageInfo = computed(() => data.value?.products.pageInfo ?? null);
@ -37,7 +40,7 @@ export async function useStore(
watch(error, e => e && console.error('useStore products error', e));
watch(
() => prodVars.productAfter,
() => variables.productAfter,
async (newCursor, oldCursor) => {
if (!newCursor || newCursor === oldCursor) return;
await refresh();
@ -48,13 +51,13 @@ export async function useStore(
watch(
[
() => prodVars.attributes,
() => prodVars.orderBy,
() => prodVars.minPrice,
() => prodVars.maxPrice
() => variables.attributes,
() => variables.orderBy,
() => variables.minPrice,
() => variables.maxPrice
],
async () => {
prodVars.productAfter = '';
variables.productAfter = '';
await refresh();
products.value = data.value?.products.edges ?? [];
}
@ -64,6 +67,6 @@ export async function useStore(
pending,
products,
pageInfo,
prodVars
variables
};
}

View file

@ -1 +1 @@
export * from './useUserActivation'
export * from './useUserActivation';

View file

@ -1,6 +1,7 @@
import {ACTIVATE_USER} from "@/graphql/mutations/user.js";
import {isGraphQLError} from "~/utils/error";
import type {IUserActivationResponse} from "~/types";
import {useNotification} from "~/composables/notification";
export function useUserActivation() {
const {t} = useI18n();
@ -14,10 +15,10 @@ export function useUserActivation() {
const result = await mutate({ token, uid });
if (result?.data?.activateUser) {
ElNotification({
message: t("popup.activationSuccess"),
type: "success"
});
useNotification(
t("popup.activationSuccess"),
'success'
);
}
}
@ -30,11 +31,11 @@ export function useUserActivation() {
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
useNotification(
message,
type: 'error'
});
'error',
t('popup.errors.main')
);
});
return {

View file

@ -1,2 +1,2 @@
export * from './useMailClient'
export * from './usePageTitle'
export * from './useMailClient';
export * from './usePageTitle';

View file

@ -1 +1,2 @@
export * from './useWishlist'
export * from './useWishlist';
export * from './useWishlistOverwrite';

View file

@ -0,0 +1,152 @@
import type {
IAddToWishlistResponse, IBulkWishlistResponse,
IRemoveAllFromWishlistResponse,
IRemoveFromWishlistResponse
} from "~/types";
import {
ADD_TO_WISHLIST,
BULK_WISHLIST,
REMOVE_ALL_FROM_WISHLIST,
REMOVE_FROM_WISHLIST
} from "~/graphql/mutations/wishlist";
import {isGraphQLError} from "~/utils/error";
import {useNotification} from "~/composables/notification";
interface IOverwriteWishlistArguments {
type: string,
productUuid?: string,
productName?: string,
bulkAction?: string,
products?: {
uuid: string
}[]
}
export function useWishlistOverwrite() {
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const wishlistUuid = computed(() => wishlistStore.wishlist?.uuid);
const {
mutate: addMutate,
loading: addLoading,
error: addError
} = useMutation<IAddToWishlistResponse>(ADD_TO_WISHLIST);
const {
mutate: removeMutate,
loading: removeLoading,
error: removedError
} = useMutation<IRemoveFromWishlistResponse>(REMOVE_FROM_WISHLIST);
const {
mutate: removeAllMutate,
loading: removeAllLoading,
error: removeAllError
} = useMutation<IRemoveAllFromWishlistResponse>(REMOVE_ALL_FROM_WISHLIST);
const {
mutate: bulkMutate,
loading: bulkLoading,
error: bulkError
} = useMutation<IBulkWishlistResponse>(BULK_WISHLIST);
async function overwriteWishlist (
args: IOverwriteWishlistArguments
) {
switch (args.type) {
case "add":
const addResult = await addMutate({
wishlistUuid: wishlistUuid.value,
productUuid: args.productUuid
});
if (addResult?.data?.addWishlistProduct?.wishlist) {
wishlistStore.setWishlist(addResult.data.addWishlistProduct.wishlist);
useNotification(
t('popup.success.addToWishlist', { product: args.productName }),
'success'
);
}
break;
case "remove":
const removeResult = await removeMutate({
wishlistUuid: wishlistUuid.value,
productUuid: args.productUuid
});
if (removeResult?.data?.removeWishlistProduct?.wishlist) {
wishlistStore.setWishlist(removeResult.data.removeWishlistProduct.wishlist);
useNotification(
t('popup.success.removeFromWishlist', { product: args.productName }),
'success'
);
}
break;
case "removeAll":
const removeAllResult = await removeAllMutate({
wishlistUuid: wishlistUuid.value,
productUuid: args.productUuid
});
if (removeAllResult?.data?.removeAllWishlistProducts?.wishlist) {
wishlistStore.setWishlist(removeAllResult.data.removeAllWishlistProducts.wishlist);
useNotification(
t('popup.success.removeAllFromWishlist'),
'success'
);
}
break;
case "bulk":
const bulkResult = await bulkMutate({
wishlistUuid: wishlistUuid.value,
action: args.bulkAction,
products: args.products
});
if (bulkResult?.data?.bulkWishlistAction?.wishlist) {
wishlistStore.setWishlist(bulkResult.data.bulkWishlistAction.wishlist);
useNotification(
t('popup.success.bulkRemoveWishlist'),
'success'
);
}
break;
default:
console.error('No type provided for overwriteWishlist');
}
}
watch(addError || removedError || removeAllError || bulkError, (err) => {
if (!err) return;
console.error('useWishlistOverwrite error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification(
message,
'error',
t('popup.errors.main')
);
});
return{
addLoading,
removeLoading,
removeAllLoading,
bulkLoading,
overwriteWishlist
};
}

View file

@ -5,8 +5,16 @@ export const PRODUCT_FRAGMENT = gql`
price
quantity
slug
description
brand {
smallLogo
uuid
name
}
category {
name
slug
uuid
}
images {
edges {

View file

@ -8,6 +8,7 @@ export const USER_FRAGMENT = gql`
firstName
lastName
phoneNumber
dateJoined
balance {
amount
}

View file

@ -2,8 +2,8 @@ import {ORDER_FRAGMENT} from "@/graphql/fragments/orders.fragment.js";
export const ADD_TO_CART = gql`
mutation addToCart(
$orderUuid: String!,
$productUuid: String!
$orderUuid: UUID!,
$productUuid: UUID!
) {
addOrderProduct(
orderUuid: $orderUuid,
@ -19,8 +19,8 @@ export const ADD_TO_CART = gql`
export const REMOVE_FROM_CART = gql`
mutation removeFromCart(
$orderUuid: String!,
$productUuid: String!
$orderUuid: UUID!,
$productUuid: UUID!
) {
removeOrderProduct(
orderUuid: $orderUuid,
@ -36,8 +36,8 @@ export const REMOVE_FROM_CART = gql`
export const REMOVE_KIND_FROM_CART = gql`
mutation removeKindFromCart(
$orderUuid: String!,
$productUuid: String!
$orderUuid: UUID!,
$productUuid: UUID!
) {
removeOrderProductsOfAKind(
orderUuid: $orderUuid,
@ -53,7 +53,7 @@ export const REMOVE_KIND_FROM_CART = gql`
export const REMOVE_ALL_FROM_CART = gql`
mutation removeAllFromCart(
$orderUuid: String!
$orderUuid: UUID!
) {
removeAllOrderProducts(
orderUuid: $orderUuid
@ -65,3 +65,22 @@ export const REMOVE_ALL_FROM_CART = gql`
}
${ORDER_FRAGMENT}
`
export const BULK_CART = gql`
mutation bulkOrderAction(
$orderUuid: UUID!,
$action: String!,
$products: [BulkActionOrderProductInput]!
) {
bulkOrderAction(
orderUuid: $orderUuid
action: $action
products: $products
) {
order {
...Order
}
}
}
${ORDER_FRAGMENT}
`

View file

@ -1,3 +1,5 @@
import {USER_FRAGMENT} from "~/graphql/fragments/user.fragment";
export const SWITCH_LANGUAGE = gql`
mutation setlanguage(
$uuid: UUID!,
@ -12,4 +14,5 @@ export const SWITCH_LANGUAGE = gql`
}
}
}
${USER_FRAGMENT}
`

View file

@ -2,8 +2,8 @@ import {WISHLIST_FRAGMENT} from "@/graphql/fragments/wishlist.fragment.js";
export const ADD_TO_WISHLIST = gql`
mutation addToWishlist(
$wishlistUuid: String!,
$productUuid: String!
$wishlistUuid: UUID!,
$productUuid: UUID!
) {
addWishlistProduct(
wishlistUuid: $wishlistUuid,
@ -19,8 +19,8 @@ export const ADD_TO_WISHLIST = gql`
export const REMOVE_FROM_WISHLIST = gql`
mutation removeFromWishlist(
$wishlistUuid: String!,
$productUuid: String!
$wishlistUuid: UUID!,
$productUuid: UUID!
) {
removeWishlistProduct(
wishlistUuid: $wishlistUuid,
@ -36,7 +36,7 @@ export const REMOVE_FROM_WISHLIST = gql`
export const REMOVE_ALL_FROM_WISHLIST = gql`
mutation removeAllFromWishlist(
$wishlistUuid: String!
$wishlistUuid: UUID!
) {
removeAllWishlistProducts(
wishlistUuid: $wishlistUuid
@ -48,3 +48,22 @@ export const REMOVE_ALL_FROM_WISHLIST = gql`
}
${WISHLIST_FRAGMENT}
`
export const BULK_WISHLIST = gql`
mutation bulkWishlistAction(
$wishlistUuid: UUID!,
$action: String!,
$products: [BulkActionOrderProductInput]!
) {
bulkWishlistAction(
wishlistUuid: $wishlistUuid
action: $action
products: $products
) {
wishlist {
...Wishlist
}
}
}
${WISHLIST_FRAGMENT}
`

View file

@ -20,7 +20,7 @@ export const GET_BRANDS = gql`
export const GET_BRAND_BY_UUID = gql`
query getBrandbyUuid(
$uuid: String!
$uuid: UUID!
) {
brands(
uuid: $uuid

View file

@ -2,22 +2,24 @@ import {PRODUCT_FRAGMENT} from "@/graphql/fragments/products.fragment.js";
export const GET_PRODUCTS = gql`
query getProducts(
$after: String,
$productAfter: String,
$first: Int,
$categoriesSlugs: String,
$orderBy: String,
$minPrice: Decimal,
$maxPrice: Decimal,
$productName: String
$productName: String,
$attributes: String
) {
products(
after: $after,
after: $productAfter,
first: $first,
categoriesSlugs: $categoriesSlugs,
orderBy: $orderBy,
minPrice: $minPrice,
maxPrice: $maxPrice,
name: $productName
name: $productName,
attributes: $attributes
) {
edges {
cursor

View file

@ -2,11 +2,12 @@
"buttons": {
"login": "Login",
"register": "Register",
"addToCart": "Add To Cart",
"addToCart": "Add to cart",
"removeFromCart": "Remove from cart",
"send": "Send",
"goEmail": "Take me to my inbox",
"logout": "Log Out",
"buy": "Buy Now",
"checkout": "Checkout",
"save": "Save",
"sendLink": "Send link",
"topUp": "Top up"
@ -37,7 +38,8 @@
"confirmNewPassword": "Confirm new password"
},
"checkboxes": {
"remember": "Remember me"
"remember": "Remember me",
"chooseAll": "Choose all"
},
"popup": {
"errors": {
@ -51,17 +53,21 @@
"confirmEmail": "Verification E-mail link successfully sent!",
"reset": "If specified email exists in our system, we will send a password recovery email!",
"newPassword": "You have successfully changed your password!",
"contactUs": "Your message was sent successfully!"
},
"contactUs": "Your message was sent successfully!",
"addToCart": "{product} has been added to the cart!",
"removeFromCart": "{product} has been removed from the cart!",
"removeAllFromCart": "You have successfully emptied the cart!",
"addToWishlist": "{product} has been added to the wishlist!",
"removeFromWishlist": "{product} has been removed from the wishlist!",
"removeAllFromWishlist": "You have successfully emptied the wishlist!",
"bulkRemoveWishlist": "Selected items have been successfully removed from the wishlist!"
},
"addToCartLimit": "Total quantity limit is {quantity}!",
"failAdd": "Please log in to make a purchase",
"activationSuccess": "E-mail successfully verified. Please Sign In!",
"successUpdate": "Profile successfully updated!",
"payment": "Your purchase is being processed! Please stand by",
"successCheckout": "Order purchase successful!",
"addToWishlist": "{product} has been added to the wishlist!"
"successCheckout": "Order purchase successful!"
},
"header": {
"actions": {
@ -113,7 +119,10 @@
},
"breadcrumbs": {
"home": "Home",
"catalog": "Catalog"
"catalog": "Catalog",
"contact": "Contact",
"wishlist": "Wishlist",
"cart": "Cart"
},
"contact": {
"title": "Contact us"
@ -123,7 +132,8 @@
"filters": {
"title": "Filters",
"apply": "Apply",
"reset": "Reset"
"reset": "Reset",
"all": "All"
}
},
"search": {
@ -131,5 +141,26 @@
"categories": "Categories",
"brands": "Brands",
"byRequest": "by request"
},
"product": {
"characteristics": "All characteristics",
"similar": "Similar products"
},
"profile": {
"settings": {
"title": "Settings"
},
"orders": {
"title": "Orders"
},
"wishlist": {
"title": "Wishlist",
"total": "{quantity} items worth {amount}"
},
"cart": {
"title": "Cart",
"quantity": "Quantity: ",
"total": "Total: "
}
}
}

View file

@ -27,7 +27,7 @@ export default defineNuxtConfig({
authType: 'Bearer',
authHeader: 'X-EVIBES-AUTH',
tokenStorage: 'cookie',
tokenName: `${process.env.EVIBES_PROJECT_NAME?.toLowerCase()}-access`,
tokenName: `${process.env.EVIBES_PROJECT_NAME?.toLowerCase()}-access`
}
},
},

View file

@ -865,30 +865,30 @@
"license": "MIT"
},
"node_modules/@floating-ui/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
"integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
"integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/utils": "^0.2.9"
"@floating-ui/core": "^1.7.2",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"dev": true,
"license": "MIT"
},
@ -969,9 +969,9 @@
}
},
"node_modules/@iconify/collections": {
"version": "1.0.557",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.557.tgz",
"integrity": "sha512-283MvzQA7NgWkjt2AiMURoyW2hUWu0C4c0PjgAOXiQ4lEVQlwIYP8a9FR3xSECfc+EAMq2+M+1kbUlq09doRtw==",
"version": "1.0.563",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.563.tgz",
"integrity": "sha512-Y5GNkqMQW4RfF5nG7GN9zht2IexXpygFb01T1+xvm/hvEBgn7CDnxO2v3D586VhcA5N93iEBpLVGyV4gaHnZOA==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
@ -1765,17 +1765,17 @@
}
},
"node_modules/@nuxt/icon": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-1.13.0.tgz",
"integrity": "sha512-Sft1DZj/U5Up60DMnhp+hRDNDXRkMhwHocxgDkDkAPBxqR8PRyvzxcMIy3rjGMu0s+fB6ZdLs6vtaWzjWuerQQ==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-1.14.0.tgz",
"integrity": "sha512-4kb2rbvbSll784LUme2fDm62NW0Tryr8wADFEU3vIoOj4TZywcwPafIl0MT6ah3RNgbPd174EFVOaUdPSUQENA==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.548",
"@iconify/collections": "^1.0.560",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^2.3.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^2.4.1",
"@nuxt/kit": "^3.17.3",
"@nuxt/devtools-kit": "^2.5.0",
"@nuxt/kit": "^3.17.5",
"consola": "^3.4.2",
"local-pkg": "^1.1.1",
"mlly": "^1.7.4",
@ -1783,7 +1783,7 @@
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinyglobby": "^0.2.13"
"tinyglobby": "^0.2.14"
}
},
"node_modules/@nuxt/image": {
@ -3251,13 +3251,13 @@
}
},
"node_modules/@vueuse/integrations": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.3.0.tgz",
"integrity": "sha512-h5mGRYPbiTZTFP/AKELLPGnUDBly7z7Qd1pgEQlT3ItQ0NlZM0vB+8SOQycpSBOBlgg72Zgw+mi2r+4O/G8RuQ==",
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.4.0.tgz",
"integrity": "sha512-rwNoE0MNJBUuSzTZcUVrkovtHvpWIySOcC6XpcS33ZarHDNhd9CPvCD4eNl3N0Phz1he1JV0iYULRyPQ5HCbFA==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "13.3.0",
"@vueuse/shared": "13.3.0"
"@vueuse/core": "13.4.0",
"@vueuse/shared": "13.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
@ -3316,6 +3316,44 @@
}
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/core": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.4.0.tgz",
"integrity": "sha512-OnK7zW3bTq/QclEk17+vDFN3tuAm8ONb9zQUIHrYQkkFesu3WeGUx/3YzpEp+ly53IfDAT9rsYXgGW6piNZC5w==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.4.0",
"@vueuse/shared": "13.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.4.0.tgz",
"integrity": "sha512-CPDQ/IgOeWbqItg1c/pS+Ulum63MNbpJ4eecjFJqgD/JUCJ822zLfpw6M9HzSvL6wbzMieOtIAW/H8deQASKHg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/shared": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.4.0.tgz",
"integrity": "sha512-+AxuKbw8R1gYy5T21V5yhadeNM7rJqb4cPaRI9DdGnnNl3uqXh+unvQ3uCaA2DjYLbNr1+l7ht/B4qEsRegX6A==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.3.0.tgz",
@ -6531,6 +6569,20 @@
"license": "MIT",
"optional": true
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -7764,6 +7816,257 @@
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"license": "MPL-2.0",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View file

@ -13,13 +13,17 @@
<script setup>
import {useBrandByUuid} from "~/composables/brands";
import {usePageTitle} from "~/composables/utils/index.js";
const route = useRoute()
const slug = computed(() => route.params.uuid)
const { setPageTitle } = usePageTitle();
const { brand } = await useBrandByUuid(slug.value);
setPageTitle(brand.value?.name ?? 'Brand');
// TODO: add product by this brand
</script>

View file

@ -11,28 +11,28 @@
import {useUserActivation} from "~/composables/user";
import { useRouteQuery } from '@vueuse/router';
const {t} = useI18n()
const appStore = useAppStore()
const route = useRoute()
const {t} = useI18n();
const appStore = useAppStore();
const route = useRoute();
useHead({
title: t('breadcrumbs.home'),
})
const token = useRouteQuery('token', '')
const uid = useRouteQuery('uid', '')
const token = useRouteQuery('token', '');
const uid = useRouteQuery('uid', '');
const { activateUser } = useUserActivation();
onMounted( async () => {
if (route.path.includes('activate-user') && token.value && uid.value) {
await activateUser(token.value, uid.value)
await activateUser(token.value, uid.value);
}
if (route.path.includes('reset-password') && token.value && uid.value) {
appStore.setActiveState('new-password')
appStore.setActiveState('new-password');
}
})
});
</script>
<style lang="scss" scoped>

View file

@ -1,38 +1,537 @@
<template>
<div class="product" v-if="product">
<ui-title>{{ product?.name }}</ui-title>
<div class="container">
<div class="product__wrapper">
<h1 class="product__title">{{ product.name }}</h1>
<div class="product__block">
<div class="product__images">
<div class="product__images-gallery">
<div
v-for="(image, idx) in images"
:key="idx"
@click="selectImage(image)"
:class="[{ active: image === selectedImage }]"
>
<nuxt-img
:src="image"
:alt="product.name"
format="webp"
densities="x1"
/>
</div>
</div>
<nuxt-img
:src="selectedImage"
:alt="product.name"
class="product__images-main"
format="webp"
densities="x1"
/>
</div>
<div class="product__center">
<p class="product__center-description">{{ product.description }}</p>
<p
class="product__center-characteristic"
@click="scrollTo('characteristics')"
>
{{ t('product.characteristics') }}
</p>
</div>
<div class="product__info">
<div class="product__info-inner">
<div class="product__info-top">
<p>{{ t('cards.product.stock') }} {{ product.quantity }}</p>
<nuxt-img
:src="product.brand.smallLogo"
:alt="product.brand.name"
format="webp"
densities="x1"
/>
</div>
<el-rate
class="white"
v-model="rating"
allow-half
disabled
/>
<div class="product__info-price">{{ product.price }}</div>
<div class="product__info-bottom">
<ui-button
class="product__info-button"
>
{{ t('buttons.addToCart') }}
</ui-button>
<div class="product__info-wishlist">
<icon name="mdi:cards-heart-outline" size="28" />
<!-- <icon name="mdi:cards-heart" size="28" />-->
</div>
</div>
</div>
</div>
</div>
</div>
<client-only>
<div class="characteristics" id="characteristics">
<div class="characteristics__wrapper">
<h6 class="characteristics__title">{{ t('product.characteristics') }}</h6>
<div class="characteristics__list">
<div
class="characteristics__column"
v-for="group in attributes"
:key="group.uuid"
>
<h6 class="characteristics__column-title">{{ group.name }}</h6>
<p
class="characteristics__item"
v-for="item in group.items"
:key="item.uuid"
>
<span class="characteristics__item-label"><span>{{ item.name }}</span></span>
<span class="characteristics__item-value">{{ item.valuesStr }}</span>
</p>
</div>
</div>
</div>
</div>
</client-only>
<div class="similar">
<h6 class="similar__title">{{ t('product.similar') }}</h6>
<div class="similar__inner">
<div class="similar__button prev" ref="prevButton">
<icon name="material-symbols:arrow-back-ios-new-rounded" size="30" />
</div>
<swiper
class="similar__swiper"
:modules="[Navigation]"
:spaceBetween="30"
:breakpoints="{
200: {
slidesPerView: 4
}
}"
:navigation="{ prevEl: prevButton, nextEl: nextButton }"
>
<swiper-slide
v-for="prod in products"
:key="prod.node.uuid"
>
<cards-product
:product="prod.node"
/>
</swiper-slide>
</swiper>
<div class="similar__button next" ref="nextButton">
<icon name="material-symbols:arrow-back-ios-new-rounded" size="30" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useProductBySlug} from "~/composables/products";
import {useProductBySlug, useProducts} from "~/composables/products";
import {usePageTitle} from "~/composables/utils";
import {useRouteParams} from "@vueuse/router";
import {useScrollTo} from "~/composables/scrollTo";
import { Swiper, SwiperSlide } from 'swiper/vue';
import 'swiper/css';
import 'swiper/css/navigation';
import {Navigation} from "swiper/modules";
const route = useRoute()
const route = useRoute();
const {t} = useI18n();
const { setPageTitle } = usePageTitle()
const { setPageTitle } = usePageTitle();
const { scrollTo } = useScrollTo();
const slug = route.params.slug as string
const slug = useRouteParams<string>('slug');
const { product } = await useProductBySlug(slug)
setPageTitle(product.value?.name ?? 'Product')
const { product } = await useProductBySlug(slug.value);
const { products, getProducts } = await useProducts();
await getProducts({
categoriesSlugs: product.value?.category.slug
})
const images = computed<string[]>(() =>
product.value
? product.value.images.edges.map(e => e.node.image)
: []
);
const rating = computed(() => {
return product.value?.feedbacks.edges[0]?.node?.rating ?? 3;
});
const attributes = computed(() => {
const edges = product.value?.attributeGroups.edges ?? [];
const mainIndex = edges.findIndex(e => e.node.name === 'Основные характеристики');
const ordered = mainIndex >= 0
? [edges[mainIndex], ...edges.slice(0, mainIndex), ...edges.slice(mainIndex + 1)]
: edges;
return ordered.map(groupEdge => {
const { node } = groupEdge
return {
uuid: node.uuid,
name: node.name,
items: node.attributes.map(attr => ({
uuid: attr.uuid,
name: attr.name,
valuesStr: attr.values.map(v => v.value).join(', ')
}))
};
});
});
const selectedImage = ref<string>(images.value[0] ?? '');
const selectImage = (image: string) => {
selectedImage.value = image;
};
const prevButton = ref<HTMLElement | null>(null);
const nextButton = ref<HTMLElement | null>(null);
setPageTitle(product.value?.name ?? 'Product');
watch(
() => route.params.slug,
async (newSlug) => {
if (typeof newSlug === 'string') {
const { product } = await useProductBySlug(newSlug)
setPageTitle(product.value?.name ?? 'Product')
const { product } = await useProductBySlug(newSlug);
setPageTitle(product.value?.name ?? 'Product');
}
}
)
);
</script>
<style scoped lang="scss">
.product {
margin-top: 25px;
&__title {
padding-bottom: 10px;
border-bottom: 2px solid #5743b5;
color: #5743b5;
font-size: 24px;
font-weight: 600;
}
&__block {
margin-top: 25px;
border-radius: $default_border_radius;
background-color: $white;
padding: 20px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 50px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
}
&__images {
display: flex;
align-items: flex-start;
gap: 20px;
&-gallery {
display: flex;
flex-direction: column;
gap: 10px;
& div {
cursor: pointer;
width: 75px;
height: 75px;
padding: 5px;
border-radius: $default_border_radius;
background-color: $white;
border: 2px solid $white;
transition: 0.2s;
@include hover {
box-shadow: 0 0 10px 1px $accent;
&.active {
box-shadow: 0 0 0 0 transparent;
}
}
&.active {
border: 2px solid $accent;
}
& img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: $default_border_radius;
}
}
}
&-main {
width: 400px;
height: 400px;
border-radius: $default_border_radius;
object-fit: contain;
border: 2px solid $accent;
}
}
&__center {
display: flex;
flex-direction: column;
gap: 25px;
&-description {
color: $accent;
font-size: 16px;
font-weight: 600;
}
&-characteristic {
cursor: pointer;
transition: 0.2s;
color: $accent;
font-size: 16px;
font-weight: 700;
@include hover {
color: $accentDark;
}
}
}
&__info {
width: 400px;
flex-shrink: 0;
border-radius: $default_border_radius;
background-color: $accent;
padding: 5px;
&-inner {
border-radius: $default_border_radius;
border: 2px solid $white;
padding: 25px;
display: flex;
flex-direction: column;
gap: 25px;
}
&-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
& p {
color: $white;
font-size: 16px;
font-weight: 500;
}
& img {
width: 75px;
border-radius: $default_border_radius;
}
}
&-price {
width: fit-content;
background-color: $accentDark;
border-radius: $default_border_radius;
border: 1px solid $white;
padding: 7px 20px;
font-size: 40px;
font-weight: 700;
color: $white;
}
&-bottom {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-shrink: 0;
}
&-button {
width: unset !important;
padding-inline: 25px !important;
background-color: $white !important;
color: $accent !important;
font-size: 20px !important;
@include hover {
background-color: #e3e3e3 !important;
}
}
&-wishlist {
cursor: pointer;
width: 41px;
height: 41px;
flex-shrink: 0;
background-color: $white;
border-radius: $default_border_radius;
display: grid;
place-items: center;
transition: 0.2s;
font-size: 22px;
color: $accent;
@include hover {
background-color: #e3e3e3;
}
}
}
}
.characteristics {
padding-top: 100px;
&__wrapper {
background-color: $white;
border-radius: $default_border_radius;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
padding: 20px;
}
&__title {
padding-bottom: 10px;
border-bottom: 2px solid $accentDark;
color: $black;
font-size: 24px;
font-weight: 700;
}
&__list {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit,minmax(100px, 30%));
gap: 40px;
}
&__column {
display: flex;
flex-direction: column;
gap: 8px;
&-title {
margin-bottom: 5px;
color: $black;
font-weight: 700;
font-size: 16px;
}
}
&__item {
display: grid;
grid-template-columns: repeat(2,1fr);
width: 100%;
gap: 5px;
font-size: 14px;
&-label {
position: relative;
font-weight: 500;
& span {
position: relative;
z-index: 1;
background-color: $white;
padding-bottom: 1px;
}
&::after {
border-bottom: 1px dashed $accent;
content: "";
min-width: 100%;
position: absolute;
right: 0;
top: 1em;
}
}
&-value {
& span {
&:first-child {
& span {
&:first-child {
display: none;
}
}
}
}
}
}
}
.similar {
margin-top: 100px;
background-color: $white;
display: flex;
flex-direction: column;
gap: 25px;
padding: 25px 10px 0 10px;
border-radius: $default_border_radius;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
&__title {
padding-bottom: 10px;
margin-inline: 15px;
border-bottom: 2px solid $accentDark;
color: $black;
font-size: 24px;
font-weight: 700;
}
&__inner {
display: flex;
align-items: stretch;
}
&__swiper {
padding-block: 25px;
padding-inline: 25px;
}
&__button {
cursor: pointer;
height: fit-content;
margin-block: auto;
flex-shrink: 0;
display: grid;
place-items: center;
padding-inline: 10px;
aspect-ratio: 1;
border-radius: 50%;
background-color: $white;
transition: 0.2s;
@include hover {
background-color: $accentLight;
& span {
color: $white;
}
}
& span {
transition: 0.2s;
color: $accent;
}
&.next {
& span {
transform: rotate(180deg);
}
}
}
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<div class="profile">
<div class="container">
<div class="profile__wrapper">
<profile-navigation />
<div class="profile__inner">
<NuxtPage />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.profile {
position: relative;
padding-top: 50px;
height: calc(100vh - 125px);
display: flex;
& .container {
display: flex;
flex: 1;
min-height: 0;
}
&__wrapper {
flex: 1;
display: flex;
align-items: stretch;
gap: 100px;
min-height: 0;
}
&__inner {
flex: 1;
width: 100%;
min-height: 0;
display: flex;
}
}
</style>

View file

@ -0,0 +1,87 @@
<template>
<div class="cart">
<div class="cart__top">
<div class="cart__top-left">
<p><span>{{ t('profile.cart.quantity') }}</span> {{ productsInCartQuantity }}</p>
<p><span>{{ t('profile.cart.total') }}</span> {{ totalPrice }}</p>
</div>
<ui-button class="cart__top-button">{{ t('buttons.checkout') }}</ui-button>
</div>
<div class="cart__list">
<cards-product
v-for="product in productsInCart"
:key="product.node.uuid"
:product="product.node.product"
:isList="true"
/>
</div>
</div>
</template>
<script setup>
import {usePageTitle} from "~/composables/utils/index.js";
const {t} = useI18n();
const cartStore = useCartStore();
const productsInCart = computed(() => {
return cartStore.currentOrder.orderProducts ? cartStore.currentOrder.orderProducts.edges : [];
});
const totalPrice = computed(() => {
return cartStore.currentOrder.totalPrice ? cartStore.currentOrder.totalPrice : [];
});
const productsInCartQuantity = computed(() => {
let count = 0;
cartStore.currentOrder.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
return count;
});
const { setPageTitle } = usePageTitle();
setPageTitle(t('breadcrumbs.cart'));
</script>
<style lang="scss" scoped>
.cart {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
flex: 1;
min-height: 0;
&__top {
width: 100%;
background-color: $white;
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
& p {
font-weight: 600;
}
&-button {
width: fit-content;
padding-inline: 20px;
}
}
&__list {
width: 100%;
padding: 20px;
background-color: $white;
display: flex;
flex-direction: column;
gap: 20px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
flex: 1;
overflow-y: auto;
}
}
</style>

View file

@ -0,0 +1,15 @@
<template>
<div class="settings">
<p>orders</p>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.settings {
}
</style>

View file

@ -0,0 +1,15 @@
<template>
<div class="settings">
<p>settings</p>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.settings {
}
</style>

View file

@ -0,0 +1,175 @@
<template>
<div class="wishlist">
<div class="wishlist__top">
<div class="wishlist__top-left">
<ui-checkbox
id="choose-all"
v-model="allSelected"
:isAccent="true"
>
{{ t('checkboxes.chooseAll') }}
</ui-checkbox>
<p>{{ t('profile.wishlist.total', {quantity: productsInWishlist.length, amount: totalPrice}) }}</p>
</div>
<div
class="wishlist__top-button"
@click="onBulkRemove"
>
<icon name="material-symbols-light:delete-rounded" size="20" />
</div>
</div>
<div class="wishlist__list">
<div class="wishlist__list-inner">
<div
class="wishlist__item"
v-for="product in productsInWishlist"
:key="product.node.uuid"
>
<ui-checkbox
:id="`item-${product.node.uuid}`"
:modelValue="selectedProducts.some(o => o.uuid === product.node.uuid)"
@update:modelValue="checked => toggleUuid(product.node.uuid, checked)"
class="wishlist__item-checkbox"
:isAccent="true"
/>
<cards-product
:product="product.node"
:isList="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {usePageTitle} from "~/composables/utils";
import {useWishlistOverwrite} from "~/composables/wishlist";
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const { overwriteWishlist } = useWishlistOverwrite();
const productsInWishlist = computed(() => {
return wishlistStore.wishlist ? wishlistStore.wishlist.products.edges : [];
});
const totalPrice = computed(() => {
return productsInWishlist.value.reduce((acc, p) => acc + p.node.price, 0);
});
const selectedProducts = ref<{ uuid: string }[]>([]);
const allSelected = computed<boolean>({
get() {
const ids = productsInWishlist.value.map(p => p.node.uuid);
return ids.length > 0
&& ids.every(id => selectedProducts.value.some(o => o.uuid === id));
},
set(val: boolean) {
if (val) {
selectedProducts.value = productsInWishlist.value
.map(p => ({ uuid: p.node.uuid }));
}
else {
selectedProducts.value = [];
}
}
});
function toggleUuid(uuid: string, checked: boolean) {
if (checked) {
if (!selectedProducts.value.some(o => o.uuid === uuid)) {
selectedProducts.value.push({ uuid });
}
}
else {
selectedProducts.value = selectedProducts.value
.filter(o => o.uuid !== uuid);
}
}
function onBulkRemove() {
overwriteWishlist({
type: 'bulk',
bulkAction: 'remove',
products: selectedProducts.value
});
}
const { setPageTitle } = usePageTitle();
setPageTitle(t('breadcrumbs.wishlist'));
</script>
<style lang="scss" scoped>
.wishlist {
display: flex;
flex-direction: column;
gap: 50px;
flex: 1;
min-height: 0;
&__top {
width: 100%;
background-color: $white;
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
&-left {
display: flex;
align-items: center;
gap: 50px;
& p {
font-weight: 600;
}
}
&-button {
cursor: pointer;
width: fit-content;
border-radius: $default_border_radius;
background-color: rgba($error, 0.3);
border: 1px solid $error;
padding: 5px 10px;
display: flex;
align-items: center;
gap: 10px;
transition: 0.2s;
color: $error;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $error;
color: $white;
}
}
}
&__list {
background-color: $white;
padding: 20px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
flex: 1;
overflow-y: auto;
&-inner {
display: flex;
flex-direction: column;
gap: 20px;
}
}
&__item {
position: relative;
display: flex;
align-items: flex-start;
gap: 5px;
}
}
</style>

13
storefront/stores/cart.ts Normal file
View file

@ -0,0 +1,13 @@
import type {IOrder} from "~/types";
export const useCartStore = defineStore('cart', () => {
const currentOrder = ref<IOrder | null>(null);
const setCurrentOrders = (order: IOrder) => {
currentOrder.value = order
};
return {
currentOrder,
setCurrentOrders
}
})

View file

@ -7,3 +7,33 @@ export interface IOrderResponse {
}[]
}
}
export interface IAddToOrderResponse {
addOrderProduct: {
order: IOrder
}
}
export interface IRemoveFromOrderResponse {
removeOrderProduct: {
order: IOrder
}
}
export interface IRemoveKindFromOrderResponse {
removeOrderProductsOfAKind: {
order: IOrder
}
}
export interface IRemoveAllFromOrderResponse {
removeAllOrderProducts: {
order: IOrder
}
}
export interface IBulkOrderResponse {
bulkOrderAction: {
order: IOrder
}
}

View file

@ -7,3 +7,27 @@ export interface IWishlistResponse {
}[]
}
}
export interface IAddToWishlistResponse {
addWishlistProduct: {
wishlist: IWishlist
}
}
export interface IRemoveFromWishlistResponse {
removeWishlistProduct: {
wishlist: IWishlist
}
}
export interface IRemoveAllFromWishlistResponse {
removeAllWishlistProducts: {
wishlist: IWishlist
}
}
export interface IBulkWishlistResponse {
bulkWishlistAction: {
wishlist: IWishlist
}
}

View file

@ -4,6 +4,12 @@ export interface IProduct {
price: number,
quantity: number,
slug: string,
description: string,
brand: {
smallLogo: string,
uuid: string,
name: string
}
category: {
name: string
slug: string,
@ -27,8 +33,8 @@ export interface IProduct {
values: {
value: string,
uuid: string
}
}
}[]
}[]
}
}[]
},

View file

@ -7,6 +7,7 @@ export interface IUser {
firstName: string,
lastName: string,
phoneNumber: string,
dateJoined: string,
balance: {
amount: number,
}