Features: 1) Build standalone pages for search, contact, catalog, category, brand, product, and home with localized metadata and scoped styles; 2) Add extensive TypeScript definitions for API and app-level structures, including products, orders, brands, and categories; 3) Implement i18n configuration with dynamic browser language detection and fallback system;

Fixes: None;

Extra: 1) Create Pinia stores for app, user, category, and company management; 2) Add utility functions for error handling and category slug lookups; 3) Include German locale file and robots.txt for improved SEO and accessibility; 4) Add SVG assets and improve general folder structure for better maintainability.
This commit is contained in:
Alexandr SaVBaD Waltz 2025-06-27 00:10:35 +03:00
parent 426af1ad2c
commit 129ad1a6fa
113 changed files with 4856 additions and 0 deletions

75
storefront/README.md Normal file
View file

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

124
storefront/app.vue Normal file
View file

@ -0,0 +1,124 @@
<template>
<div class="main">
<nuxt-loading-indicator color="#7965d1" />
<base-header />
<ui-breadcrumbs v-if="showBreadcrumbs" />
<transition name="opacity" mode="out-in">
<base-auth v-if="activeState">
<forms-login v-if="appStore.isLogin" />
<forms-register v-if="appStore.isRegister" />
<forms-reset-password v-if="appStore.isForgot" />
<forms-new-password v-if="appStore.isReset" />
</base-auth>
</transition>
<nuxt-page />
<base-footer />
</div>
</template>
<script lang="ts" setup>
import {useAppConfig} from "~/composables/config";
import { DEFAULT_LOCALE } from '~/config/constants';
import {useRefresh} from "~/composables/auth";
import {useLanguages} from "~/composables/languages";
import {useCompanyInfo} from "~/composables/company";
import {useCategories} from "~/composables/categories";
const { locale } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const switchLocalePath = useSwitchLocalePath();
const showBreadcrumbs = computed(() => {
const name = typeof route.name === 'string' ? route.name : '';
return !['index', 'brand', 'search'].some(prefix => name.startsWith(prefix));
});
const activeState = computed(() => appStore.activeState);
const { COOKIES_LOCALE_KEY } = useAppConfig();
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const { refresh } = useRefresh();
const { getCategories } = await useCategories();
let refreshInterval: NodeJS.Timeout;
await Promise.all([
refresh(),
useLanguages(),
useCompanyInfo(),
getCategories()
]);
watch(
() => appStore.activeState,
(state) => {
appStore.setOverflowHidden(state !== '')
},
{ immediate: true }
)
let stopWatcher: () => void;
onMounted( async () => {
refreshInterval = setInterval(async () => {
await refresh();
}, 600000);
if (!cookieLocale.value) {
cookieLocale.value = DEFAULT_LOCALE;
await router.push({path: switchLocalePath(cookieLocale.value)});
}
if (locale.value !== cookieLocale.value) {
await router.push({path: switchLocalePath(cookieLocale.value)});
}
stopWatcher = watch(
() => appStore.isOverflowHidden,
(hidden) => {
const root = document.documentElement;
const body = document.body;
if (hidden) {
root.classList.add('lock-scroll');
body.classList.add('lock-scroll');
} else {
root.classList.remove('lock-scroll');
body.classList.remove('lock-scroll');
}
},
{ immediate: true }
);
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
onBeforeUnmount(() => {
stopWatcher()
document.documentElement.classList.remove('lock-scroll');
document.body.classList.remove('lock-scroll');
});
</script>
<style lang="scss">
.main {
padding-top: 90px;
background-color: $light;
}
.lock-scroll {
overflow: hidden !important;
}
</style>

View file

@ -0,0 +1,41 @@
@use "../global/variables" as *;
.el-collapse {
border: none !important;
display: flex;
flex-direction: column;
gap: 20px;
padding-block: 20px
}
.el-collapse-item {
border-radius: $default_border_radius;
border: 1px solid $accentDark;
background-color: rgba($accent, 0.2);
box-shadow: 0 0 10px 1px rgba(0, 0, 0, 0.3);
}
.el-collapse-item__header {
background-color: transparent !important;
border-bottom: none !important;
line-height: 100% !important;
font-size: 14px !important;
font-weight: 600 !important;
padding-inline: 8px !important;
color: $accentDark !important;
}
.el-collapse-item__header.focusing:focus:not(:hover) {
color: $accentDark !important;
}
.el-collapse-item__wrap {
border-top: 2px solid $accentDark;
border-bottom: none !important;
background-color: transparent !important;
}
.el-collapse-item__content {
padding: 10px !important;
display: flex;
flex-direction: column;
gap: 5px;
}
.el-icon {
display: none !important;
}

View file

@ -0,0 +1,104 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer__wrapper">
<div class="footer__column">
<nuxt-link-locale to="/">
<nuxt-img
format="webp"
width="150px"
densities="x1"
src="/images/evibes-big-simple-white.png"
alt="logo"
loading="lazy"
class="header__logo"
/>
</nuxt-link-locale>
<p>{{ t('footer.address') }} <a :href="`https://www.google.com/maps/search/?api=1&query=${encodedCompanyAddress}`" target="_blank" rel="noopener noreferrer">{{ companyInfo?.companyAddress }}</a></p>
<p>{{ t('footer.email') }} <a :href="'mailto:' + companyInfo?.emailFrom">{{ companyInfo?.emailFrom }}</a></p>
<p>{{ t('footer.phone') }} <a :href="'tel:' + companyInfo?.companyPhoneNumber">{{ companyInfo?.companyPhoneNumber }}</a></p>
</div>
<div class="footer__column">
<nuxt-link-locale class="footer__link" to="/contact">{{ t('contact.title') }}</nuxt-link-locale>
</div>
</div>
</div>
<div class="footer__bottom">
<p>©2025 {{ companyInfo?.companyName }}. All Rights Reserved</p>
</div>
</footer>
</template>
<script setup lang="ts">
const companyStore = useCompanyStore()
const { t } = useI18n()
const companyInfo = computed(() => companyStore.companyInfo)
const encodedCompanyAddress = computed(() => {
return companyInfo.value?.companyAddress ? encodeURIComponent(companyInfo.value?.companyAddress) : ''
})
</script>
<style scoped lang="scss">
.footer {
margin-top: 100px;
background-color: $accentDark;
&__bottom {
background-color: $accent;
padding-block: 10px;
& p {
text-align: center;
font-size: 12px;
font-weight: 500;
color: $white;
}
}
&__wrapper {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-block: 35px;
}
&__column {
display: flex;
flex-direction: column;
gap: 10px;
& p {
font-weight: 600;
font-size: 14px;
color: $white;
& span {
font-weight: 400;
}
& a {
transition: 0.2s;
font-weight: 400;
color: $white;
@include hover {
color: #d9d9d9;
}
}
}
}
&__link {
transition: 0.2s;
font-weight: 500;
font-size: 16px;
color: $white;
@include hover {
color: #d9d9d9;
}
}
}
</style>

View file

@ -0,0 +1,272 @@
<template>
<div class="catalog" ref="blockRef">
<button
@click="setBlock(!isBlockOpen)"
class="catalog__button"
:class="[{ active: isBlockOpen }]"
>
{{ t('header.catalog.title') }}
<span></span>
</button>
<div class="container">
<div class="categories" :class="[{active: isBlockOpen}]">
<div class="categories__block" v-if="categories.length > 0">
<div class="categories__left">
<p
v-for="category in categories"
:key="category.node.uuid"
:class="[{ active: category.node.uuid === activeCategory.uuid }]"
@click="setActiveCategory( category.node)"
>
{{ category.node.name }}
</p>
</div>
<div class="categories__main">
<div
class="categories__main-block"
v-for="mainChildren in activeCategory.children"
:key="mainChildren.uuid"
>
<nuxt-link-locale
:to="`/catalog/${mainChildren.slug}`"
class="categories__main-link"
@click="setBlock(false)"
>
{{ mainChildren.name }}
</nuxt-link-locale>
<div class="categories__main-list">
<nuxt-link-locale
v-for="children in mainChildren.children"
:key="children.uuid"
:to="`/catalog/${children.slug}`"
@click="setBlock(false)"
>
{{ children.name }}
</nuxt-link-locale >
</div>
</div>
</div>
</div>
<div class="categories__empty" v-else><p>{{ t('header.catalog.empty') }}</p></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from "vue";
import {onClickOutside} from "@vueuse/core";
import type {ICategory} from "~/types";
import {useCategoryStore} from "~/stores/category";
const { t } = useI18n()
const categoryStore = useCategoryStore();
const categories = computed(() => categoryStore.categories)
const isBlockOpen = ref<boolean>(false)
const setBlock = (state: boolean) => {
isBlockOpen.value = state
}
// TODO: add loading state
// TODO: fix displaying main part (children categories)
const blockRef = ref(null)
onClickOutside(blockRef, () => setBlock(false))
const activeCategory = ref<ICategory>(categories.value[0]?.node)
const setActiveCategory = (category: ICategory) => {
activeCategory.value = category
}
</script>
<style lang="scss" scoped>
.catalog {
&__button {
cursor: pointer;
border-radius: $default_border_radius;
background-color: rgba($accent, 0.2);
border: 1px solid $accent;
padding: 5px 20px;
display: flex;
align-items: center;
gap: 10px;
transition: 0.2s;
color: $accent;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $accent;
color: $white;
}
& span {
transition: 0.2s;
font-size: 26px;
}
&.active {
background-color: $accent;
color: $white;
& span {
transform: rotate(-180deg);
}
}
}
}
.container {
position: absolute;
left: 50%;
top: 110%;
transform: translateX(-50%);
width: 100%;
}
.categories {
border-radius: $default_border_radius;
width: 100%;
background-color: $white;
box-shadow: 0 0 15px 1px $accentLight;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
&__block {
display: grid;
grid-template-columns: 20% 80%;
max-height: 60vh;
}
&__columns {
& div {
padding: 20px 50px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-column-gap: 30px;
grid-row-gap: 5px;
& p {
cursor: pointer;
padding: 5px 20px;
transition: 0.2s;
font-size: 16px;
font-weight: 600;
@include hover {
color: $accent;
}
}
}
}
&__left {
flex-shrink: 0;
padding-block: 10px;
overflow: auto;
& p {
cursor: pointer;
transition: 0.2s;
flex-shrink: 0;
padding: 10px;
border-left: 3px solid $white;
font-weight: 700;
@include hover {
color: $accent;
}
&.active {
border-color: $accent;
color: $accent;
}
}
}
&__main {
padding: 20px;
border-left: 2px solid $accentDark;
overflow: auto;
&-block {
padding-bottom: 15px;
border-bottom: 1px solid #eeeeee;
margin-bottom: 15px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
}
&-link {
position: relative;
width: fit-content;
cursor: pointer;
font-weight: 600;
font-size: 16px;
&::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: $accent;
}
@include hover {
&::after {
width: 100%;
}
}
}
&-list {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3,1fr);
grid-column-gap: 30px;
grid-row-gap: 5px;
& a {
cursor: pointer;
transition: 0.1s;
font-size: 14px;
@include hover {
color: $accent;
}
}
}
}
&__empty {
& p {
padding: 20px;
text-align: center;
font-size: 16px;
font-weight: 600;
}
}
}
</style>

View file

@ -0,0 +1,127 @@
<template>
<header class="header">
<nuxt-link-locale to="/">
<nuxt-img
format="webp"
width="150px"
densities="x1"
src="/images/evibes-big-simple.png"
alt="logo"
class="header__logo"
/>
</nuxt-link-locale>
<base-header-catalog />
<base-header-search />
<div class="header__actions">
<nuxt-link-locale to="/wishlist" class="header__actions-item">
<div>
<!-- <ui-counter>0</ui-counter>-->
<!-- <skeletons-ui-counter />-->
<Icon name="mdi:cards-heart-outline" size="28" />
</div>
<p>{{ t('header.actions.wishlist') }}</p>
</nuxt-link-locale>
<nuxt-link-locale to="/cart" class="header__actions-item">
<div>
<!-- <ui-counter>0</ui-counter>-->
<!-- <skeletons-ui-counter />-->
<Icon name="ph:shopping-cart-light" size="28" />
</div>
<p>{{ t('header.actions.cart') }}</p>
</nuxt-link-locale>
<client-only>
<nuxt-link-locale
to="/"
class="header__actions-item"
v-if="isAuthenticated"
>
<Icon name="material-symbols-light:person-outline-rounded" size="32" />
<p @click="logout">{{ t('header.actions.profile') }}</p>
</nuxt-link-locale>
<div
class="header__actions-item"
@click="appStore.setActiveState('login')"
v-else
>
<Icon name="material-symbols-light:person-outline-rounded" size="32" />
<p>{{ t('header.actions.login') }}</p>
</div>
<template #fallback>
<div
class="header__actions-item"
@click="appStore.setActiveState('login')"
>
<Icon name="material-symbols-light:person-outline-rounded" size="32" />
<p>{{ t('header.actions.login') }}</p>
</div>
</template>
</client-only>
</div>
<ui-language-switcher />
</header>
</template>
<script setup lang="ts">
import {useLogout} from "~/composables/auth";
const { t } = useI18n()
const appStore = useAppStore()
const userStore = useUserStore();
const isAuthenticated = computed(() => userStore.isAuthenticated)
const { logout } = useLogout()
</script>
<style lang="scss" scoped>
.header {
box-shadow: 0 1px 2px #0000001a;
position: fixed;
z-index: 2;
top: 0;
left: 0;
width: 100vw;
background-color: $white;
display: flex;
align-items: center;
justify-content: space-between;
gap: 50px;
padding: 10px 25px;
&__actions {
display: flex;
align-items: center;
gap: 15px;
&-item {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 7px 10px;
border-radius: $default_border_radius;
transition: 0.2s;
@include hover {
background-color: #f7f7f7;
color: $accent;
}
& div {
position: relative;
}
& i {
transition: 0.2s;
font-size: 24px;
}
& p {
transition: 0.2s;
font-size: 12px;
}
}
}
}
</style>

View file

@ -0,0 +1,67 @@
<template>
<nuxt-link-locale
class="card"
:to="`/catalog/${brand.uuid}`"
>
<nuxt-img
v-if="brand.smallLogo"
format="webp"
densities="x1"
:src="brand.smallLogo"
:alt="brand.name"
class="card__image"
loading="lazy"
/>
<div class="card__image-placeholder" v-else />
<p>{{ brand.name }}</p>
</nuxt-link-locale>
</template>
<script setup lang="ts">
import type {IBrand} from "~/types";
const props = defineProps<{
brand: IBrand;
}>();
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
background-color: $white;
border-radius: $default_border_radius;
border: 2px solid $accentDark;
height: 100%;
padding: 20px;
transition: 0.2s;
@include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4);
}
&__image {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
object-position: center;
&-placeholder {
width: 100%;
aspect-ratio: 1;
background-color: $accentLight;
border-radius: $default_border_radius;
}
}
& p {
text-align: center;
font-size: 18px;
font-weight: 600;
}
}
</style>

View file

@ -0,0 +1,67 @@
<template>
<nuxt-link-locale
class="card"
:to="`/catalog/${category.slug}`"
>
<nuxt-img
v-if="category.image"
format="webp"
densities="x1"
:src="category.image"
:alt="category.name"
class="card__image"
loading="lazy"
/>
<div class="card__image-placeholder" v-else />
<p>{{ category.name }}</p>
</nuxt-link-locale>
</template>
<script setup lang="ts">
import type {ICategory} from "~/types/index.js";
const props = defineProps<{
category: ICategory;
}>();
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
background-color: $white;
border-radius: $default_border_radius;
border: 2px solid $accentDark;
height: 100%;
padding: 20px;
transition: 0.2s;
@include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4);
}
&__image {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
object-position: center;
&-placeholder {
width: 100%;
aspect-ratio: 1;
background-color: $accentLight;
border-radius: $default_border_radius;
}
}
& p {
text-align: center;
font-size: 18px;
font-weight: 600;
}
}
</style>

View file

@ -0,0 +1,308 @@
<template>
<div
class="card"
:class="{ 'card__list': productView === 'list' }"
>
<div class="card__wrapper">
<nuxt-link-locale
:to="`/product/${product.slug}`"
class="card__link"
>
<div class="card__block">
<client-only>
<Swiper
v-if="images.length"
@swiper="onSwiper"
:modules="[EffectFade, Pagination]"
effect="fade"
:slides-per-view="1"
:pagination="paginationOptions"
class="card__swiper"
>
<SwiperSlide
v-for="(img, i) in images"
:key="i"
class="card__swiper-slide"
>
<nuxt-img
:src="img"
:alt="product.name"
loading="lazy"
class="card__swiper-image"
/>
</SwiperSlide>
</Swiper>
<div class="card__image-placeholder" />
<div
v-for="(_, i) in images"
:key="i"
class="card__block-hover"
:style="{ left: `${(100/ images.length) * i}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(i)"
@mouseleave="goTo(0)"
/>
</client-only>
</div>
</nuxt-link-locale>
<div class="card__content">
<div class="card__price">{{ product.price }}</div>
<p class="card__name">{{ product.name }}</p>
<el-rate
v-model="rating"
size="large"
allow-half
disabled
/>
<div class="card__quantity">{{ t('cards.product.stock') }} {{ product.quantity }}</div>
</div>
</div>
<div class="card__bottom">
<ui-button class="card__bottom-button">
{{ t('buttons.addToCart') }}
</ui-button>
<div class="card__bottom-wishlist">
<Icon name="mdi:cards-heart-outline" size="28" />
<!-- <Icon name="mdi:cards-heart" size="28" />-->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IProduct} from "~/types/app/products";
import { useAppConfig } from '~/composables/config';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-fade';
import 'swiper/css/pagination'
const props = defineProps<{
product: IProduct;
}>();
const {t} = useI18n();
const { COOKIES_PRODUCT_VIEW_KEY } = useAppConfig()
const productView = useCookie<string>(
COOKIES_PRODUCT_VIEW_KEY as string,
{
default: () => 'grid',
path: '/',
}
)
const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
});
const images = computed(() =>
props.product.images.edges.map(e => e.node.image)
);
const paginationOptions = computed(() =>
images.value.length > 1
? {
clickable: true,
bulletClass: 'swiper-pagination-line',
bulletActiveClass: 'swiper-pagination-line--active'
}
: false
);
const swiperRef = ref<any>(null);
function onSwiper(swiper: any) {
swiperRef.value = swiper;
}
function goTo(index: number) {
swiperRef.value?.slideTo(index);
}
</script>
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 2px solid $accentDark;
width: 100%;
background-color: $white;
transition: 0.2s;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&__list {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: 10px;
& .card__link {
width: fit-content;
padding: 0;
}
& .card__block {
width: 150px;
height: 150px;
}
& .card__bottom {
margin-top: 0;
width: fit-content;
flex-shrink: 0;
padding-inline: 0;
flex-direction: column;
align-items: flex-end;
gap: 10px;
&-button {
width: fit-content;
padding-inline: 25px;
}
}
& .card__wrapper {
flex-direction: row;
}
}
@include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4);
}
&__wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__link {
display: block;
width: 100%;
padding: 20px 15px;
}
&__block {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
&-hover {
position: absolute;
top: 0;
left: 0;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
background: transparent;
}
}
&__swiper {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
padding-bottom: 10px;
&-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__image {
&-placeholder {
width: 100%;
height: 200px;
background-color: $accentLight;
}
}
&__content {
padding-inline: 20px;
}
&__price {
width: fit-content;
background-color: rgba($accent, 0.2);
border-radius: $default_border_radius;
padding: 5px 10px;
margin-bottom: 10px;
font-weight: 700;
font-size: 16px;
}
&__name {
overflow: hidden;
font-weight: 500;
font-size: 14px;
}
&__quantity {
font-size: 14px;
}
&__bottom {
margin-top: auto;
padding: 0 20px 20px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
max-width: 100%;
&-button {
width: 84%;
}
&-wishlist {
cursor: pointer;
width: 34px;
height: 34px;
flex-shrink: 0;
background-color: $accent;
border-radius: $default_border_radius;
display: grid;
place-items: center;
transition: 0.2s;
font-size: 22px;
color: $white;
@include hover {
background-color: $accentLight;
}
}
}
}
:deep(.swiper-pagination) {
bottom: 0;
display: flex;
justify-content: center;
gap: 6px;
}
:deep(.swiper-pagination-line) {
display: inline-block;
width: 24px;
height: 2px;
background-color: rgba($accentDark, 0.3);
border-radius: 0;
opacity: 1;
transition: 0.2s;
}
:deep(.swiper-pagination-line--active) {
background-color: $accentDark;
}
</style>

View file

@ -0,0 +1,85 @@
<template>
<form @submit.prevent="handleContactUs()" class="form">
<ui-input
:type="'text'"
:placeholder="t('fields.name')"
:rules="[required]"
v-model="name"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:rules="[required]"
v-model="email"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.subject')"
:rules="[required]"
v-model="subject"
/>
<ui-textarea
:placeholder="t('fields.message')"
:rules="[required]"
v-model="message"
/>
<ui-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.send') }}
</ui-button>
</form>
</template>
<script setup>
import {useValidators} from "~/composables/rules";
import {useContactUs} from "~/composables/contact/index.js";
const { t } = useI18n()
const { required } = useValidators()
const name = ref('')
const email = ref('')
const phoneNumber = ref('')
const subject = ref('')
const message = ref('')
const isFormValid = computed(() => {
return (
required(name.value) === true &&
required(email.value) === true &&
required(phoneNumber.value) === true &&
required(subject.value) === true &&
required(message.value) === true
)
})
const { contactUs, loading } = useContactUs();
async function handleContactUs() {
await contactUs(
name.value,
email.value,
phoneNumber.value,
subject.value,
message.value,
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View file

@ -0,0 +1,64 @@
<template>
<div class="brands">
<client-only>
<NuxtMarquee
class="brand__marquee"
id="marquee-slider"
:speed="50"
:pauseOnHover="true"
>
<div
class="brands__item"
v-for="brand in brands"
:key="brand.node.uuid"
>
<nuxt-link-locale
:to="`/brand/${brand.node.uuid}`"
>
<nuxt-img
densities="x1"
:src="brand.node.smallLogo"
:alt="brand.node.name"
loading="lazy"
class="brands__item-image"
/>
</nuxt-link-locale>
</div>
</NuxtMarquee>
</client-only>
</div>
</template>
<script setup lang="ts">
import {useBrands} from "~/composables/brands";
const { brands } = await useBrands();
</script>
<style lang="scss" scoped>
.brands {
&__item {
margin: 10px;
flex-shrink: 0;
width: 135px;
height: 70px;
background-color: $white;
border: 2px solid $accentDark;
border-radius: $default_border_radius;
padding: 10px;
cursor: pointer;
transition: 0.2s;
@include hover {
box-shadow: 0 0 10px 1px rgba($accentDark, 0.4);
}
&-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
</style>

View file

@ -0,0 +1,34 @@
<template>
<div class="block">
<ui-title>{{ tag.name }}</ui-title>
<div class="container">
<div class="block__list">
<cards-category
v-for="category in tag.categorySet.edges"
:key="category.node.uuid"
:category="category.node"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ICategoryTag} from "~/types/index.js";
const props = defineProps<{
tag: ICategoryTag;
}>();
</script>
<style lang="scss" scoped>
.block {
&__list {
display: grid;
grid-template-columns: repeat(auto-fill, 275px);
align-items: center;
justify-content: center;
gap: 50px;
}
}
</style>

View file

@ -0,0 +1,21 @@
<template>
<div class="tags">
<home-category-tags-block
v-for="tag in tags"
:key="tag.node.uuid"
:tag="tag.node"
/>
</div>
</template>
<script setup>
import {useCategoryTags} from "~/composables/categories";
const { tags } = await useCategoryTags();
</script>
<style lang="scss" scoped>
.tags {
}
</style>

View file

@ -0,0 +1,159 @@
<template>
<el-skeleton
class="sk"
:class="[{'sk__list': isList }]"
animated
>
<template #template>
<div class="sk__content">
<el-skeleton-item
variant="image"
class="sk__image"
/>
<div class="sk__content-wrapper">
<el-skeleton-item
variant="p"
class="sk__price"
/>
<el-skeleton-item
variant="p"
class="sk__name"
/>
<el-skeleton-item
variant="p"
class="sk__rating"
/>
<el-skeleton-item
variant="p"
class="sk__quantity"
/>
</div>
</div>
<div class="sk__buttons">
<el-skeleton-item
variant="p"
class="sk__button"
/>
<el-skeleton-item
variant="p"
class="sk__button"
/>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
const props = defineProps<{
isList?: boolean
}>()
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $default_border_radius;
background-color: rgba(255, 255, 255, 0.61);
border: 2px solid $accent;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: flex-start;
padding: 10px;
& .sk__content {
width: 100%;
display: flex;
flex-direction: row;
&-wrapper {
width: 100%;
padding-top: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
}
& .sk__image {
width: 150px;
height: 150px;
}
& .sk__price {
width: 100px;
}
& .sk__quantity {
width: 100px;
}
& .sk__buttons {
width: fit-content;
margin-top: 0;
flex-direction: column;
align-items: flex-end;
}
& .sk__button {
&:first-child {
width: 140px;
}
}
}
&__image {
width: 100%;
height: 220px;
border-radius: $default_border_radius;
}
&__content {
&-wrapper {
padding: 24px 20px 20px 20px;
}
}
&__price {
width: 35%;
height: 25px;
}
&__name {
width: 100%;
height: 75px;
}
&__rating {
width: 120px;
height: 40px;
}
&__quantity {
width: 50%;
height: 18px;
}
&__buttons {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
margin-top: auto;
padding: 0 20px 20px 20px;
}
&__button {
width: 100%;
height: 34px;
&:last-child {
width: 34px;
flex-shrink: 0;
}
}
}
</style>

View file

@ -0,0 +1,228 @@
<template>
<div class="filter" :class="[{active: isOpen}]">
<div class="filter__wrapper" ref="filtersRef">
<div class="filter__top">
<h2>{{ t('store.filters.title') }}</h2>
<Icon
name="line-md:close"
size="30"
@click="closeFilters"
/>
</div>
<div class="filter__inner">
<el-collapse v-model="collapse" class="filter__collapse">
<el-collapse-item
v-for="(attribute, idx) in filterableAttributes"
:key="idx"
:name="1 + idx"
>
<template #title="{ isActive }">
<div :class="['filter__collapse-title', { 'is-active': isActive }]">
{{ attribute.attributeName }}
<Icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filter__collapse-icon"
:class="[{ active: isActive }]"
/>
</div>
</template>
<ui-checkbox
:id="attribute.attributeName + '-all'"
v-model="selectedAllMap[attribute.attributeName]"
@change="toggleAll(attribute.attributeName)"
:isFilter="true"
>
{{ t('store.filters.all') }}
</ui-checkbox>
<ui-checkbox
v-for="(value, idx) in attribute.possibleValues"
:key="idx"
:id="attribute.attributeName + idx"
v-model="selectedMap[attribute.attributeName][value]"
:isFilter="true"
>
{{ value }}
</ui-checkbox>
</el-collapse-item>
</el-collapse>
</div>
<div class="filter__bottom">
<button
class="filter__bottom-button"
@click="onReset"
>
{{ t('store.filters.reset') }}
</button>
<ui-button
@click="onApply"
>
{{ t('store.filters.apply') }}
</ui-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IStoreFilters} from "~/types";
import {useFilters} from "~/composables/store";
const appStore = useAppStore();
const { t } = useI18n();
const props = defineProps<{
filterableAttributes: IStoreFilters[]
isOpen: boolean
}>();
const emit = defineEmits<{
(e: 'update:selected', value: Record<string, string[]>): void;
(e: 'close'): void;
}>();
const { selectedMap, selectedAllMap, collapse, toggleAll, resetFilters, applyFilters } = useFilters(
toRef(props, 'filterableAttributes')
);
const filtersRef = ref<HTMLElement | null>(null);
function closeFilters() {
emit('close');
}
onClickOutside(filtersRef, closeFilters);
function onReset() {
resetFilters();
emit('update:selected', {});
closeFilters();
}
function onApply() {
const picked = applyFilters();
emit('update:selected', picked);
closeFilters();
}
watch(
() => props.isOpen,
open => {
appStore.setOverflowHidden(open)
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.filter {
position: fixed;
z-index: 3;
height: 100vh;
width: 100vw;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
background-color: rgba(0, 0, 0, 0.4);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
&.active {
opacity: 1;
visibility: visible;
}
&__wrapper {
position: absolute;
top: 0;
right: 0;
width: 450px;
height: 100%;
background-color: #e8e8e8;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.2s ease;
}
&.active &__wrapper {
transform: translateX(0);
}
&__top {
display: flex;
align-items: center;
justify-content: space-between;
background-color: $white;
border-radius: $default_border_radius;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.3);
padding: 10px 20px;
margin: 20px;
color: $accent;
text-align: center;
font-size: 20px;
& span {
cursor: pointer;
transition: 0.2s;
@include hover {
color: $accentDark;
}
}
}
&__inner {
height: 100%;
overflow: auto;
padding-inline: 10px;
margin-inline: 15px;
}
&__collapse {
&-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
&-icon {
transition: 0.2s;
&.active {
transform: rotate(-180deg);
}
}
}
&__bottom {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 15px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.3);
background-color: $white;
&-button {
cursor: pointer;
padding-block: 7px;
background-color: rgba($accent, 0.2);
border: 1px solid $accent;
border-radius: $default_border_radius;
transition: 0.2s;
font-size: 14px;
color: $accent;
font-weight: 700;
@include hover {
background-color: $accent;
color: $white;
}
}
}
}
</style>

View file

@ -0,0 +1,146 @@
<template>
<div class="store">
<store-filter
v-if="filters.length"
:filterableAttributes="filters"
:isOpen="showFilter"
@update:selected="onFiltersChange"
@close="showFilter = false"
/>
<store-top
v-model="orderBy"
@toggle-filter="onFilterToggle"
/>
<div
class="store__list"
:class="[
{ 'store__list-grid': productView === 'grid' },
{ 'store__list-list': productView === 'list' }
]"
>
<cards-product
v-if="products.length"
v-for="product in products"
:key="product.node.uuid"
:product="product.node"
/>
<skeletons-cards-product
v-if="pending"
v-for="idx in 12"
:key="idx"
:isList="productView === 'list'"
/>
</div>
<div class="store__list-observer" ref="observer"></div>
</div>
</template>
<script setup lang="ts">
import {useFilters, useStore} from "~/composables/store";
import {useRouteQuery} from "@vueuse/router";
import {useCategoryBySlug} from "~/composables/categories";
import {useAppConfig} from '~/composables/config';
const { COOKIES_PRODUCT_VIEW_KEY } = useAppConfig();
const productView = useCookie<string>(
COOKIES_PRODUCT_VIEW_KEY as string,
{
default: () => 'grid',
path: '/',
}
);
const slug = useRouteParams<string>('slug');
const attributes = useRouteQuery<string>('attributes', '');
const orderBy = useRouteQuery<string>('orderBy', 'created');
const minPrice = useRouteQuery<number>('minPrice', 0);
const maxPrice = useRouteQuery<number>('maxPrice', 50000);
const observer = ref(null);
const { category, filters } = await useCategoryBySlug(slug.value);
watch(
() => category.value,
(cat) => {
if (cat && !useRoute().query.maxPrice) {
maxPrice.value = cat.minMaxPrices.maxPrice;
}
},
{ immediate: true }
);
const { pending, products, pageInfo, prodVars } = await useStore(
slug.value,
attributes.value,
orderBy.value,
minPrice.value,
maxPrice.value,
''
);
const { buildAttributesString } = useFilters(filters);
const showFilter = ref<boolean>(false);
function onFilterToggle() {
showFilter.value = true;
}
function onFiltersChange(newFilters: Record<string, string[]>) {
attributes.value = buildAttributesString(newFilters);
}
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage) {
prodVars.productAfter = pageInfo.value.endCursor;
}
},
);
watch(orderBy, newVal => {
prodVars.orderBy = newVal || '';
});
watch(attributes, newVal => {
prodVars.attributes = newVal || '';
});
watch(minPrice, newVal => {
prodVars.minPrice = newVal || 0;
});
watch(maxPrice, newVal => {
prodVars.maxPrice = newVal || 500000;
});
</script>
<style scoped lang="scss">
.store {
position: relative;
&__inner {
width: 100%;
}
&__list {
margin-top: 50px;
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 25px;
}
&-list {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 20px;
}
&-observer {
background-color: transparent;
width: 100%;
height: 10px;
}
}
}
</style>

View file

@ -0,0 +1,184 @@
<template>
<div class="top">
<div class="top__main">
<div class="top__sorting">
<p>{{ t('store.sorting') }}</p>
<client-only>
<el-select
v-model="select"
size="large"
style="width: 240px"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</client-only>
</div>
<div class="top__view">
<button
class="top__view-button"
:class="{ active: productView === 'list' }"
@click="setView('list')"
>
<Icon name="material-symbols:view-list-sharp" size="16" />
</button>
<button
class="top__view-button"
:class="{ active: productView === 'grid' }"
@click="setView('grid')"
>
<Icon name="material-symbols:grid-view" size="16" />
</button>
</div>
</div>
<div class="top__filter">
<button
class="top__filter-button"
@click="$emit('toggle-filter')"
>
{{ t('store.filters.title') }}
<Icon name="line-md:filter" size="16" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n()
import { useAppConfig } from '~/composables/config';
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'toggle-filter'): void
}>()
const { COOKIES_PRODUCT_VIEW_KEY } = useAppConfig()
const productView = useCookie(COOKIES_PRODUCT_VIEW_KEY as string)
function setView(view: 'list' | 'grid') {
productView.value = view
}
const select = ref(props.modelValue || 'created')
const options = [
{
value: 'created',
label: 'New',
},
{
value: 'rating',
label: 'Rating',
},
{
value: 'price',
label: 'Сheap first',
},
{
value: '-price',
label: 'Expensive first',
}
]
watch(select, value => {
emit('update:modelValue', value)
})
</script>
<style scoped lang="scss">
.top {
margin-bottom: 20px;
width: 100%;
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
&__main {
display: flex;
align-items: center;
gap: 75px;
padding: 15px 30px;
background-color: $white;
border-radius: $default_border_radius;
border: 1px solid #dedede;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.1);
}
&__filter {
padding: 15px 30px;
background-color: $white;
border-radius: $default_border_radius;
border: 1px solid #dedede;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.1);
&-button {
cursor: pointer;
border-radius: $default_border_radius;
background-color: rgba($accent, 0.2);
border: 1px solid $accent;
padding: 7px 20px;
display: flex;
align-items: center;
gap: 10px;
transition: 0.2s;
color: $accent;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $accent;
color: $white;
}
}
}
&__sorting {
display: flex;
align-items: center;
gap: 20px;
& p {
font-weight: 700;
font-size: 14px;
color: $accentDark;
}
}
&__view {
display: flex;
align-items: center;
gap: 1px;
border-radius: $default_border_radius;
border: 1px solid #7965d1;
background-color: rgba($accent, 0.2);
&-button {
cursor: pointer;
background-color: transparent;
display: grid;
place-items: center;
padding: 5px 12px;
transition: 0.2s;
color: $accent;
@include hover {
background-color: rgba($accent, 1);
color: $white;
}
&.active {
background-color: rgba($accent, 1);
color: $white;
}
}
}
}
</style>

View file

@ -0,0 +1,49 @@
<template>
<client-only>
<el-breadcrumb separator="/" class="breadcrumbs">
<el-breadcrumb-item
v-for="(crumb, idx) in breadcrumbs"
:key="idx"
>
<nuxt-link-locale
v-if="idx !== breadcrumbs.length - 1"
:to="crumb.link"
class="breadcrumbs__link"
>
{{ crumb.text }}
</nuxt-link-locale>
<span v-else class="breadcrumbs__current">
{{ crumb.text }}
</span>
</el-breadcrumb-item>
</el-breadcrumb>
</client-only>
</template>
<script setup lang="ts">
import {useBreadcrumbs} from "~/composables/breadcrumbs";
const { breadcrumbs } = useBreadcrumbs()
</script>
<style scoped lang="scss">
.breadcrumbs {
padding: 15px 250px 15px 50px;
&__link {
cursor: pointer !important;
transition: 0.2s;
color: $accent !important;
font-weight: 600 !important;
@include hover {
color: $accentDark !important;
}
}
&__current {
font-weight: 600;
color: #333;
}
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<div class="loader">
<li class="loader__dots" id="dot-1"></li>
<li class="loader__dots" id="dot-2"></li>
<li class="loader__dots" id="dot-3"></li>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.loader {
display: flex;
gap: 0.6em;
list-style: none;
&__dots {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: $white;
}
}
#dot-1 {
animation: loader-1 0.6s infinite ease-in-out;
}
@keyframes loader-1 {
50% {
opacity: 0;
transform: translateY(-0.3em);
}
}
#dot-2 {
animation: loader-2 0.6s 0.3s infinite ease-in-out;
}
@keyframes loader-2 {
50% {
opacity: 0;
transform: translateY(-0.3em);
}
}
#dot-3 {
animation: loader-3 0.6s 0.6s infinite ease-in-out;
}
@keyframes loader-3 {
50% {
opacity: 0;
transform: translateY(-0.3em);
}
}
</style>

View file

@ -0,0 +1,89 @@
<template>
<div class="block">
<textarea
:placeholder="placeholder"
:value="modelValue"
@input="onInput"
class="block__textarea"
/>
<p v-if="!validate" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
const $emit = defineEmits();
const props = defineProps<{
placeholder: string,
isError?: boolean,
error?: string,
modelValue?: [string, number],
rules?: array
}>();
const validate = ref<boolean>(true)
const errorMessage = ref<string>('')
const onInput = (e: Event) => {
let result = true
props.rules?.forEach((rule) => {
result = rule((e.target).value)
if (!result) {
errorMessage.value = String(result)
result = false
}
})
validate.value = result
return $emit('update:modelValue', (e.target).value)
}
</script>
<style lang="scss" scoped>
.block {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
position: relative;
&__textarea {
width: 100%;
height: 150px;
resize: none;
padding: 6px 12px;
border: 1px solid #e0e0e0;
border-radius: $default_border_radius;
background-color: $white;
color: #1f1f1f;
font-size: 12px;
font-weight: 400;
line-height: 20px;
&::placeholder {
color: #2B2B2B;
}
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

@ -0,0 +1,28 @@
<template>
<div class="title">
<h2>
<slot />
</h2>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.title {
padding-block: 10px 50px;
display: grid;
place-items: center;
margin-bottom: 50px;
background: linear-gradient(0deg, $light 0%, $accentSmooth 100%);
& h2 {
text-align: center;
font-size: 48px;
font-weight: 900;
color: $white;
}
}
</style>

View file

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

View file

@ -0,0 +1,92 @@
import { LOGIN } from '~/graphql/mutations/auth';
import type { ILoginResponse } from '~/types/api/auth';
import { isGraphQLError } from '~/utils/error';
import { useAppConfig } from '~/composables/config';
import { useLocaleRedirect } from '~/composables/languages';
import { useWishlist } from '~/composables/wishlist';
import { usePendingOrder } from '~/composables/orders';
import { useUserStore } from '~/stores/user';
import { useAppStore } from '~/stores/app';
import {DEFAULT_LOCALE} from "~/config/constants";
export function useLogin() {
const { t } = useI18n();
const userStore = useUserStore();
const appStore = useAppStore();
const { COOKIES_LOCALE_KEY, COOKIES_REFRESH_TOKEN_KEY, COOKIES_ACCESS_TOKEN_KEY } = useAppConfig();
const { checkAndRedirect } = useLocaleRedirect();
const cookieRefresh = useCookie(
COOKIES_REFRESH_TOKEN_KEY,
{
default: () => '',
path: '/'
}
);
const cookieAccess = useCookie(
COOKIES_ACCESS_TOKEN_KEY,
{
default: () => '',
path: '/'
}
);
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const { mutate, loading, error } = useMutation<ILoginResponse>(LOGIN);
async function login(
email: string,
password: string,
isStayLogin: boolean
) {
const result = await mutate({ email, password });
const authData = result?.data?.obtainJwtToken;
if (!authData) return;
if (isStayLogin && authData.refreshToken) {
cookieRefresh.value = authData.refreshToken;
}
userStore.setUser(authData.user);
cookieAccess.value = authData.accessToken
ElNotification({ message: t('popup.success.login'), type: 'success' });
if (authData.user.language !== cookieLocale.value) {
await checkAndRedirect(authData.user.language);
}
await useWishlist();
await usePendingOrder(authData.user.email);
appStore.unsetActiveState();
}
watch(error, (err) => {
if (!err) return;
console.error('useLogin error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
});
return {
loading,
login
};
}

View file

@ -0,0 +1,36 @@
import {useAppConfig} from "~/composables/config";
export function useLogout() {
const userStore = useUserStore();
const router = useRouter();
const { COOKIES_REFRESH_TOKEN_KEY, COOKIES_ACCESS_TOKEN_KEY } = useAppConfig();
const cookieRefresh = useCookie(
COOKIES_REFRESH_TOKEN_KEY,
{
default: () => '',
path: '/'
}
);
const cookieAccess = useCookie(
COOKIES_ACCESS_TOKEN_KEY,
{
default: () => '',
path: '/'
}
);
async function logout() {
userStore.setUser(null);
cookieRefresh.value = '';
cookieAccess.value = '';
await router.push({path: '/'});
}
return {
logout
};
}

View file

@ -0,0 +1,59 @@
import {NEW_PASSWORD} from "@/graphql/mutations/auth.js";
import {isGraphQLError} from "~/utils/error";
import type {INewPasswordResponse} from "~/types";
import { useRouteQuery } from '@vueuse/router';
export function useNewPassword() {
const {t} = useI18n();
const router = useRouter();
const appStore = useAppStore();
const token = useRouteQuery('token', '');
const uid = useRouteQuery('uid', '');
const { mutate, loading, error } = useMutation<INewPasswordResponse>(NEW_PASSWORD);
async function newPassword(
password: string,
confirmPassword: string
) {
const result = await mutate({
password,
confirmPassword,
token: token.value,
uid: uid.value
});
if (result?.data?.confirmResetPassword.success) {
ElNotification({
message: t('popup.success.newPassword'),
type: 'success'
})
await router.push({path: '/'})
appStore.unsetActiveState();
}
}
watch(error, (err) => {
if (!err) return;
console.error('useNewPassword error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
});
return {
newPassword,
loading
};
}

View file

@ -0,0 +1,48 @@
import {RESET_PASSWORD} from "@/graphql/mutations/auth.js";
import {isGraphQLError} from "~/utils/error";
import type {IPasswordResetResponse} from "~/types";
export function usePasswordReset() {
const {t} = useI18n();
const appStore = useAppStore();
const { mutate, loading, error } = useMutation<IPasswordResetResponse>(RESET_PASSWORD);
async function resetPassword(
email: string
) {
const result = await mutate({
email
});
if (result?.data?.resetPassword.success) {
ElNotification({
message: t('popup.success.reset'),
type: 'success'
})
appStore.unsetActiveState();
}
}
watch(error, (err) => {
if (!err) return;
console.error('usePasswordReset error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
});
return {
resetPassword,
loading
};
}

View file

@ -0,0 +1,76 @@
import { REFRESH } from '@/graphql/mutations/auth';
import { useAppConfig } from '~/composables/config';
import { useLocaleRedirect } from '~/composables/languages';
import { useWishlist } from '~/composables/wishlist';
import { usePendingOrder } from '~/composables/orders';
import { useUserStore } from '~/stores/user';
import { isGraphQLError } from '~/utils/error';
import {DEFAULT_LOCALE} from "~/config/constants";
export function useRefresh() {
const { t } = useI18n();
const userStore = useUserStore();
const { COOKIES_REFRESH_TOKEN_KEY, COOKIES_LOCALE_KEY } = useAppConfig();
const { checkAndRedirect } = useLocaleRedirect();
const { mutate, loading, error } = useMutation(REFRESH);
async function refresh() {
const cookieRefresh = useCookie(
COOKIES_REFRESH_TOKEN_KEY,
{
default: () => '',
path: '/'
}
);
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
if (!cookieRefresh.value) {
return;
}
const result = await mutate({ refreshToken: cookieRefresh.value });
const data = result?.data?.refreshJwtToken;
if (!data) {
return;
}
userStore.setUser(data.user);
if (data.user.language !== cookieLocale.value) {
await checkAndRedirect(data.user.language);
}
cookieRefresh.value = data.refreshToken
await useWishlist();
await usePendingOrder(data.user.email);
}
watch(error, (err) => {
if (!err) return;
console.error('useRefresh error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
})
return {
refresh,
loading
};
}

View file

@ -0,0 +1,82 @@
import {REGISTER} from "@/graphql/mutations/auth.js";
import {useMailClient} from "@/composables/utils";
import {isGraphQLError} from "~/utils/error";
import type {IRegisterResponse} from "~/types";
export function useRegister() {
const {t} = useI18n();
const appStore = useAppStore();
const { mailClientUrl, detectMailClient, openMailClient } = useMailClient();
const { mutate, loading, error } = useMutation<IRegisterResponse>(REGISTER);
async function register(
firstName: string,
lastName: string,
phoneNumber: string,
email: string,
password: string,
confirmPassword: string
) {
const result = await mutate({
firstName,
lastName,
phoneNumber,
email,
password,
confirmPassword
});
if (result?.data?.createUser?.success) {
detectMailClient(email);
ElNotification({
message: h('div', [
h('p', t('popup.success.register')),
mailClientUrl.value ? h(
'button',
{
style: {
marginTop: '10px',
padding: '6px 12px',
backgroundColor: '#000000',
color: '#fff',
border: 'none',
cursor: 'pointer',
},
onClick: () => {
openMailClient()
}
},
t('buttons.goEmail')
) : ''
]),
type: 'success'
})
appStore.unsetActiveState();
}
}
watch(error, (err) => {
if (!err) return
console.error('useRegister error:', err)
let message = t('popup.errors.defaultError')
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message
} else {
message = err.message
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
})
})
return {
register,
loading
};
}

View file

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

View file

@ -0,0 +1,21 @@
import {GET_BRAND_BY_UUID} from "~/graphql/queries/standalone/brands";
import type {IBrandsResponse} from "~/types";
export async function useBrandByUuid(uuid: string) {
const brand = computed(() => data.value?.brands.edges[0].node ?? []);
const { data, error } = await useAsyncQuery<IBrandsResponse>(
GET_BRAND_BY_UUID,
{ uuid }
);
watch(error, (err) => {
if (err) {
console.error('useBrandsByUuid error:', err)
}
});
return {
brand
}
}

View file

@ -0,0 +1,20 @@
import {GET_BRANDS} from "~/graphql/queries/standalone/brands";
import type {IBrandsResponse} from "~/types";
export async function useBrands() {
const brands = computed(() => data.value?.brands.edges ?? []);
const { data, error } = await useAsyncQuery<IBrandsResponse>(
GET_BRANDS
);
watch(error, (err) => {
if (err) {
console.error('useBrands error:', err)
}
});
return {
brands
}
}

View file

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

View file

@ -0,0 +1,76 @@
import { useCategoryStore } from '~/stores/category'
import type {ICategory, IProduct} from "~/types";
interface Crumb {
text: string
link?: string
}
function findCategoryPath(
nodes: ICategory[],
targetSlug: string,
path: ICategory[] = []
): ICategory[] | null {
for (const node of nodes) {
const newPath = [...path, node]
if (node.slug === targetSlug) {
return newPath
}
if (node.children?.length) {
const found = findCategoryPath(node.children, targetSlug, newPath)
if (found) {
return found
}
}
}
return null
}
export function useBreadcrumbs() {
const { t } = useI18n()
const route = useRoute()
const pageTitle = useState<string>('pageTitle')
const categoryStore = useCategoryStore()
const product = useState<IProduct | null>('currentProduct')
const breadcrumbs = computed<Crumb[]>(() => {
const crumbs: Crumb[] = [
{ text: t('breadcrumbs.home'), link: '/' }
]
if (route.path.includes('/catalog') || route.path.includes('/product')) {
crumbs.push({ text: t('breadcrumbs.catalog'), link: '/catalog' })
let categorySlug: string | undefined
if (route.path.includes('/catalog')) {
categorySlug = route.params.slug as string
} else if (route.path.includes('/product')) {
categorySlug = product.value?.category?.slug
}
if (categorySlug) {
const roots = categoryStore.categories.map(e => e.node)
const path = findCategoryPath(roots, categorySlug)
path?.forEach(node => {
crumbs.push({
text: node.name,
link: `/catalog/${node.slug}`
})
})
}
if (route.path.includes('/product') && product.value) {
crumbs.push({ text: product.value.name })
}
}
else {
crumbs.push({
text: pageTitle.value || t(`breadcrumbs.${String(route.name)}`)
})
}
return crumbs
})
return { breadcrumbs }
}

View file

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

View file

@ -0,0 +1,37 @@
import type { ICategoriesResponse } from '~/types';
import { useCategoryStore } from '~/stores/category';
import {GET_CATEGORIES} from "~/graphql/queries/standalone/categories";
export async function useCategories() {
const categoryStore = useCategoryStore();
const getCategories = async (cursor?: string): Promise<void> => {
const {data, error} = await useAsyncQuery<ICategoriesResponse>(
GET_CATEGORIES,
{
level: 0,
whole: true,
categoryAfter: cursor
}
);
if (!error.value && data.value?.categories.edges) {
if (!cursor) {
categoryStore.setCategories(data.value.categories.edges);
} else {
categoryStore.addCategories(data.value.categories.edges);
}
const pageInfo = data.value.categories.pageInfo;
if (pageInfo && pageInfo.hasNextPage && pageInfo.endCursor) {
await getCategories(pageInfo.endCursor);
}
}
if (error.value) console.error('useCategories error:', error.value);
}
return {
getCategories
};
}

View file

@ -0,0 +1,27 @@
import type {ICategoryBySlugResponse} from '~/types';
import {GET_CATEGORY_BY_SLUG} from "~/graphql/queries/standalone/categories";
export async function useCategoryBySlug(slug: string) {
const category = computed(() => data.value?.categories.edges[0]?.node ?? null);
const filters = computed(() => {
if (!category.value) return [];
return category.value.filterableAttributes
.filter(attr => attr.possibleValues.length > 0);
});
const { data, error } = await useAsyncQuery<ICategoryBySlugResponse>(
GET_CATEGORY_BY_SLUG,
{ categorySlug: slug }
);
watch(error, (err) => {
if (err) {
console.error('useCategoryBySlug error:', err)
}
});
return {
category,
filters
};
}

View file

@ -0,0 +1,20 @@
import type {ICategoryTagsResponse} from "~/types";
import {GET_CATEGORY_TAGS} from "~/graphql/queries/standalone/categories";
export async function useCategoryTags() {
const tags = computed(() => data.value?.categoryTags?.edges ?? []);
const { data, error } = await useAsyncQuery<ICategoryTagsResponse>(
GET_CATEGORY_TAGS
);
watch(error, (err) => {
if (err) {
console.error('useCategoryTags error:', err)
}
});
return {
tags
};
}

View file

@ -0,0 +1,25 @@
import { GET_COMPANY_INFO } from '~/graphql/queries/standalone/company';
import { useCompanyStore } from '~/stores/company';
import type { ICompanyResponse } from '~/types';
export async function useCompanyInfo() {
const companyStore = useCompanyStore();
const { data, error } = await useAsyncQuery<ICompanyResponse>(
GET_COMPANY_INFO
);
if (data.value?.parameters) {
companyStore.setCompanyInfo(data.value.parameters)
}
watch(error, (err) => {
if (err) {
console.error('useCompanyInfo error:', err);
}
});
return {
};
}

View file

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

View file

@ -0,0 +1,15 @@
export const useAppConfig = () => {
const runtimeConfig = useRuntimeConfig();
const APP_NAME: string = runtimeConfig.public.evibesProjectName;
const APP_NAME_KEY: string = APP_NAME.toLowerCase();
return {
APP_NAME,
APP_NAME_KEY,
COOKIES_LOCALE_KEY: `${APP_NAME_KEY}-locale`,
COOKIES_REFRESH_TOKEN_KEY: `${APP_NAME_KEY}-refresh`,
COOKIES_ACCESS_TOKEN_KEY: `${APP_NAME_KEY}-access`,
COOKIES_PRODUCT_VIEW_KEY: `${APP_NAME_KEY}-product-view`
};
};

View file

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

View file

@ -0,0 +1,53 @@
import {isGraphQLError} from "~/utils/error";
import type {IContactUsResponse} from "~/types";
import {CONTACT_US} from "~/graphql/mutations/contact";
export function useContactUs() {
const {t} = useI18n();
const { mutate, loading, error } = useMutation<IContactUsResponse>(CONTACT_US);
async function contactUs(
name: string,
email: string,
phoneNumber: string,
subject: string,
message: string
) {
const result = await mutate({
name,
email,
phoneNumber,
subject,
message
});
if (result?.data?.contactUs.received) {
ElNotification({
message: t('popup.success.contactUs'),
type: 'success'
})
}
}
watch(error, (err) => {
if (!err) return;
console.error('useContactUs error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
});
return {
contactUs,
loading
};
}

View file

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

View file

@ -0,0 +1,51 @@
import {GET_LANGUAGES} from "~/graphql/queries/standalone/languages.js";
import type {ILanguage} from "~/types";
import type {ILanguagesResponse} from "~/types";
import {useAppConfig} from "~/composables/config";
import {DEFAULT_LOCALE, SUPPORTED_LOCALES} from "~/config/constants";
export async function useLanguages() {
const languageStore = useLanguageStore();
const { COOKIES_LOCALE_KEY } = useAppConfig();
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const { data, error } = await useAsyncQuery<ILanguagesResponse>(
GET_LANGUAGES
);
if (!error.value && data.value?.languages) {
const filteredLanguages = data.value.languages.filter((locale: ILanguage) =>
SUPPORTED_LOCALES.some(supportedLocale =>
supportedLocale.code === locale.code
)
);
languageStore.setLanguages(filteredLanguages);
const currentLocale = filteredLanguages.find(locale =>
locale.code === cookieLocale.value
);
if (currentLocale) {
languageStore.setCurrentLocale(currentLocale);
}
}
watch(error, (err) => {
if (err) {
console.error('useLanguage error:', err)
}
});
return {
};
}

View file

@ -0,0 +1,54 @@
import {SWITCH_LANGUAGE} from "@/graphql/mutations/languages.js";
import {useAppConfig} from "~/composables/config";
import type {IUserResponse, LocaleDefinition} from "~/types";
import {DEFAULT_LOCALE} from "@intlify/core-base";
export function useLanguageSwitch() {
const userStore = useUserStore();
const router = useRouter();
const { COOKIES_LOCALE_KEY } = useAppConfig();
const switchLocalePath = useSwitchLocalePath();
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const isAuthenticated = computed(() => userStore.isAuthenticated)
const userUuid = computed(() => userStore.user?.uuid);
const { mutate, loading, error } = useMutation<IUserResponse>(SWITCH_LANGUAGE);
async function switchLanguage(
locale: string
) {
cookieLocale.value = locale;
await router.push({path: switchLocalePath(cookieLocale.value as LocaleDefinition['code'])})
if (isAuthenticated.value) {
const result = await mutate({
uuid: userUuid.value,
locale
});
if (result?.data?.updateUser) {
userStore.setUser(result.data.updateUser.user)
}
}
}
watch(error, (err) => {
if (err) {
console.error('useBrands error:', err)
}
});
return {
switchLanguage,
loading
};
}

View file

@ -0,0 +1,37 @@
import {SUPPORTED_LOCALES, DEFAULT_LOCALE} from '~/config/constants';
import {useAppConfig} from "~/composables/config";
import type {SupportedLocale} from "~/types";
export function useLocaleRedirect() {
const { locale } = useI18n();
const router = useRouter();
const switchLocalePath = useSwitchLocalePath();
const { COOKIES_LOCALE_KEY } = useAppConfig();
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
function isSupportedLocale(locale: string): locale is SupportedLocale {
return SUPPORTED_LOCALES.some(l => l.code === locale);
}
async function checkAndRedirect(userLocale: string) {
const targetLocale = isSupportedLocale(userLocale) ? userLocale : DEFAULT_LOCALE;
if (targetLocale !== locale.value) {
cookieLocale.value = targetLocale
locale.value = targetLocale;
await router.push({path: switchLocalePath(targetLocale)});
}
}
return {
checkAndRedirect
};
}

View file

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

View file

@ -0,0 +1,28 @@
import {GET_ORDERS} from "~/graphql/queries/standalone/orders";
import type {IOrderResponse} from "~/types";
export async function usePendingOrder(userEmail: string) {
const cartStore = useCartStore();
const { data, error } = await useAsyncQuery<IOrderResponse>(
GET_ORDERS,
{
status: "PENDING",
userEmail
}
);
if (!error.value && data.value?.orders.edges[0].node) {
cartStore.setCurrentOrders(data.value?.orders.edges[0].node);
}
watch(error, (err) => {
if (err) {
console.error('usePendingOrder error:', err);
}
});
return {
};
}

View file

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

View file

@ -0,0 +1,20 @@
import {GET_PRODUCT_TAGS} from "~/graphql/queries/standalone/products.js";
import type {IProductTagsResponse} from "~/types";
export async function useProductTags() {
const tags = computed(() => data.value?.productTags?.edges ?? []);
const { data, error } = await useAsyncQuery<IProductTagsResponse>(
GET_PRODUCT_TAGS
);
watch(error, (err) => {
if (err) {
console.error('useProductTags error:', err)
}
});
return {
tags
};
}

View file

@ -0,0 +1,29 @@
import { GET_PRODUCTS } from '~/graphql/queries/standalone/products';
import type { IProductResponse } from '~/types';
export async function useProducts() {
const variables = ref({ first: 12 });
const { data, error, refresh } = await useAsyncQuery<IProductResponse>(
GET_PRODUCTS,
variables
);
const products = computed(() => data.value?.products?.edges ?? []);
const pageInfo = computed(() => data.value?.products?.pageInfo ?? {});
const getProducts = async (params: Record<string, any> = {}) => {
variables.value = { ...variables.value, ...params };
await refresh();
};
watch(error, (e) => {
if (e) console.error('useProducts error:', e);
});
return {
products,
pageInfo,
getProducts
};
}

View file

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

View file

@ -0,0 +1,44 @@
export function useValidators() {
const { t } = useI18n()
const required = (text: string) => {
if (text) return true
return t('errors.required')
}
const isEmail = (email: string) => {
if (!email) return required(email)
if (/.+@.+\..+/.test(email)) return true
return t('errors.mail')
}
const isPasswordValid = (pass: string) => {
if (pass.length < 8) {
return t('errors.needMin')
}
if (!/[a-z]/.test(pass)) {
return t('errors.needLower')
}
if (!/[A-Z]/.test(pass)) {
return t('errors.needUpper')
}
if (!/\d/.test(pass)) {
return t('errors.needNumber')
}
if (!/[#.?!@$%^&*'()_+=:;"'/>.<,|\-]/.test(pass)) {
return t('errors.needSpecial')
}
return true
}
return {
required,
isEmail,
isPasswordValid
}
}

View file

@ -0,0 +1,52 @@
import {SEARCH} from "~/graphql/mutations/search";
import type {ISearchResponse, ISearchResults} from "~/types";
import {isGraphQLError} from "~/utils/error";
export function useSearch() {
const {t} = useI18n();
const searchResults = ref<ISearchResults | null>(null);
const { mutate, loading, error } = useMutation<ISearchResponse>(SEARCH);
async function search(
query: string
) {
searchResults.value = null;
const result = await mutate({ query });
if (result?.data?.search) {
const limitedResults = {
brands: result.data.search.results.brands?.slice(0, 7) || [],
categories: result.data.search.results.categories?.slice(0, 7) || [],
posts: result.data.search.results.posts?.slice(0, 7) || [],
products: result.data.search.results.products?.slice(0, 7) || []
};
searchResults.value = limitedResults;
return { results: limitedResults };
}
}
watch(error, (err) => {
if (!err) return;
console.error('useSearch error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
});
return {
search,
loading,
searchResults
};
}

View file

@ -0,0 +1,31 @@
import {getCombinedSearch} from "~/graphql/queries/combined/searchPage";
import type {ISearchCombinedResponse} from "~/types";
export async function useSearchCombined(name: string) {
const { document, variables } = getCombinedSearch(
{
productName: name
},
{
categoryName: name
},
{
brandName: name
}
);
const { data, error } = await useAsyncQuery<ISearchCombinedResponse>(
document,
variables
);
watch(error, (err) => {
if (err) {
console.error('useSearchCombined error:', err);
}
});
return {
data
};
}

View file

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

View file

@ -0,0 +1,91 @@
import { reactive, watch, ref, type Ref } from 'vue';
import type { IStoreFilters } from '~/types';
export function useFilters(filterableAttributes: Ref<IStoreFilters[]>) {
const selectedMap = reactive<Record<string, Record<string, boolean>>>({});
const selectedAllMap = reactive<Record<string, boolean>>({});
const collapse = ref<string[]>([]);
watch(
filterableAttributes,
attrs => {
attrs.forEach(attr => {
const key = attr.attributeName;
if (!selectedMap[key]) {
selectedMap[key] = {};
}
if (selectedAllMap[key] === undefined) {
selectedAllMap[key] = false;
}
attr.possibleValues.forEach(v => {
if (selectedMap[key][v] === undefined) {
selectedMap[key][v] = false;
}
})
})
},
{ immediate: true }
);
watch(
() => filterableAttributes.value.map(a => selectedMap[a.attributeName]),
maps => {
maps.forEach((values, idx) => {
const key = filterableAttributes.value[idx].attributeName;
selectedAllMap[key] = Object.values(values).every(v => v);
})
},
{ immediate: true, deep: true }
);
function toggleAll(attrName: string) {
const all = selectedAllMap[attrName];
const attr = filterableAttributes.value.find(a => a.attributeName === attrName);
if (!attr) return;
attr.possibleValues.forEach(v => {
selectedMap[attrName][v] = all;
})
}
function resetFilters() {
filterableAttributes.value.forEach(attr => {
selectedAllMap[attr.attributeName] = false;
attr.possibleValues.forEach(v => {
selectedMap[attr.attributeName][v] = false;
})
});
}
function applyFilters() {
const picked: Record<string, string[]> = {};
Object.entries(selectedMap).forEach(([attr, values]) => {
const checked = Object.entries(values)
.filter(([, ok]) => ok)
.map(([val]) => val)
if (checked.length) {
picked[attr] = checked;
}
});
return picked;
}
function buildAttributesString(filters: Record<string, string[]>): string {
return Object.entries(filters)
.map(([name, vals]) =>
vals.length === 1
? `${name}=icontains-${vals[0]}`
: `${name}=in-${JSON.stringify(vals)}`
)
.join(';');
}
return {
selectedMap,
selectedAllMap,
collapse,
toggleAll,
resetFilters,
applyFilters,
buildAttributesString
};
}

View file

@ -0,0 +1,69 @@
import { GET_PRODUCTS } from '~/graphql/queries/standalone/products';
import type {IProductResponse} from '~/types';
interface ProdVars {
first: number,
categoriesSlugs: string,
attributes?: string,
orderBy?: string,
minPrice?: number,
maxPrice?: number,
productAfter?: string
}
export async function useStore(
slug: string,
attributes?: string,
orderBy?: string,
minPrice?: number,
maxPrice?: number,
productAfter?: string
) {
const prodVars = reactive<ProdVars>({
first: 15,
categoriesSlugs: slug,
attributes,
orderBy,
minPrice,
maxPrice,
productAfter
});
const { pending, data, error, refresh } = await useAsyncQuery<IProductResponse>(GET_PRODUCTS, prodVars);
const products = ref(data.value?.products.edges ?? []);
const pageInfo = computed(() => data.value?.products.pageInfo ?? null);
watch(error, e => e && console.error('useStore products error', e));
watch(
() => prodVars.productAfter,
async (newCursor, oldCursor) => {
if (!newCursor || newCursor === oldCursor) return;
await refresh();
const newEdges = data.value?.products.edges ?? [];
products.value.push(...newEdges);
}
);
watch(
[
() => prodVars.attributes,
() => prodVars.orderBy,
() => prodVars.minPrice,
() => prodVars.maxPrice
],
async () => {
prodVars.productAfter = '';
await refresh();
products.value = data.value?.products.edges ?? [];
}
);
return {
pending,
products,
pageInfo,
prodVars
};
}

View file

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

View file

@ -0,0 +1,44 @@
import {ACTIVATE_USER} from "@/graphql/mutations/user.js";
import {isGraphQLError} from "~/utils/error";
import type {IUserActivationResponse} from "~/types";
export function useUserActivation() {
const {t} = useI18n();
const { mutate, loading, error } = useMutation<IUserActivationResponse>(ACTIVATE_USER);
async function activateUser(
token: string,
uid: string
) {
const result = await mutate({ token, uid });
if (result?.data?.activateUser) {
ElNotification({
message: t("popup.activationSuccess"),
type: "success"
});
}
}
watch(error, (err) => {
if (!err) return;
console.error('useUserActivation error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
ElNotification({
title: t('popup.errors.main'),
message,
type: 'error'
});
});
return {
activateUser,
loading
};
}

View file

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

View file

@ -0,0 +1,40 @@
export function useMailClient() {
const mailClientUrl = ref<string | null>(null);
const mailClients = {
'gmail.com': 'https://mail.google.com/',
'outlook.com': 'https://outlook.live.com/',
'icloud.com': 'https://www.icloud.com/mail/',
'yahoo.com': 'https://mail.yahoo.com/',
'mail.ru': 'https://e.mail.ru/inbox/',
'yandex.ru': 'https://mail.yandex.ru/',
'proton.me': 'https://account.proton.me/mail',
'fastmail.com': 'https://fastmail.com/'
};
function detectMailClient(email: string) {
mailClientUrl.value = null;
if (!email) return;
const domain = email.split('@')[1];
Object.entries(mailClients).forEach((el) => {
if (domain === el[0]) mailClientUrl.value = el[1];
});
return mailClientUrl.value;
}
function openMailClient() {
if (mailClientUrl.value) {
window.open(mailClientUrl.value);
}
}
return {
mailClientUrl,
detectMailClient,
openMailClient
};
}

View file

@ -0,0 +1,12 @@
export function usePageTitle() {
const title = useState<string>('pageTitle', () => '')
function setPageTitle(value: string) {
title.value = value
useHead({
title: value
})
}
return { title, setPageTitle }
}

View file

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

View file

@ -0,0 +1,24 @@
import type {IWishlistResponse} from "~/types";
import {GET_WISHLIST} from "~/graphql/queries/standalone/wishlist";
export async function useWishlist() {
const wishlistStore = useWishlistStore();
const { data, error } = await useAsyncQuery<IWishlistResponse>(
GET_WISHLIST
);
if (!error.value && data.value?.wishlists.edges[0]) {
wishlistStore.setWishlist(data.value.wishlists.edges[0].node)
}
watch(error, (err) => {
if (err) {
console.error('useWishlist error:', err)
}
});
return {
};
}

14
storefront/config/i18n.ts Normal file
View file

@ -0,0 +1,14 @@
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from './constants';
import type { NuxtI18nOptions } from '@nuxtjs/i18n';
export const i18nConfig: NuxtI18nOptions = {
defaultLocale: DEFAULT_LOCALE,
locales: SUPPORTED_LOCALES,
strategy: 'prefix',
detectBrowserLanguage: {
alwaysRedirect: true,
redirectOn: 'root',
fallbackLocale: DEFAULT_LOCALE,
cookieKey: 'evibes-locale'
}
};

View file

@ -0,0 +1,23 @@
import combineQuery from 'graphql-combine-query'
import {GET_PRODUCTS} from "~/graphql/queries/standalone/products";
import {GET_CATEGORIES} from "~/graphql/queries/standalone/categories";
import {GET_BRANDS} from "~/graphql/queries/standalone/brands";
export const getCombinedSearch = (
productsVariables?: {
productName?: string;
},
categoriesVariables?: {
categoryName?: string;
},
brandsVariables?: {
brandName?: string;
}
) => {
const { document, variables } = combineQuery('getSearchedItems')
.add(GET_PRODUCTS, productsVariables || {})
.add(GET_CATEGORIES, categoriesVariables || {})
.add(GET_BRANDS, brandsVariables || {})
return { document, variables };
};

View file

@ -0,0 +1,24 @@
import combineQuery from 'graphql-combine-query'
import {GET_PRODUCTS} from "~/graphql/queries/standalone/products";
import {GET_CATEGORY_BY_SLUG} from "~/graphql/queries/standalone/categories";
export const getStore = (
productsVariables?: {
first?: number;
productAfter?: string;
categoriesSlug?: string;
orderby?: string;
minPrice?: number;
maxPrice?: number;
attributes?: string;
},
categoryVariables?: {
categorySlug?: string;
}
) => {
const { document, variables } = combineQuery('getStoreData')
.add(GET_PRODUCTS, productsVariables || {})
.add(GET_CATEGORY_BY_SLUG, categoryVariables || {})
return { document, variables };
};

View file

@ -0,0 +1,85 @@
import {CATEGORY_FRAGMENT} from "~/graphql/fragments/categories.fragment.js";
export const GET_CATEGORIES = gql`
query getCategories (
$level: Decimal,
$whole: Boolean,
$categoryAfter: String,
$categoryName: String
) {
categories (
level: $level,
whole: $whole,
after: $categoryAfter,
name: $categoryName
) {
edges {
cursor
node {
...Category
children {
...Category
children {
...Category
children {
...Category
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
${CATEGORY_FRAGMENT}
`
export const GET_CATEGORY_BY_SLUG = gql`
query getCategoryBySlug(
$categorySlug: String!
) {
categories(
slug: $categorySlug
) {
edges {
node {
...Category
filterableAttributes {
possibleValues
attributeName
}
minMaxPrices {
maxPrice
minPrice
}
}
}
}
}
${CATEGORY_FRAGMENT}
`
export const GET_CATEGORY_TAGS = gql`
query getCategoryTags {
categoryTags {
edges {
node {
uuid
tagName
name
categorySet {
edges {
node {
...Category
}
}
}
}
}
}
}
${CATEGORY_FRAGMENT}
`

View file

@ -0,0 +1,5 @@
{
"fields": {
"search": "Suche"
}
}

99
storefront/nuxt.config.ts Normal file
View file

@ -0,0 +1,99 @@
import { defineNuxtConfig } from 'nuxt/config';
import { i18nConfig } from './config/i18n';
import {fileURLToPath, URL} from "node:url";
import { resolve } from 'node:path';
export default defineNuxtConfig({
ssr: true,
devtools: { enabled: true },
typescript: { strict: true },
modules: [
"@nuxtjs/i18n",
"@nuxt/icon",
"@pinia/nuxt",
"@nuxtjs/apollo",
"@vueuse/nuxt",
"@element-plus/nuxt",
"nuxt-marquee",
"@nuxt/image"
],
i18n: i18nConfig,
apollo: {
autoImports: true,
clients: {
default: {
httpEndpoint: `https://api.${process.env.EVIBES_BASE_DOMAIN}/graphql/`,
connectToDevTools: true,
authType: 'Bearer',
authHeader: 'X-EVIBES-AUTH',
tokenStorage: 'cookie',
tokenName: `${process.env.EVIBES_PROJECT_NAME?.toLowerCase()}-access`,
}
},
},
runtimeConfig: {
public: {
evibesProjectName: process.env.EVIBES_PROJECT_NAME,
evibesBaseDomain: process.env.EVIBES_BASE_DOMAIN
},
},
app: {
head: {
charset: "utf-8",
viewport: "width=device-width, initial-scale=1",
title: process.env.EVIBES_PROJECT_NAME,
titleTemplate: `${process.env.EVIBES_PROJECT_NAME} | %s`,
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
]
},
pageTransition: {
name: 'opacity',
mode: 'out-in'
}
},
css: [
'./assets/styles/main.scss',
'./assets/styles/global/fonts.scss',
'swiper/css',
'swiper/css/effect-fade',
],
alias: {
'styles': fileURLToPath(new URL("./assets/styles", import.meta.url)),
'images': fileURLToPath(new URL("./assets/images", import.meta.url)),
'icons': fileURLToPath(new URL("./assets/icons", import.meta.url)),
},
vite: {
envDir: '../',
envPrefix: 'EVIBES_',
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/assets/styles/global/variables.scss" as *;
@use "@/assets/styles/global/mixins.scss" as *;
`
}
}
}
},
image: {
domains: [`https://api.${process.env.EVIBES_BASE_DOMAIN}`]
},
hooks: {
'pages:extend'(pages) {
pages.push(
{
name: 'activate-user',
path: '/activate-user',
file: resolve(__dirname, 'pages/index.vue')
},
{
name: 'reset-password',
path: '/reset-password',
file: resolve(__dirname, 'pages/index.vue')
}
)
}
}
})

View file

@ -0,0 +1,36 @@
<template>
<div class="brand">
<ui-title>{{ brand.name }}</ui-title>
<div class="brand__categories">
<cards-category
v-for="category in brand.categories"
:key="category.uuid"
:category="category"
/>
</div>
</div>
</template>
<script setup>
import {useBrandByUuid} from "~/composables/brands";
const route = useRoute()
const slug = computed(() => route.params.uuid)
const { brand } = await useBrandByUuid(slug.value);
// TODO: add product by this brand
</script>
<style lang="scss" scoped>
.brand {
&__categories {
display: grid;
grid-template-columns: repeat(auto-fill, 275px);
align-items: center;
justify-content: center;
gap: 50px;
}
}
</style>

View file

@ -0,0 +1,54 @@
<template>
<div class="category">
<ui-title>{{ category?.name }}</ui-title>
<div class="container">
<div class="category__wrapper">
<div class="category__list" v-if="category?.children?.length">
<cards-category
v-for="cat in category?.children || []"
:key="cat.uuid"
:category="cat"
/>
</div>
<store v-else />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ICategory} from "~/types";
import {usePageTitle} from "~/composables/utils";
const route = useRoute()
const categoryStore = useCategoryStore()
const { setPageTitle } = usePageTitle()
const slug = computed(() => route.params.slug as string)
const roots = computed(() => categoryStore.categories.map(e => e.node))
const category = computed(() => findBySlug(roots.value, slug.value))
setPageTitle(category.value?.name ?? 'Category')
function findBySlug(nodes: ICategory[], slug: string): ICategory | undefined {
for (const n of nodes) {
if (n.slug === slug) return n
if (n.children?.length) {
const found = findBySlug(n.children, slug)
if (found) return found
}
}
}
</script>
<style scoped lang="scss">
.category {
&__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: 20px;
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div class="catalog">
<ui-title>{{ t('catalog.title') }}</ui-title>
<div class="container">
<div class="catalog__wrapper">
<cards-category
v-for="category in categories"
:key="category.node.uuid"
:category="category.node"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n()
const categoryStore = useCategoryStore()
useHead({
title: t('breadcrumbs.catalog'),
})
const categories = computed(() => categoryStore.categories)
</script>
<style scoped lang="scss">
.catalog {
&__wrapper {
margin-top: 50px;
display: grid;
grid-template-columns: repeat(auto-fill, 275px);
align-items: center;
justify-content: center;
gap: 50px;
}
}
</style>

View file

@ -0,0 +1,28 @@
<template>
<div class="contact">
<ui-title>{{ t('contact.title') }}</ui-title>
<div class="container">
<div class="contact__wrapper">
<forms-contact />
</div>
</div>
</div>
</template>
<script setup>
import {usePageTitle} from "~/composables/utils";
const {t} = useI18n()
const { setPageTitle } = usePageTitle()
setPageTitle(t('breadcrumbs.contact'))
</script>
<style lang="scss" scoped>
.contact {
&__wrapper {
width: 50%;
margin-inline: auto;
}
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div class="home">
<home-hero />
<home-brands />
<home-collection />
<home-category-tags />
</div>
</template>
<script setup lang="ts">
import {useUserActivation} from "~/composables/user";
import { useRouteQuery } from '@vueuse/router';
const {t} = useI18n()
const appStore = useAppStore()
const route = useRoute()
useHead({
title: t('breadcrumbs.home'),
})
const token = useRouteQuery('token', '')
const uid = useRouteQuery('uid', '')
const { activateUser } = useUserActivation();
onMounted( async () => {
if (route.path.includes('activate-user') && token.value && uid.value) {
await activateUser(token.value, uid.value)
}
if (route.path.includes('reset-password') && token.value && uid.value) {
appStore.setActiveState('new-password')
}
})
</script>
<style lang="scss" scoped>
.home {
display: flex;
flex-direction: column;
gap: 125px;
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div class="product" v-if="product">
<ui-title>{{ product?.name }}</ui-title>
<div class="container">
<div class="product__wrapper">
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useProductBySlug} from "~/composables/products";
import {usePageTitle} from "~/composables/utils";
const route = useRoute()
const { setPageTitle } = usePageTitle()
const slug = route.params.slug as string
const { product } = await useProductBySlug(slug)
setPageTitle(product.value?.name ?? 'Product')
watch(
() => route.params.slug,
async (newSlug) => {
if (typeof newSlug === 'string') {
const { product } = await useProductBySlug(newSlug)
setPageTitle(product.value?.name ?? 'Product')
}
}
)
</script>
<style scoped lang="scss">
</style>

100
storefront/pages/search.vue Normal file
View file

@ -0,0 +1,100 @@
<template>
<div class="search">
<div class="container">
<div class="search__wrapper">
<div
v-for="(block, idx) in blocks"
:key="idx"
class="search__block"
>
<h6 class="search__block-title" v-if="hasData(block.key)">{{ t(block.title) }} {{ t('search.byRequest') }} "{{ q }}"</h6>
<div class="search__block-list" v-if="block.title.includes('products')">
<cards-product
v-for="product in data?.products.edges"
:key="product.node.uuid"
:product="product.node"
/>
</div>
<div class="search__block-list" v-if="block.title.includes('categories')">
<cards-category
v-for="category in data?.categories.edges"
:key="category.node.uuid"
:category="category.node"
/>
</div>
<div class="search__block-list" v-if="block.title.includes('brands')">
<cards-brand
v-for="brand in data?.brands.edges"
:key="brand.node.uuid"
:brand="brand.node"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useRouteQuery} from "@vueuse/router";
import {usePageTitle} from "~/composables/utils";
import {useSearchCombined} from "~/composables/search";
import type {ISearchCombinedResponse} from "~/types";
const {t} = useI18n();
const q = useRouteQuery('q', '');
const { setPageTitle } = usePageTitle();
setPageTitle(t('breadcrumbs.search'));
const { data } = await useSearchCombined(q.value);
type SearchResponseKey = keyof ISearchCombinedResponse;
const blocks = computed(() => {
if (!data.value) return [];
return (Object.keys(data.value) as SearchResponseKey[])
.map((key) => ({
key,
title: `search.${key}`
}));
});
const hasData = (blockKey: string): boolean => {
const validKey = blockKey as SearchResponseKey;
return (data.value?.[validKey]?.edges?.length ?? 0) > 0;
};
</script>
<style scoped lang="scss">
.search {
padding-top: 75px;
&__wrapper {
display: flex;
flex-direction: column;
gap: 50px;
}
&__block {
&-title {
padding-bottom: 10px;
border-bottom: 2px solid $accentDark;
color: $accentDark;
font-size: 28px;
}
&-list {
margin-top: 25px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(275px, 1fr));
justify-content: space-between;
grid-gap: 75px;
}
}
}
</style>

View file

@ -0,0 +1,9 @@
import { provideApolloClient } from '@vue/apollo-composable'
import type { ApolloClient } from '@apollo/client/core'
export default defineNuxtPlugin(() => {
const apollo = useApollo()
const defaultClient = apollo.clients!.default as ApolloClient<unknown>
provideApolloClient(defaultClient)
})

View file

@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="40px" viewBox="0 0 100.000000 100.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,100.000000) scale(0.100000,-0.100000)"
fill="#7965D1" stroke="none">
<path d="M678 935 c-73 -50 -88 -121 -38 -175 29 -31 50 -35 57 -13 2 6 -5 14
-16 18 -30 9 -26 48 9 88 63 72 130 72 149 -1 18 -67 -6 -117 -89 -182 -97
-76 -142 -97 -235 -109 -121 -16 -324 -29 -380 -24 -48 5 -49 4 -33 -13 26
-26 108 -34 248 -23 308 23 362 40 480 147 l65 59 0 64 c0 79 -17 114 -72 152
-61 41 -100 44 -145 12z"/>
<path d="M327 912 c-10 -10 -17 -27 -17 -38 0 -24 35 -64 55 -64 18 0 19 12 3
28 -16 16 19 54 46 50 17 -2 22 -11 24 -45 4 -55 -38 -105 -105 -124 -50 -14
-179 -17 -225 -6 -34 9 -36 -3 -6 -23 55 -35 251 -29 327 10 95 48 92 168 -6
219 -33 17 -78 13 -96 -7z"/>
<path d="M475 435 c-60 -8 -171 -19 -245 -25 -74 -7 -137 -14 -139 -16 -2 -2
9 -9 25 -16 35 -15 179 -13 309 3 50 7 146 12 215 13 186 2 223 -22 185 -119
-20 -53 -49 -78 -115 -100 -37 -12 -54 -14 -69 -5 -41 21 -16 91 36 105 27 6
27 7 9 21 -31 22 -69 17 -99 -14 -15 -15 -27 -34 -27 -42 0 -23 52 -90 81
-106 43 -22 73 -17 144 22 73 40 93 64 102 118 21 131 -138 193 -412 161z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View file

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

34
storefront/stores/app.ts Normal file
View file

@ -0,0 +1,34 @@
export const useAppStore = defineStore("app", () => {
const activeState = ref<string>('');
const setActiveState = (state: string) => {
activeState.value = state;
};
const unsetActiveState = () => {
activeState.value = '';
};
const isRegister = computed<boolean>(() => activeState.value === "register");
const isLogin = computed<boolean>(() => activeState.value === "login");
const isForgot = computed<boolean>(() => activeState.value === "reset-password");
const isReset = computed<boolean>(() => activeState.value === "new-password");
const isOverflowHidden = ref<boolean>(false);
const setOverflowHidden = (value: boolean) => {
isOverflowHidden.value = value;
}
return {
activeState,
setActiveState,
unsetActiveState,
isRegister,
isLogin,
isForgot,
isReset,
isOverflowHidden,
setOverflowHidden
};
});

View file

@ -0,0 +1,17 @@
import type {ICategory} from "~/types";
export const useCategoryStore = defineStore('category', () => {
const categories = ref<{ node: ICategory; }[] | []>([])
const setCategories = (payload: { node: ICategory; }[]) => {
categories.value = payload
};
const addCategories = (payload: { node: ICategory; }[]) => {
categories.value = [...categories.value, ...payload];
};
return {
categories,
setCategories,
addCategories
}
})

View file

@ -0,0 +1,13 @@
import type {ICompany} from "~/types";
export const useCompanyStore = defineStore('company', () => {
const companyInfo = ref<ICompany | null>(null);
const setCompanyInfo = (payload: ICompany) => {
companyInfo.value = payload
};
return {
companyInfo,
setCompanyInfo
}
})

View file

@ -0,0 +1,41 @@
import type {ILanguage} from "~/types";
import {useAppConfig} from "~/composables/config";
import {DEFAULT_LOCALE} from "~/config/constants";
export const useLanguageStore = defineStore('language', () => {
const { COOKIES_LOCALE_KEY } = useAppConfig();
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const localeFromCookies = computed(() => cookieLocale.value);
const languages = ref<ILanguage[] | null>(null);
const setLanguages = (payload: ILanguage[]) => {
languages.value = payload
};
const currentLocale = ref<ILanguage | null>(null);
const setCurrentLocale = (payload: ILanguage | null) => {
currentLocale.value = payload
};
watch(
() => localeFromCookies.value,
() => {
setCurrentLocale(languages.value?.find(l => l.code === localeFromCookies.value) as ILanguage)
}
)
return {
languages,
setLanguages,
currentLocale,
setCurrentLocale
}
})

26
storefront/stores/user.ts Normal file
View file

@ -0,0 +1,26 @@
import type {IUser} from "~/types";
import {useAppConfig} from "~/composables/config";
export const useUserStore = defineStore('user', () => {
const { COOKIES_ACCESS_TOKEN_KEY } = useAppConfig();
const cookieAccess = useCookie(
COOKIES_ACCESS_TOKEN_KEY,
{
default: () => '',
path: '/'
}
);
const user = ref<IUser | null>(null);
const isAuthenticated = computed(() => Boolean(cookieAccess.value && user.value));
const setUser = (data: IUser | null) => {
user.value = data;
};
return {
user,
setUser,
isAuthenticated
};
})

7
storefront/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": false,
"noUnusedParameters": false
}
}

View file

@ -0,0 +1,35 @@
import type {IUser} from "~/types";
export interface ILoginResponse {
obtainJwtToken: {
accessToken: string
refreshToken: string
user: IUser
}
}
export interface IRefreshResponse {
refreshJwtToken: {
accessToken: string
refreshToken: string
user: IUser
}
}
export interface IRegisterResponse {
createUser: {
success: boolean
}
}
export interface IPasswordResetResponse {
resetPassword: {
success: boolean
}
}
export interface INewPasswordResponse {
confirmResetPassword: {
success: boolean
}
}

View file

@ -0,0 +1,9 @@
import type {IBrand} from "~/types";
export interface IBrandsResponse {
brands: {
edges: {
node: IBrand
}[]
}
}

View file

@ -0,0 +1,40 @@
import type {ICategory, ICategoryTag, IStoreFilters} from "~/types";
export interface ICategoriesResponse {
categories: {
edges: {
cursor: string,
node: ICategory
}[],
pageInfo: {
hasNextPage: boolean
endCursor: string
}
}
}
export interface ICategoryBySlugResponse {
categories: {
edges: {
node:
ICategory &
{
filterableAttributes: IStoreFilters[]
} &
{
minMaxPrices: {
maxPrice: number
minPrice: number
}
}
}[]
}
}
export interface ICategoryTagsResponse {
categoryTags: {
edges: {
node: ICategoryTag
}[]
}
}

View file

@ -0,0 +1,5 @@
import type {ICompany} from "~/types";
export interface ICompanyResponse {
parameters: ICompany
}

View file

@ -0,0 +1,6 @@
export interface IContactUsResponse {
contactUs: {
error: boolean | null,
received: boolean | null
}
}

View file

@ -0,0 +1,5 @@
import type {ILanguage} from "~/types";
export interface ILanguagesResponse {
languages: ILanguage[];
}

View file

@ -0,0 +1,9 @@
import type {IOrder} from "~/types";
export interface IOrderResponse {
orders: {
edges: {
node: IOrder
}[]
}
}

View file

@ -0,0 +1,22 @@
import type {IProduct, IProductTag} from "~/types";
export interface IProductResponse {
products: {
edges: {
cursor: string,
node: IProduct
}[],
pageInfo: {
hasNextPage: boolean
endCursor: string
}
}
}
export interface IProductTagsResponse {
productTags: {
edges: {
node: IProductTag
}[]
}
}

View file

@ -0,0 +1,12 @@
import type {IBrandsResponse, ICategoriesResponse, IProductResponse, ISearchResults} from "~/types";
export interface ISearchResponse {
search: {
results: ISearchResults
}
}
export interface ISearchCombinedResponse
extends IProductResponse,
ICategoriesResponse,
IBrandsResponse {}

View file

@ -0,0 +1,6 @@
import type {ICategoryBySlugResponse, IProductResponse} from "~/types";
export interface IStoreResponse
extends IProductResponse,
ICategoryBySlugResponse {}

View file

@ -0,0 +1,13 @@
import type {IUser} from "~/types";
export interface IUserResponse {
updateUser: {
user: IUser
}
}
export interface IUserActivationResponse {
activateUser: {
success: boolean
}
}

Some files were not shown because too many files have changed in this diff Show more