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