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 {useLanguages} from "~/composables/languages";
import {useCompanyInfo} from "~/composables/company"; import {useCompanyInfo} from "~/composables/company";
import {useCategories} from "~/composables/categories"; import {useCategories} from "~/composables/categories";
import {useNotification} from "~/composables/notification";
const { locale } = useI18n(); const { locale } = useI18n();
const route = useRoute(); const route = useRoute();
@ -32,7 +33,13 @@ const switchLocalePath = useSwitchLocalePath();
const showBreadcrumbs = computed(() => { const showBreadcrumbs = computed(() => {
const name = typeof route.name === 'string' ? route.name : ''; 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); const activeState = computed(() => appStore.activeState);
@ -66,7 +73,7 @@ watch(
{ immediate: true } { immediate: true }
) )
let stopWatcher: () => void; let stopWatcher: VoidFunction = () => {}
onMounted( async () => { onMounted( async () => {
refreshInterval = setInterval(async () => { refreshInterval = setInterval(async () => {

View file

@ -2,4 +2,8 @@
@use "modules/transitions"; @use "modules/transitions";
@use "global/mixins"; @use "global/mixins";
@use "global/variables"; @use "global/variables";
// UI
@use "ui/collapse"; @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-skeleton-to-color: #c3c3c7 !important;
} }
.el-badge__content {
border: none !important;
}
@media (max-width: 1680px) { @media (max-width: 1680px) {
.container { .container {
max-width: 1200px; max-width: 1200px;

View file

@ -36,6 +36,6 @@
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
} }
.el-icon { .el-collapse .el-icon {
display: none !important; 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: add loading state
// TODO: fix displaying main part (children categories)
const blockRef = ref(null) const blockRef = ref(null)
onClickOutside(blockRef, () => setBlock(false)) onClickOutside(blockRef, () => setBlock(false))
@ -148,7 +147,7 @@ const setActiveCategory = (category: ICategory) => {
&__block { &__block {
display: grid; display: grid;
grid-template-columns: 20% 80%; grid-template-columns: 20% 80%;
max-height: 60vh; max-height: 50vh;
} }
&__columns { &__columns {

View file

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

View file

@ -17,11 +17,11 @@
@click="clearSearch" @click="clearSearch"
v-if="query" v-if="query"
> >
<Icon name="gridicons:cross" size="16" /> <icon name="gridicons:cross" size="16" />
</button> </button>
<div class="search__tools-line" v-if="query"></div> <div class="search__tools-line" v-if="query"></div>
<button type="submit"> <button type="submit">
<Icon name="tabler:search" size="16" /> <icon name="tabler:search" size="16" />
</button> </button>
</div> </div>
</form> </form>
@ -29,22 +29,23 @@
<skeletons-header-search v-if="loading" /> <skeletons-header-search v-if="loading" />
<div <div
class="search__results-inner" class="search__results-inner"
v-for="(blocks, item) in filteredSearchResults" v-for="(blocks, category) in filteredSearchResults"
:key="item" :key="category"
> >
<div class="search__results-title"> <div class="search__results-title">
<p>{{ getBlockTitle(item) }}:</p> <p>{{ getBlockTitle(category) }}:</p>
</div> </div>
<div <div
class="search__item" class="search__item"
v-for="item in blocks" v-for="item in blocks"
:key="item.uuid" :key="item.uuid"
@click.stop="goTo(category, item)"
> >
<div class="search__item-left"> <div class="search__item-left">
<Icon name="ic:twotone-search" size="18" /> <icon name="ic:twotone-search" size="18" />
<p>{{ item.name }}</p> <p>{{ item.name }}</p>
</div> </div>
<Icon name="line-md:external-link" size="18" /> <icon name="line-md:external-link" size="18" />
</div> </div>
</div> </div>
<div class="search__results-empty" v-if="!hasResults && query && !loading"> <div class="search__results-empty" v-if="!hasResults && query && !loading">
@ -90,6 +91,29 @@ function submitSearch() {
toggleSearch(false); 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="card" class="card"
:class="{ 'card__list': productView === 'list' }" :class="{ 'card__list': isList }"
> >
<div class="card__wrapper"> <div class="card__wrapper">
<nuxt-link-locale <nuxt-link-locale
@ -10,7 +10,7 @@
> >
<div class="card__block"> <div class="card__block">
<client-only> <client-only>
<Swiper <swiper
v-if="images.length" v-if="images.length"
@swiper="onSwiper" @swiper="onSwiper"
:modules="[EffectFade, Pagination]" :modules="[EffectFade, Pagination]"
@ -19,7 +19,7 @@
:pagination="paginationOptions" :pagination="paginationOptions"
class="card__swiper" class="card__swiper"
> >
<SwiperSlide <swiper-slide
v-for="(img, i) in images" v-for="(img, i) in images"
:key="i" :key="i"
class="card__swiper-slide" class="card__swiper-slide"
@ -29,16 +29,18 @@
:alt="product.name" :alt="product.name"
loading="lazy" loading="lazy"
class="card__swiper-image" class="card__swiper-image"
format="webp"
densities="x1"
/> />
</SwiperSlide> </swiper-slide>
</Swiper> </swiper>
<div class="card__image-placeholder" /> <div class="card__image-placeholder" />
<div <div
v-for="(_, i) in images" v-for="(image, idx) in images"
:key="i" :key="idx"
class="card__block-hover" class="card__block-hover"
:style="{ left: `${(100/ images.length) * i}%`, width: `${100/ images.length}%` }" :style="{ left: `${(100/ images.length) * idx}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(i)" @mouseenter="goTo(idx)"
@mouseleave="goTo(0)" @mouseleave="goTo(0)"
/> />
</client-only> </client-only>
@ -50,19 +52,46 @@
<el-rate <el-rate
v-model="rating" v-model="rating"
size="large" size="large"
allow-half
disabled disabled
/> />
<div class="card__quantity">{{ t('cards.product.stock') }} {{ product.quantity }}</div> <div class="card__quantity">{{ t('cards.product.stock') }} {{ product.quantity }}</div>
</div> </div>
</div> </div>
<div class="card__bottom"> <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') }} {{ t('buttons.addToCart') }}
</ui-button> </ui-button>
<div class="card__bottom-wishlist"> <div
<Icon name="mdi:cards-heart-outline" size="28" /> class="card__bottom-wishlist"
<!-- <Icon name="mdi:cards-heart" size="28" />--> @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> </div>
</div> </div>
@ -70,28 +99,36 @@
<script setup lang="ts"> <script setup lang="ts">
import type {IProduct} from "~/types/app/products"; import type {IProduct} from "~/types/app/products";
import { useAppConfig } from '~/composables/config';
import { Swiper, SwiperSlide } from 'swiper/vue'; import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules'; import { EffectFade, Pagination } from 'swiper/modules';
import 'swiper/css'; import 'swiper/css';
import 'swiper/css/effect-fade'; import 'swiper/css/effect-fade';
import 'swiper/css/pagination' import 'swiper/css/pagination'
import {useWishlistOverwrite} from "~/composables/wishlist";
import {useOrderOverwrite} from "~/composables/orders/useOrderOverwrite";
const props = defineProps<{ const props = defineProps<{
product: IProduct; product: IProduct;
isList?: boolean;
}>(); }>();
const {t} = useI18n(); 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>( const isProductInWishlist = computed(() => {
COOKIES_PRODUCT_VIEW_KEY as string, const el = wishlistStore.wishlist?.products?.edges.find(
{ (el) => el?.node?.uuid === props.product.uuid
default: () => 'grid', );
path: '/',
} return !!el;
) });
const isProductInCart = computed(() => {
return cartStore.currentOrder?.orderProducts?.edges.find((prod) => prod.node.product.uuid === props.product?.uuid);
});
const rating = computed(() => { const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5; return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
@ -171,7 +208,7 @@ function goTo(index: number) {
} }
@include hover { @include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4); box-shadow: 0 0 20px 2px rgba($accentDark, 0.4);
} }
&__wrapper { &__wrapper {
@ -248,6 +285,10 @@ function goTo(index: number) {
} }
&__quantity { &__quantity {
width: fit-content;
background-color: rgba($accent, 0.2);
border-radius: $default_border_radius;
padding: 5px 10px;
font-size: 14px; 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__wrapper" ref="filtersRef">
<div class="filter__top"> <div class="filter__top">
<h2>{{ t('store.filters.title') }}</h2> <h2>{{ t('store.filters.title') }}</h2>
<Icon <icon
name="line-md:close" name="line-md:close"
size="30" size="30"
@click="closeFilters" @click="closeFilters"
@ -19,7 +19,7 @@
<template #title="{ isActive }"> <template #title="{ isActive }">
<div :class="['filter__collapse-title', { 'is-active': isActive }]"> <div :class="['filter__collapse-title', { 'is-active': isActive }]">
{{ attribute.attributeName }} {{ attribute.attributeName }}
<Icon <icon
name="material-symbols:keyboard-arrow-down" name="material-symbols:keyboard-arrow-down"
size="22" size="22"
class="filter__collapse-icon" class="filter__collapse-icon"
@ -31,7 +31,7 @@
:id="attribute.attributeName + '-all'" :id="attribute.attributeName + '-all'"
v-model="selectedAllMap[attribute.attributeName]" v-model="selectedAllMap[attribute.attributeName]"
@change="toggleAll(attribute.attributeName)" @change="toggleAll(attribute.attributeName)"
:isFilter="true" :isAccent="true"
> >
{{ t('store.filters.all') }} {{ t('store.filters.all') }}
</ui-checkbox> </ui-checkbox>
@ -40,7 +40,7 @@
:key="idx" :key="idx"
:id="attribute.attributeName + idx" :id="attribute.attributeName + idx"
v-model="selectedMap[attribute.attributeName][value]" v-model="selectedMap[attribute.attributeName][value]"
:isFilter="true" :isAccent="true"
> >
{{ value }} {{ value }}
</ui-checkbox> </ui-checkbox>
@ -67,6 +67,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type {IStoreFilters} from "~/types"; import type {IStoreFilters} from "~/types";
import {useFilters} from "~/composables/store"; import {useFilters} from "~/composables/store";
import {useRouteQuery} from "@vueuse/router";
const appStore = useAppStore(); const appStore = useAppStore();
const { t } = useI18n(); const { t } = useI18n();
@ -81,7 +82,17 @@ const emit = defineEmits<{
(e: 'close'): void; (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') toRef(props, 'filterableAttributes')
); );
@ -110,6 +121,27 @@ watch(
}, },
{ immediate: true } { 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
export * from './useLocaleRedirect' export * from './useLocaleRedirect';
export * from './useLanguage' export * from './useLanguage';
export * from './useLanguageSwitch' 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 './useProducts';
export * from './useProductBySlug' export * from './useProductBySlug';
export * from './useProductTags' export * from './useProductTags';

View file

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

View file

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

View file

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

View file

@ -79,6 +79,29 @@ export function useFilters(filterableAttributes: Ref<IStoreFilters[]>) {
.join(';'); .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 { return {
selectedMap, selectedMap,
selectedAllMap, selectedAllMap,
@ -86,6 +109,7 @@ export function useFilters(filterableAttributes: Ref<IStoreFilters[]>) {
toggleAll, toggleAll,
resetFilters, resetFilters,
applyFilters, applyFilters,
buildAttributesString buildAttributesString,
parseAttributesString
}; };
} }

View file

@ -19,7 +19,7 @@ export async function useStore(
maxPrice?: number, maxPrice?: number,
productAfter?: string productAfter?: string
) { ) {
const prodVars = reactive<ProdVars>({ const variables = reactive<ProdVars>({
first: 15, first: 15,
categoriesSlugs: slug, categoriesSlugs: slug,
attributes, attributes,
@ -29,7 +29,10 @@ export async function useStore(
productAfter 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 products = ref(data.value?.products.edges ?? []);
const pageInfo = computed(() => data.value?.products.pageInfo ?? null); 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(error, e => e && console.error('useStore products error', e));
watch( watch(
() => prodVars.productAfter, () => variables.productAfter,
async (newCursor, oldCursor) => { async (newCursor, oldCursor) => {
if (!newCursor || newCursor === oldCursor) return; if (!newCursor || newCursor === oldCursor) return;
await refresh(); await refresh();
@ -48,13 +51,13 @@ export async function useStore(
watch( watch(
[ [
() => prodVars.attributes, () => variables.attributes,
() => prodVars.orderBy, () => variables.orderBy,
() => prodVars.minPrice, () => variables.minPrice,
() => prodVars.maxPrice () => variables.maxPrice
], ],
async () => { async () => {
prodVars.productAfter = ''; variables.productAfter = '';
await refresh(); await refresh();
products.value = data.value?.products.edges ?? []; products.value = data.value?.products.edges ?? [];
} }
@ -64,6 +67,6 @@ export async function useStore(
pending, pending,
products, products,
pageInfo, 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 {ACTIVATE_USER} from "@/graphql/mutations/user.js";
import {isGraphQLError} from "~/utils/error"; import {isGraphQLError} from "~/utils/error";
import type {IUserActivationResponse} from "~/types"; import type {IUserActivationResponse} from "~/types";
import {useNotification} from "~/composables/notification";
export function useUserActivation() { export function useUserActivation() {
const {t} = useI18n(); const {t} = useI18n();
@ -14,10 +15,10 @@ export function useUserActivation() {
const result = await mutate({ token, uid }); const result = await mutate({ token, uid });
if (result?.data?.activateUser) { if (result?.data?.activateUser) {
ElNotification({ useNotification(
message: t("popup.activationSuccess"), t("popup.activationSuccess"),
type: "success" 'success'
}); );
} }
} }
@ -30,11 +31,11 @@ export function useUserActivation() {
} else { } else {
message = err.message; message = err.message;
} }
ElNotification({ useNotification(
title: t('popup.errors.main'), message,
message, 'error',
type: 'error' t('popup.errors.main')
}); );
}); });
return { return {

View file

@ -1,2 +1,2 @@
export * from './useMailClient' export * from './useMailClient';
export * from './usePageTitle' 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 price
quantity quantity
slug slug
description
brand {
smallLogo
uuid
name
}
category { category {
name name
slug
uuid
} }
images { images {
edges { edges {

View file

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

View file

@ -2,8 +2,8 @@ import {ORDER_FRAGMENT} from "@/graphql/fragments/orders.fragment.js";
export const ADD_TO_CART = gql` export const ADD_TO_CART = gql`
mutation addToCart( mutation addToCart(
$orderUuid: String!, $orderUuid: UUID!,
$productUuid: String! $productUuid: UUID!
) { ) {
addOrderProduct( addOrderProduct(
orderUuid: $orderUuid, orderUuid: $orderUuid,
@ -19,8 +19,8 @@ export const ADD_TO_CART = gql`
export const REMOVE_FROM_CART = gql` export const REMOVE_FROM_CART = gql`
mutation removeFromCart( mutation removeFromCart(
$orderUuid: String!, $orderUuid: UUID!,
$productUuid: String! $productUuid: UUID!
) { ) {
removeOrderProduct( removeOrderProduct(
orderUuid: $orderUuid, orderUuid: $orderUuid,
@ -36,8 +36,8 @@ export const REMOVE_FROM_CART = gql`
export const REMOVE_KIND_FROM_CART = gql` export const REMOVE_KIND_FROM_CART = gql`
mutation removeKindFromCart( mutation removeKindFromCart(
$orderUuid: String!, $orderUuid: UUID!,
$productUuid: String! $productUuid: UUID!
) { ) {
removeOrderProductsOfAKind( removeOrderProductsOfAKind(
orderUuid: $orderUuid, orderUuid: $orderUuid,
@ -53,7 +53,7 @@ export const REMOVE_KIND_FROM_CART = gql`
export const REMOVE_ALL_FROM_CART = gql` export const REMOVE_ALL_FROM_CART = gql`
mutation removeAllFromCart( mutation removeAllFromCart(
$orderUuid: String! $orderUuid: UUID!
) { ) {
removeAllOrderProducts( removeAllOrderProducts(
orderUuid: $orderUuid orderUuid: $orderUuid
@ -65,3 +65,22 @@ export const REMOVE_ALL_FROM_CART = gql`
} }
${ORDER_FRAGMENT} ${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` export const SWITCH_LANGUAGE = gql`
mutation setlanguage( mutation setlanguage(
$uuid: UUID!, $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` export const ADD_TO_WISHLIST = gql`
mutation addToWishlist( mutation addToWishlist(
$wishlistUuid: String!, $wishlistUuid: UUID!,
$productUuid: String! $productUuid: UUID!
) { ) {
addWishlistProduct( addWishlistProduct(
wishlistUuid: $wishlistUuid, wishlistUuid: $wishlistUuid,
@ -19,8 +19,8 @@ export const ADD_TO_WISHLIST = gql`
export const REMOVE_FROM_WISHLIST = gql` export const REMOVE_FROM_WISHLIST = gql`
mutation removeFromWishlist( mutation removeFromWishlist(
$wishlistUuid: String!, $wishlistUuid: UUID!,
$productUuid: String! $productUuid: UUID!
) { ) {
removeWishlistProduct( removeWishlistProduct(
wishlistUuid: $wishlistUuid, wishlistUuid: $wishlistUuid,
@ -36,7 +36,7 @@ export const REMOVE_FROM_WISHLIST = gql`
export const REMOVE_ALL_FROM_WISHLIST = gql` export const REMOVE_ALL_FROM_WISHLIST = gql`
mutation removeAllFromWishlist( mutation removeAllFromWishlist(
$wishlistUuid: String! $wishlistUuid: UUID!
) { ) {
removeAllWishlistProducts( removeAllWishlistProducts(
wishlistUuid: $wishlistUuid wishlistUuid: $wishlistUuid
@ -48,3 +48,22 @@ export const REMOVE_ALL_FROM_WISHLIST = gql`
} }
${WISHLIST_FRAGMENT} ${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` export const GET_BRAND_BY_UUID = gql`
query getBrandbyUuid( query getBrandbyUuid(
$uuid: String! $uuid: UUID!
) { ) {
brands( brands(
uuid: $uuid uuid: $uuid

View file

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

View file

@ -2,11 +2,12 @@
"buttons": { "buttons": {
"login": "Login", "login": "Login",
"register": "Register", "register": "Register",
"addToCart": "Add To Cart", "addToCart": "Add to cart",
"removeFromCart": "Remove from cart",
"send": "Send", "send": "Send",
"goEmail": "Take me to my inbox", "goEmail": "Take me to my inbox",
"logout": "Log Out", "logout": "Log Out",
"buy": "Buy Now", "checkout": "Checkout",
"save": "Save", "save": "Save",
"sendLink": "Send link", "sendLink": "Send link",
"topUp": "Top up" "topUp": "Top up"
@ -37,7 +38,8 @@
"confirmNewPassword": "Confirm new password" "confirmNewPassword": "Confirm new password"
}, },
"checkboxes": { "checkboxes": {
"remember": "Remember me" "remember": "Remember me",
"chooseAll": "Choose all"
}, },
"popup": { "popup": {
"errors": { "errors": {
@ -51,17 +53,21 @@
"confirmEmail": "Verification E-mail link successfully sent!", "confirmEmail": "Verification E-mail link successfully sent!",
"reset": "If specified email exists in our system, we will send a password recovery email!", "reset": "If specified email exists in our system, we will send a password recovery email!",
"newPassword": "You have successfully changed your password!", "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!"
}, },
"addToCart": "{product} has been added to the cart!",
"addToCartLimit": "Total quantity limit is {quantity}!", "addToCartLimit": "Total quantity limit is {quantity}!",
"failAdd": "Please log in to make a purchase", "failAdd": "Please log in to make a purchase",
"activationSuccess": "E-mail successfully verified. Please Sign In!", "activationSuccess": "E-mail successfully verified. Please Sign In!",
"successUpdate": "Profile successfully updated!", "successUpdate": "Profile successfully updated!",
"payment": "Your purchase is being processed! Please stand by", "payment": "Your purchase is being processed! Please stand by",
"successCheckout": "Order purchase successful!", "successCheckout": "Order purchase successful!"
"addToWishlist": "{product} has been added to the wishlist!"
}, },
"header": { "header": {
"actions": { "actions": {
@ -113,7 +119,10 @@
}, },
"breadcrumbs": { "breadcrumbs": {
"home": "Home", "home": "Home",
"catalog": "Catalog" "catalog": "Catalog",
"contact": "Contact",
"wishlist": "Wishlist",
"cart": "Cart"
}, },
"contact": { "contact": {
"title": "Contact us" "title": "Contact us"
@ -123,7 +132,8 @@
"filters": { "filters": {
"title": "Filters", "title": "Filters",
"apply": "Apply", "apply": "Apply",
"reset": "Reset" "reset": "Reset",
"all": "All"
} }
}, },
"search": { "search": {
@ -131,5 +141,26 @@
"categories": "Categories", "categories": "Categories",
"brands": "Brands", "brands": "Brands",
"byRequest": "by request" "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', authType: 'Bearer',
authHeader: 'X-EVIBES-AUTH', authHeader: 'X-EVIBES-AUTH',
tokenStorage: 'cookie', 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" "license": "MIT"
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.1", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.9" "@floating-ui/utils": "^0.2.10"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.7.1", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.1", "@floating-ui/core": "^1.7.2",
"@floating-ui/utils": "^0.2.9" "@floating-ui/utils": "^0.2.10"
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.9", "version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -969,9 +969,9 @@
} }
}, },
"node_modules/@iconify/collections": { "node_modules/@iconify/collections": {
"version": "1.0.557", "version": "1.0.563",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.557.tgz", "resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.563.tgz",
"integrity": "sha512-283MvzQA7NgWkjt2AiMURoyW2hUWu0C4c0PjgAOXiQ4lEVQlwIYP8a9FR3xSECfc+EAMq2+M+1kbUlq09doRtw==", "integrity": "sha512-Y5GNkqMQW4RfF5nG7GN9zht2IexXpygFb01T1+xvm/hvEBgn7CDnxO2v3D586VhcA5N93iEBpLVGyV4gaHnZOA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iconify/types": "*" "@iconify/types": "*"
@ -1765,17 +1765,17 @@
} }
}, },
"node_modules/@nuxt/icon": { "node_modules/@nuxt/icon": {
"version": "1.13.0", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-1.13.0.tgz", "resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-1.14.0.tgz",
"integrity": "sha512-Sft1DZj/U5Up60DMnhp+hRDNDXRkMhwHocxgDkDkAPBxqR8PRyvzxcMIy3rjGMu0s+fB6ZdLs6vtaWzjWuerQQ==", "integrity": "sha512-4kb2rbvbSll784LUme2fDm62NW0Tryr8wADFEU3vIoOj4TZywcwPafIl0MT6ah3RNgbPd174EFVOaUdPSUQENA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iconify/collections": "^1.0.548", "@iconify/collections": "^1.0.560",
"@iconify/types": "^2.0.0", "@iconify/types": "^2.0.0",
"@iconify/utils": "^2.3.0", "@iconify/utils": "^2.3.0",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^2.4.1", "@nuxt/devtools-kit": "^2.5.0",
"@nuxt/kit": "^3.17.3", "@nuxt/kit": "^3.17.5",
"consola": "^3.4.2", "consola": "^3.4.2",
"local-pkg": "^1.1.1", "local-pkg": "^1.1.1",
"mlly": "^1.7.4", "mlly": "^1.7.4",
@ -1783,7 +1783,7 @@
"pathe": "^2.0.3", "pathe": "^2.0.3",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
"std-env": "^3.9.0", "std-env": "^3.9.0",
"tinyglobby": "^0.2.13" "tinyglobby": "^0.2.14"
} }
}, },
"node_modules/@nuxt/image": { "node_modules/@nuxt/image": {
@ -3251,13 +3251,13 @@
} }
}, },
"node_modules/@vueuse/integrations": { "node_modules/@vueuse/integrations": {
"version": "13.3.0", "version": "13.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.3.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.4.0.tgz",
"integrity": "sha512-h5mGRYPbiTZTFP/AKELLPGnUDBly7z7Qd1pgEQlT3ItQ0NlZM0vB+8SOQycpSBOBlgg72Zgw+mi2r+4O/G8RuQ==", "integrity": "sha512-rwNoE0MNJBUuSzTZcUVrkovtHvpWIySOcC6XpcS33ZarHDNhd9CPvCD4eNl3N0Phz1he1JV0iYULRyPQ5HCbFA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vueuse/core": "13.3.0", "@vueuse/core": "13.4.0",
"@vueuse/shared": "13.3.0" "@vueuse/shared": "13.4.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "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": { "node_modules/@vueuse/metadata": {
"version": "13.3.0", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.3.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.3.0.tgz",
@ -6531,6 +6569,20 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -7764,6 +7816,257 @@
"node": ">= 0.8.0" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View file

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

View file

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

View file

@ -1,38 +1,537 @@
<template> <template>
<div class="product" v-if="product"> <div class="product" v-if="product">
<ui-title>{{ product?.name }}</ui-title>
<div class="container"> <div class="container">
<div class="product__wrapper"> <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> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useProductBySlug} from "~/composables/products"; import {useProductBySlug, useProducts} from "~/composables/products";
import {usePageTitle} from "~/composables/utils"; 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) const { product } = await useProductBySlug(slug.value);
setPageTitle(product.value?.name ?? 'Product') 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( watch(
() => route.params.slug, () => route.params.slug,
async (newSlug) => { async (newSlug) => {
if (typeof newSlug === 'string') { if (typeof newSlug === 'string') {
const { product } = await useProductBySlug(newSlug) const { product } = await useProductBySlug(newSlug);
setPageTitle(product.value?.name ?? 'Product') setPageTitle(product.value?.name ?? 'Product');
} }
} }
) );
</script> </script>
<style scoped lang="scss"> <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> </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, price: number,
quantity: number, quantity: number,
slug: string, slug: string,
description: string,
brand: {
smallLogo: string,
uuid: string,
name: string
}
category: { category: {
name: string name: string
slug: string, slug: string,
@ -27,8 +33,8 @@ export interface IProduct {
values: { values: {
value: string, value: string,
uuid: string uuid: string
} }[]
} }[]
} }
}[] }[]
}, },

View file

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