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:
parent
426af1ad2c
commit
129ad1a6fa
113 changed files with 4856 additions and 0 deletions
75
storefront/README.md
Normal file
75
storefront/README.md
Normal 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
124
storefront/app.vue
Normal 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>
|
||||||
41
storefront/assets/styles/ui/collapse.scss
Normal file
41
storefront/assets/styles/ui/collapse.scss
Normal 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;
|
||||||
|
}
|
||||||
104
storefront/components/base/footer/index.vue
Normal file
104
storefront/components/base/footer/index.vue
Normal 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>
|
||||||
272
storefront/components/base/header/catalog.vue
Normal file
272
storefront/components/base/header/catalog.vue
Normal 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>
|
||||||
127
storefront/components/base/header/index.vue
Normal file
127
storefront/components/base/header/index.vue
Normal 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>
|
||||||
67
storefront/components/cards/brand.vue
Normal file
67
storefront/components/cards/brand.vue
Normal 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>
|
||||||
67
storefront/components/cards/category.vue
Normal file
67
storefront/components/cards/category.vue
Normal 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>
|
||||||
308
storefront/components/cards/product.vue
Normal file
308
storefront/components/cards/product.vue
Normal 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>
|
||||||
85
storefront/components/forms/contact.vue
Normal file
85
storefront/components/forms/contact.vue
Normal 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>
|
||||||
64
storefront/components/home/brands.vue
Normal file
64
storefront/components/home/brands.vue
Normal 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>
|
||||||
34
storefront/components/home/category-tags/block.vue
Normal file
34
storefront/components/home/category-tags/block.vue
Normal 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>
|
||||||
21
storefront/components/home/category-tags/index.vue
Normal file
21
storefront/components/home/category-tags/index.vue
Normal 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>
|
||||||
159
storefront/components/skeletons/cards/product.vue
Normal file
159
storefront/components/skeletons/cards/product.vue
Normal 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>
|
||||||
228
storefront/components/store/filter.vue
Normal file
228
storefront/components/store/filter.vue
Normal 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>
|
||||||
146
storefront/components/store/index.vue
Normal file
146
storefront/components/store/index.vue
Normal 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>
|
||||||
184
storefront/components/store/top.vue
Normal file
184
storefront/components/store/top.vue
Normal 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>
|
||||||
49
storefront/components/ui/breadcrumbs.vue
Normal file
49
storefront/components/ui/breadcrumbs.vue
Normal 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>
|
||||||
59
storefront/components/ui/loader.vue
Normal file
59
storefront/components/ui/loader.vue
Normal 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>
|
||||||
89
storefront/components/ui/textarea.vue
Normal file
89
storefront/components/ui/textarea.vue
Normal 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>
|
||||||
28
storefront/components/ui/title.vue
Normal file
28
storefront/components/ui/title.vue
Normal 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>
|
||||||
6
storefront/composables/auth/index.ts
Normal file
6
storefront/composables/auth/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from './useLogin'
|
||||||
|
export * from './useRefresh'
|
||||||
|
export * from './useRegister'
|
||||||
|
export * from './useLogout'
|
||||||
|
export * from './usePasswordReset'
|
||||||
|
export * from './useNewPassword'
|
||||||
92
storefront/composables/auth/useLogin.ts
Normal file
92
storefront/composables/auth/useLogin.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
36
storefront/composables/auth/useLogout.ts
Normal file
36
storefront/composables/auth/useLogout.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
59
storefront/composables/auth/useNewPassword.ts
Normal file
59
storefront/composables/auth/useNewPassword.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
48
storefront/composables/auth/usePasswordReset.ts
Normal file
48
storefront/composables/auth/usePasswordReset.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
76
storefront/composables/auth/useRefresh.ts
Normal file
76
storefront/composables/auth/useRefresh.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
82
storefront/composables/auth/useRegister.ts
Normal file
82
storefront/composables/auth/useRegister.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
2
storefront/composables/brands/index.ts
Normal file
2
storefront/composables/brands/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './useBrands'
|
||||||
|
export * from './useBrandByUuid'
|
||||||
21
storefront/composables/brands/useBrandByUuid.ts
Normal file
21
storefront/composables/brands/useBrandByUuid.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
storefront/composables/brands/useBrands.ts
Normal file
20
storefront/composables/brands/useBrands.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
storefront/composables/breadcrumbs/index.ts
Normal file
1
storefront/composables/breadcrumbs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useBreadcrumbs'
|
||||||
76
storefront/composables/breadcrumbs/useBreadcrumbs.ts
Normal file
76
storefront/composables/breadcrumbs/useBreadcrumbs.ts
Normal 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 }
|
||||||
|
}
|
||||||
3
storefront/composables/categories/index.ts
Normal file
3
storefront/composables/categories/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './useCategories'
|
||||||
|
export * from './useCategoryTags'
|
||||||
|
export * from './useCategoryBySlug'
|
||||||
37
storefront/composables/categories/useCategories.ts
Normal file
37
storefront/composables/categories/useCategories.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
27
storefront/composables/categories/useCategoryBySlug.ts
Normal file
27
storefront/composables/categories/useCategoryBySlug.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
20
storefront/composables/categories/useCategoryTags.ts
Normal file
20
storefront/composables/categories/useCategoryTags.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
25
storefront/composables/company/useCompanyInfo.ts
Normal file
25
storefront/composables/company/useCompanyInfo.ts
Normal 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 {
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
1
storefront/composables/config/index.ts
Normal file
1
storefront/composables/config/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useAppConfig'
|
||||||
15
storefront/composables/config/useAppConfig.ts
Normal file
15
storefront/composables/config/useAppConfig.ts
Normal 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`
|
||||||
|
};
|
||||||
|
};
|
||||||
1
storefront/composables/contact/index.ts
Normal file
1
storefront/composables/contact/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useContactUs'
|
||||||
53
storefront/composables/contact/useContactUs.ts
Normal file
53
storefront/composables/contact/useContactUs.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
3
storefront/composables/languages/index.ts
Normal file
3
storefront/composables/languages/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './useLocaleRedirect'
|
||||||
|
export * from './useLanguage'
|
||||||
|
export * from './useLanguageSwitch'
|
||||||
51
storefront/composables/languages/useLanguage.ts
Normal file
51
storefront/composables/languages/useLanguage.ts
Normal 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 {
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
54
storefront/composables/languages/useLanguageSwitch.ts
Normal file
54
storefront/composables/languages/useLanguageSwitch.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
37
storefront/composables/languages/useLocaleRedirect.ts
Normal file
37
storefront/composables/languages/useLocaleRedirect.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
1
storefront/composables/orders/index.ts
Normal file
1
storefront/composables/orders/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './usePendingOrder'
|
||||||
28
storefront/composables/orders/usePendingOrder.ts
Normal file
28
storefront/composables/orders/usePendingOrder.ts
Normal 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 {
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
26
storefront/composables/products/useProductBySlug.ts
Normal file
26
storefront/composables/products/useProductBySlug.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
storefront/composables/products/useProductTags.ts
Normal file
20
storefront/composables/products/useProductTags.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
29
storefront/composables/products/useProducts.ts
Normal file
29
storefront/composables/products/useProducts.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
1
storefront/composables/rules/index.ts
Normal file
1
storefront/composables/rules/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useFormValidation'
|
||||||
44
storefront/composables/rules/useFormValidation.ts
Normal file
44
storefront/composables/rules/useFormValidation.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
52
storefront/composables/search/useSearch.ts
Normal file
52
storefront/composables/search/useSearch.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
31
storefront/composables/search/useSearchCombined.ts
Normal file
31
storefront/composables/search/useSearchCombined.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
2
storefront/composables/store/index.ts
Normal file
2
storefront/composables/store/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './useStore'
|
||||||
|
export * from './useFilters'
|
||||||
91
storefront/composables/store/useFilters.ts
Normal file
91
storefront/composables/store/useFilters.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
69
storefront/composables/store/useStore.ts
Normal file
69
storefront/composables/store/useStore.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
1
storefront/composables/user/index.ts
Normal file
1
storefront/composables/user/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useUserActivation'
|
||||||
44
storefront/composables/user/useUserActivation.ts
Normal file
44
storefront/composables/user/useUserActivation.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
2
storefront/composables/utils/index.ts
Normal file
2
storefront/composables/utils/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './useMailClient'
|
||||||
|
export * from './usePageTitle'
|
||||||
40
storefront/composables/utils/useMailClient.ts
Normal file
40
storefront/composables/utils/useMailClient.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
12
storefront/composables/utils/usePageTitle.ts
Normal file
12
storefront/composables/utils/usePageTitle.ts
Normal 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 }
|
||||||
|
}
|
||||||
1
storefront/composables/wishlist/index.ts
Normal file
1
storefront/composables/wishlist/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useWishlist'
|
||||||
24
storefront/composables/wishlist/useWishlist.ts
Normal file
24
storefront/composables/wishlist/useWishlist.ts
Normal 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
14
storefront/config/i18n.ts
Normal 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'
|
||||||
|
}
|
||||||
|
};
|
||||||
23
storefront/graphql/queries/combined/searchPage.ts
Normal file
23
storefront/graphql/queries/combined/searchPage.ts
Normal 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 };
|
||||||
|
};
|
||||||
24
storefront/graphql/queries/combined/storePage.ts
Normal file
24
storefront/graphql/queries/combined/storePage.ts
Normal 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 };
|
||||||
|
};
|
||||||
85
storefront/graphql/queries/standalone/categories.ts
Normal file
85
storefront/graphql/queries/standalone/categories.ts
Normal 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}
|
||||||
|
`
|
||||||
5
storefront/i18n/locales/de-de.json
Normal file
5
storefront/i18n/locales/de-de.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"search": "Suche"
|
||||||
|
}
|
||||||
|
}
|
||||||
99
storefront/nuxt.config.ts
Normal file
99
storefront/nuxt.config.ts
Normal 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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
36
storefront/pages/brand/[uuid].vue
Normal file
36
storefront/pages/brand/[uuid].vue
Normal 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>
|
||||||
54
storefront/pages/catalog/[slug].vue
Normal file
54
storefront/pages/catalog/[slug].vue
Normal 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>
|
||||||
38
storefront/pages/catalog/index.vue
Normal file
38
storefront/pages/catalog/index.vue
Normal 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>
|
||||||
28
storefront/pages/contact.vue
Normal file
28
storefront/pages/contact.vue
Normal 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>
|
||||||
44
storefront/pages/index.vue
Normal file
44
storefront/pages/index.vue
Normal 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>
|
||||||
38
storefront/pages/product/[slug].vue
Normal file
38
storefront/pages/product/[slug].vue
Normal 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
100
storefront/pages/search.vue
Normal 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>
|
||||||
9
storefront/plugins/apollo.ts
Normal file
9
storefront/plugins/apollo.ts
Normal 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)
|
||||||
|
})
|
||||||
0
storefront/public/images/evibes-big-simple-white.png
Normal file
0
storefront/public/images/evibes-big-simple-white.png
Normal file
25
storefront/public/images/evibes.svg
Normal file
25
storefront/public/images/evibes.svg
Normal 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 |
2
storefront/public/robots.txt
Normal file
2
storefront/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
3
storefront/server/tsconfig.json
Normal file
3
storefront/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
34
storefront/stores/app.ts
Normal file
34
storefront/stores/app.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
17
storefront/stores/category.ts
Normal file
17
storefront/stores/category.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
13
storefront/stores/company.ts
Normal file
13
storefront/stores/company.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
41
storefront/stores/language.ts
Normal file
41
storefront/stores/language.ts
Normal 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
26
storefront/stores/user.ts
Normal 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
7
storefront/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
}
|
||||||
|
}
|
||||||
35
storefront/types/api/auth.ts
Normal file
35
storefront/types/api/auth.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
9
storefront/types/api/brands.ts
Normal file
9
storefront/types/api/brands.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type {IBrand} from "~/types";
|
||||||
|
|
||||||
|
export interface IBrandsResponse {
|
||||||
|
brands: {
|
||||||
|
edges: {
|
||||||
|
node: IBrand
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
40
storefront/types/api/categories.ts
Normal file
40
storefront/types/api/categories.ts
Normal 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
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
storefront/types/api/company.ts
Normal file
5
storefront/types/api/company.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type {ICompany} from "~/types";
|
||||||
|
|
||||||
|
export interface ICompanyResponse {
|
||||||
|
parameters: ICompany
|
||||||
|
}
|
||||||
6
storefront/types/api/contact.ts
Normal file
6
storefront/types/api/contact.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface IContactUsResponse {
|
||||||
|
contactUs: {
|
||||||
|
error: boolean | null,
|
||||||
|
received: boolean | null
|
||||||
|
}
|
||||||
|
}
|
||||||
5
storefront/types/api/languages.ts
Normal file
5
storefront/types/api/languages.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type {ILanguage} from "~/types";
|
||||||
|
|
||||||
|
export interface ILanguagesResponse {
|
||||||
|
languages: ILanguage[];
|
||||||
|
}
|
||||||
9
storefront/types/api/orders.ts
Normal file
9
storefront/types/api/orders.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type {IOrder} from "~/types";
|
||||||
|
|
||||||
|
export interface IOrderResponse {
|
||||||
|
orders: {
|
||||||
|
edges: {
|
||||||
|
node: IOrder
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
22
storefront/types/api/products.ts
Normal file
22
storefront/types/api/products.ts
Normal 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
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
storefront/types/api/search.ts
Normal file
12
storefront/types/api/search.ts
Normal 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 {}
|
||||||
6
storefront/types/api/store.ts
Normal file
6
storefront/types/api/store.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type {ICategoryBySlugResponse, IProductResponse} from "~/types";
|
||||||
|
|
||||||
|
|
||||||
|
export interface IStoreResponse
|
||||||
|
extends IProductResponse,
|
||||||
|
ICategoryBySlugResponse {}
|
||||||
13
storefront/types/api/user.ts
Normal file
13
storefront/types/api/user.ts
Normal 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
Loading…
Reference in a new issue