Features: 1) Add Apollo Client integration with support for locale-based headers; 2) Add Czech and German locale files; 3) Add Source Code Pro Bold font;

Fixes: None;

Extra: None;
This commit is contained in:
Alexandr SaVBaD Waltz 2025-05-31 18:24:02 +03:00
parent dbdf20b1b1
commit 8a8a1605ea
129 changed files with 4891 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import {ApolloClient, ApolloLink, createHttpLink, InMemoryCache} from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import {DEFAULT_LOCALE, LOCALE_STORAGE_ACCESS_TOKEN_KEY, LOCALE_STORAGE_LOCALE_KEY} from "@/config/index.js";
import {computed} from "vue";
const httpLink = createHttpLink({
uri: 'https://api.' + import.meta.env.EVIBES_BASE_DOMAIN + '/graphql/',
});
const userLocale = computed(() => {
return localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY)
});
export const createApolloClient = () => {
const accessToken = computed(() => {
return localStorage.getItem(LOCALE_STORAGE_ACCESS_TOKEN_KEY)
})
const authLink = setContext((_, { headers }) => {
const baseHeaders = {
...headers,
"Accept-language": userLocale.value ? userLocale.value : DEFAULT_LOCALE,
};
if (accessToken.value) {
baseHeaders["X-EVIBES-AUTH"] = `Bearer ${accessToken.value}`;
}
return { headers: baseHeaders };
})
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
}
}
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1,42 @@
/* ===== SOURCE CODE PRO ===== */
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Black.ttf');
font-weight: 900;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-ExtraBold.ttf');
font-weight: 800;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Regular.ttf');
font-weight: 400;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Light.ttf');
font-weight: 300;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-ExtraLight.ttf');
font-weight: 200;
}

View file

@ -0,0 +1,7 @@
@mixin hover {
@media (hover: hover) and (pointer: fine) {
&:hover {
@content;
}
}
}

View file

@ -0,0 +1,9 @@
$font_default: 'Source Code Pro', sans-serif;
$white: #ffffff;
$black: #000000;
$accent: #7965d1;
$accentLight: #a69cdc;
$accentDisabled: #826fa2;
$error: #f13838;
$default_border_radius: 4px;

View file

@ -0,0 +1,4 @@
@use "modules/normalize";
@use "modules/transitions";
@use "global/mixins";
@use "global/variables";

View file

@ -0,0 +1,49 @@
@use "../global/variables" as *;
* {
margin: 0;
padding: 0;
border: none;
box-sizing: border-box;
}
html {
overflow-x: hidden;
font-family: $font_default;
}
#app {
overflow-x: hidden;
position: relative;
}
a {
text-decoration: none;
color: inherit;
}
input, textarea, button {
font-family: $font_default;
outline: none;
}
button:focus-visible {
outline: none;
}
.container {
max-width: 1500px;
margin-inline: auto;
}
@media (max-width: 1680px) {
.container {
max-width: 1200px;
}
}
@media (max-width: 1300px) {
.container {
width: 90%;
}
}

View file

@ -0,0 +1,28 @@
.opacity-enter-active,
.opacity-leave-active {
transition: 0.3s ease all;
}
.opacity-enter-from,
.opacity-leave-to {
opacity: 0;
}
.fromTop-enter-active,
.fromTop-leave-active {
transition: all 0.3s ease;
}
.fromTop-enter-from,
.fromTop-leave-to {
transform: translateY(-3rem);
opacity: 0;
}
.fromLeft-enter-active,
.fromLeft-leave-active {
transition: all 0.3s ease;
}
.fromLeft-enter-from,
.fromLeft-leave-to {
transform: translateX(-3rem);
opacity: 0;
}

View file

@ -0,0 +1,65 @@
<template>
<div class="auth">
<div class="auth__content" ref="modalRef">
<slot></slot>
</div>
</div>
</template>
<script setup>
import {useAppStore} from "@/stores/app.js";
import {ref} from "vue";
import {onClickOutside} from "@vueuse/core";
const appStore = useAppStore()
const closeModal = () => {
appStore.setActiveState(null)
}
const modalRef = ref(null)
onClickOutside(modalRef, () => closeModal())
</script>
<style lang="scss" scoped>
.auth {
position: fixed;
z-index: 3;
width: 100vw;
height: 100vh;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
background-color: rgba(0, 0, 0, 0.4);
&__content {
position: absolute;
z-index: 2;
top: 125px;
background-color: $white;
width: 600px;
padding: 30px;
border-radius: $default_border_radius;
}
}
@media (max-width: 1000px) {
.auth {
&__content {
width: 85%;
}
}
}
@media (max-width: 500px) {
.auth {
&__content {
padding: 20px 30px;
}
}
}
</style>

View file

@ -0,0 +1,19 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer_wrapper">
</div>
</div>
</footer>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.footer {
}
</style>

View file

@ -0,0 +1,43 @@
<template>
<div class="catalogue">
<button class="catalogue__button">
{{ t('header.catalogue.title') }}
<span></span>
</button>
</div>
</template>
<script setup>
import {useI18n} from "vue-i18n";
const {t} = useI18n()
</script>
<style lang="scss" scoped>
.catalogue {
&__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 {
font-size: 26px;
}
}
}
</style>

View file

@ -0,0 +1,237 @@
<template>
<div class="search">
<div
@click="toggleSearch(true)"
class="search__wrapper"
:class="[{ active: isSearchActive }]"
>
<form class="search__form" @submit.prevent="submitSearch">
<input
type="text"
v-model="query"
:placeholder="t('fields.search')"
/>
<div class="search__tools">
<button
type="button"
@click="clearSearch"
v-if="query"
>
<i class="pi pi-times"></i>
</button>
<div class="search__tools-line" v-if="query"></div>
<button type="submit">
<i class="pi pi-search"></i>
</button>
</div>
</form>
<div class="search__results" :class="[{ active: (searchResults && isSearchActive) || loading }]">
<header-search-skeleton v-if="loading" />
<div
class="search__results-inner"
v-for="(blocks, item) in filteredSearchResults"
:key="item"
>
<div class="search__results-title">
<p>{{ getBlockTitle(item) }}:</p>
</div>
<div
class="search__item"
v-for="item in blocks"
:key="item.uuid"
>
<div class="search__item-left">
<i class="pi pi-search"></i>
<p>{{ item.name }}</p>
</div>
<i class="pi pi-external-link"></i>
</div>
</div>
<div class="search__results-empty" v-if="!hasResults && query && !loading">
<p>{{ t('header.search.empty') }}</p>
</div>
</div>
</div>
<div
class="search__bg"
@click="toggleSearch(false)"
v-if="isSearchActive"
/>
</div>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import HeaderSearchSkeleton from "@/components/skeletons/header/header-search-skeleton.vue";
import { useSearchUI } from "@/composables/search";
import {useRouter} from "vue-router";
const {t} = useI18n();
const router = useRouter();
const {
query,
isSearchActive,
loading,
searchResults,
filteredSearchResults,
hasResults,
getBlockTitle,
clearSearch,
toggleSearch
} = useSearchUI();
function submitSearch() {
if (query.value) {
router.push({
name: 'search',
query: { q: query.value }
});
toggleSearch(false);
}
}
</script>
<style lang="scss" scoped>
.search {
width: 100%;
position: relative;
height: 45px;
&__bg {
background-color: #0000001a;
height: 100vh;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 1;
}
&__wrapper {
width: 100%;
background-color: #f7f7f7;
border-radius: $default_border_radius;
transition: 0.2s;
position: relative;
z-index: 2;
&.active {
background-color: $white;
box-shadow: 0 0 0 1px #0000000a,0 4px 4px #0000000a,0 20px 40px #00000014;
}
@include hover {
background-color: $white;
box-shadow: 0 0 0 1px #0000000a,0 4px 4px #0000000a,0 20px 40px #00000014;
}
}
&__form {
width: 100%;
height: 45px;
position: relative;
& input {
background-color: transparent;
width: 100%;
height: 100%;
padding-inline: 20px 150px;
border: 1px solid #dedede;
border-radius: $default_border_radius;
font-size: 16px;
font-weight: 500;
}
}
&__tools {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 10px;
& button {
cursor: pointer;
border-radius: $default_border_radius;
padding: 5px 12px;
border: 1px solid $accent;
background-color: rgba($accent, 0.2);
transition: 0.2s;
font-size: 12px;
color: $accent;
@include hover {
background-color: rgba($accent, 1);
color: $white;
}
}
&-line {
background-color: $accent;
height: 15px;
width: 1px;
}
}
&__results {
max-height: 0;
overflow: auto;
transition: 0.2s;
&.active {
max-height: 40vh;
}
&-title {
background-color: rgba($accent, 0.2);
padding: 7px 20px;
font-weight: 600;
}
&-empty {
padding: 10px 20px;
font-size: 14px;
}
}
&__item {
cursor: pointer;
padding: 7px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
transition: 0.2s;
font-size: 14px;
@include hover {
background-color: #efefef;
}
&-left {
display: flex;
align-items: center;
gap: 15px;
}
& p {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
& i {
color: #7c7c7c;
}
}
}
</style>

View file

@ -0,0 +1,129 @@
<template>
<header class="header">
<router-link :to="translations.i18nRoute({ name: 'home' })">
<img class="header__logo" src="@images/evibes-big-simple.png" alt="logo">
</router-link>
<base-header-catalogue />
<base-header-search />
<div class="header__actions">
<router-link :to="translations.i18nRoute({ name: 'wishlist' })" class="header__actions-item">
<div>
<ui-counter>0</ui-counter>
<!-- <counter-skeleton />-->
<i class="pi pi-heart"></i>
</div>
<p>{{ t('header.actions.wishlist') }}</p>
</router-link>
<router-link :to="translations.i18nRoute({ name: 'cart' })" class="header__actions-item">
<div>
<ui-counter>0</ui-counter>
<!-- <counter-skeleton />-->
<i class="pi pi-shopping-cart"></i>
</div>
<p>{{ t('header.actions.cart') }}</p>
</router-link>
<router-link
:to="translations.i18nRoute({ name: 'home' })"
class="header__actions-item"
v-if="isAuthenticated"
>
<i class="pi pi-user"></i>
<p>{{ t('header.actions.user') }}</p>
</router-link>
<div
class="header__actions-item"
@click="appStore.setActiveState('login')"
v-else
>
<i class="pi pi-user"></i>
<p>{{ t('header.actions.user') }}</p>
</div>
</div>
<ui-language-switcher />
</header>
</template>
<script setup>
import {computed, onMounted} from "vue";
import {useCategories} from "@/composables/categories/index.js";
import translations from "@/core/helpers/translations.js";
import {useI18n} from "vue-i18n";
import BaseHeaderSearch from "@/components/base/header/base-header-search.vue";
import UiLanguageSwitcher from "@/components/ui/ui-language-switcher.vue";
import {useUserStore} from "@/stores/user.js";
import {useAppStore} from "@/stores/app.js";
import UiCounter from "@/components/ui/ui-counter.vue";
import CounterSkeleton from "@/components/skeletons/ui/counter-skeleton.vue";
import BaseHeaderCatalogue from "@/components/base/header/base-header-catalogue.vue";
//TODO: add categories to header
const {t} = useI18n()
const userStore = useUserStore()
const appStore = useAppStore()
const isAuthenticated = computed(() => userStore.user)
const { categories, loading, getCategories } = useCategories();
onMounted(async () => {
await getCategories()
})
</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;
&__logo {
width: 150px;
}
&__actions {
display: flex;
align-items: center;
gap: 15px;
&-item {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 7px 10px;
border-radius: $default_border_radius;
transition: 0.2s;
@include hover {
background-color: #f7f7f7;
color: $accent;
}
& div {
position: relative;
}
& i {
transition: 0.2s;
font-size: 24px;
}
& p {
transition: 0.2s;
font-size: 12px;
}
}
}
}
</style>

View file

@ -0,0 +1,127 @@
<template>
<div class="card">
<router-link
:to="translations.i18nRoute({ name: 'product', params: { productSlug: product.slug } })"
>
<img
class="card__image"
:src="product.images.edges[0].node.image"
:alt="product.name"
>
</router-link>
<div class="card__content">
<p class="card__price">{{ product.price }}</p>
<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 class="card__bottom">
<ui-button class="card__bottom-button">
{{ t('buttons.addToCart') }}
</ui-button>
<div class="card__bottom-wishlist">
<i class="pi pi-heart"></i>
<!-- <i class="pi pi-heart-fill"></i>-->
</div>
</div>
</div>
</div>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import {computed} from "vue";
import UiButton from "@/components/ui/ui-button.vue";
import translations from "@/core/helpers/translations.js";
const props = defineProps({
product: Object
})
const {t} = useI18n()
const rating = computed(() => {
return props.product.feedbacks.edges[0] ? props.product.feedbacks.edges[0]?.node?.rating : 5
})
</script>
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 1px solid $black;
width: 340px;
background-color: $white;
transition: 0.2s;
position: relative;
@include hover {
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.4);
}
&__image {
width: 100%;
height: 300px;
object-fit: cover;
border-radius: $default_border_radius;
}
&__content {
padding: 20px;
}
&__price {
font-weight: 700;
font-size: 22px;
}
&__name {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
font-weight: 500;
font-size: 18px;
}
&__quantity {
font-size: 14px;
}
&__bottom {
margin-top: 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;
}
}
}
}
</style>

View file

@ -0,0 +1,88 @@
<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 {useI18n} from "vue-i18n";
import {required} from "@/core/rules/textFieldRules.js";
import {computed, ref} from "vue";
import UiInput from "@/components/ui/ui-input.vue";
import UiButton from "@/components/ui/ui-button.vue";
import UiTextarea from "@/components/ui/ui-textarea.vue";
import {useContactUs} from "@/composables/contact/index.js";
const {t} = useI18n()
const name = ref('')
const email = ref('')
const phoneNumber = ref('')
const subject = ref('')
const message = ref('')
const isFormValid = computed(() => {
return (
required(name.value) === true &&
required(email.value) === true &&
required(phoneNumber.value) === true &&
required(subject.value) === true &&
required(message.value) === true
)
})
const { contactUs, loading } = useContactUs();
async function handleContactUs() {
await contactUs(
name.value,
email.value,
phoneNumber.value,
subject.value,
message.value,
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<form @submit.prevent="handleDeposit()" class="form">
<div class="form__box">
<ui-input
:type="'text'"
:placeholder="''"
v-model="amount"
:numberOnly="true"
/>
<ui-input
:type="'text'"
:placeholder="''"
v-model="amount"
:numberOnly="true"
/>
</div>
<ui-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.topUp') }}
</ui-button>
</form>
</template>
<script setup>
import UiInput from "@/components/ui/ui-input.vue";
import {computed, ref} from "vue";
import UiButton from "@/components/ui/ui-button.vue";
import {useI18n} from "vue-i18n";
import {useDeposit} from "@/composables/user/useDeposit.js";
import {useCompanyStore} from "@/stores/company.js";
const {t} = useI18n()
const companyStore = useCompanyStore()
const paymentMin = computed(() => companyStore.companyInfo?.paymentGatewayMinimum)
const paymentMax = computed(() => companyStore.companyInfo?.paymentGatewayMaximum)
const amount = ref('')
const isFormValid = computed(() => {
return (
amount.value >= paymentMin.value &&
amount.value <= paymentMax.value
)
})
const { deposit, loading } = useDeposit();
async function handleDeposit() {
await deposit(amount.value);
}
</script>
<style lang="scss" scoped>
.form {
&__box {
display: flex;
align-items: flex-start;
gap: 20px;
}
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<form @submit.prevent="handleLogin()" class="form">
<h2 class="form__title">{{ t('forms.login.title') }}</h2>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.password')"
:rules="[required]"
v-model="password"
/>
<ui-checkbox
v-model="isStayLogin"
>
{{ t('checkboxes.remember') }}
</ui-checkbox>
<ui-link
@click="appStore.setActiveState('reset-password')"
>
{{ t('forms.login.forgot') }}
</ui-link>
<ui-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.login') }}
</ui-button>
<p class="form__register">
{{ t('forms.login.register') }}
<ui-link
@click="appStore.setActiveState('register')"
>
{{ t('forms.register.title') }}
</ui-link>
</p>
</form>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import {isEmail, required} from "@/core/rules/textFieldRules.js";
import {computed, ref} from "vue";
import UiInput from "@/components/ui/ui-input.vue";
import UiButton from "@/components/ui/ui-button.vue";
import UiCheckbox from "@/components/ui/ui-checkbox.vue";
import {useLogin} from "@/composables/auth";
import UiLink from "@/components/ui/ui-link.vue";
import {useAppStore} from "@/stores/app.js";
const {t} = useI18n()
const appStore = useAppStore()
const email = ref('')
const password = ref('')
const isStayLogin = ref(false)
const isFormValid = computed(() => {
return (
isEmail(email.value) === true &&
required(password.value) === true
)
})
const { login, loading } = useLogin();
async function handleLogin() {
await login(email.value, password.value, isStayLogin.value);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__title {
font-size: 36px;
color: $accent;
}
&__register {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
}
</style>

View file

@ -0,0 +1,72 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<h2 class="form__title">{{ t('forms.newPassword.title') }}</h2>
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.save') }}
</ui-button>
</form>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import {isPasswordValid,} from "@/core/rules/textFieldRules.js";
import {computed, ref} from "vue";
import UiInput from "@/components/ui/ui-input.vue";
import UiButton from "@/components/ui/ui-button.vue";
import {useNewPassword} from "@/composables/auth";
const {t} = useI18n()
const password = ref('')
const confirmPassword = ref('')
const compareStrings = (v) => {
if (v === password.value) return true
return t('errors.compare')
}
const isFormValid = computed(() => {
return (
isPasswordValid(password.value) === true &&
compareStrings(confirmPassword.value) === true
)
})
const { newPassword, loading } = useNewPassword();
async function handleReset() {
await newPassword(
password.value,
confirmPassword.value,
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__title {
font-size: 36px;
color: $accent;
}
}
</style>

View file

@ -0,0 +1,137 @@
<template>
<form @submit.prevent="handleRegister()" class="form">
<h2 class="form__title">{{ t('forms.register.title') }}</h2>
<div class="form__box">
<ui-input
:type="'text'"
:placeholder="t('fields.firstName')"
:rules="[required]"
v-model="firstName"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.lastName')"
:rules="[required]"
v-model="lastName"
/>
</div>
<div class="form__box">
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
</div>
<ui-input
:type="'password'"
:placeholder="t('fields.password')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.register') }}
</ui-button>
<p class="form__login">
{{ t('forms.register.login') }}
<ui-link
@click="appStore.setActiveState('login')"
>
{{ t('forms.login.title') }}
</ui-link>
</p>
</form>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import {isEmail, isPasswordValid, required} from "@/core/rules/textFieldRules.js";
import {computed, ref} from "vue";
import UiInput from "@/components/ui/ui-input.vue";
import UiButton from "@/components/ui/ui-button.vue";
import {useRegister} from "@/composables/auth/index.js";
import UiLink from "@/components/ui/ui-link.vue";
import {useAppStore} from "@/stores/app.js";
const {t} = useI18n()
const appStore = useAppStore()
const firstName = ref('')
const lastName = ref('')
const phoneNumber = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const compareStrings = (v) => {
if (v === password.value) return true
return t('errors.compare')
}
const isFormValid = computed(() => {
return (
required(firstName.value) === true &&
required(lastName.value) === true &&
required(phoneNumber.value) === true &&
isEmail(email.value) === true &&
isPasswordValid(password.value) === true &&
compareStrings(confirmPassword.value) === true
)
})
const { register, loading } = useRegister();
async function handleRegister() {
await register(
firstName.value,
lastName.value,
phoneNumber.value,
email.value,
password.value,
confirmPassword.value
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__title {
font-size: 36px;
color: $accent;
}
&__box {
display: flex;
align-items: center;
gap: 20px;
}
&__login {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<h2 class="form__title">{{ t('forms.reset.title') }}</h2>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
<ui-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.sendLink') }}
</ui-button>
</form>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import {isEmail} from "@/core/rules/textFieldRules.js";
import {computed, ref} from "vue";
import UiInput from "@/components/ui/ui-input.vue";
import UiButton from "@/components/ui/ui-button.vue";
import {usePasswordReset} from "@/composables/auth";
const {t} = useI18n()
const email = ref('')
const isFormValid = computed(() => {
return (
isEmail(email.value) === true
)
})
const { resetPassword, loading } = usePasswordReset();
async function handleReset() {
await resetPassword(email.value);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__title {
font-size: 36px;
color: $accent;
}
}
</style>

View file

@ -0,0 +1,104 @@
<template>
<form class="form" @submit.prevent="handleUpdate()">
<ui-input
:type="'text'"
:placeholder="t('fields.firstName')"
:rules="[required]"
v-model="firstName"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.lastName')"
:rules="[required]"
v-model="lastName"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-button
class="form__button"
:isLoading="loading"
>
{{ t('buttons.save') }}
</ui-button>
</form>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import {isEmail, isPasswordValid, required} from "@/core/rules/textFieldRules.js";
import {computed, ref, watchEffect} from "vue";
import UiInput from "@/components/ui/ui-input.vue";
import UiButton from "@/components/ui/ui-button.vue";
import {useUserStore} from "@/stores/user.js";
import {useUserUpdating} from "@/composables/user";
const {t} = useI18n()
const userStore = useUserStore()
const userFirstName = computed(() => userStore.user?.firstName)
const userLastName = computed(() => userStore.user?.lastName)
const userEmail = computed(() => userStore.user?.email)
const userPhoneNumber = computed(() => userStore.user?.phoneNumber)
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const phoneNumber = ref('')
const password = ref('')
const confirmPassword = ref('')
const compareStrings = (v) => {
if (v === password.value) return true
return t('errors.compare')
}
const { updateUser, loading } = useUserUpdating();
watchEffect(() => {
firstName.value = userFirstName.value || ''
lastName.value = userLastName.value || ''
email.value = userEmail.value || ''
phoneNumber.value = userPhoneNumber.value || ''
})
async function handleUpdate() {
await updateUser(
firstName.value,
lastName.value,
email.value,
phoneNumber.value,
password.value,
confirmPassword.value,
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<div class="brands">
<div class="container">
<div class="brands__wrapper">
<vue-marquee-slider
id="marquee-slider"
:speed="40000"
:paused="isMarqueePaused"
@mouseenter="isMarqueePaused = true"
@mouseleave="isMarqueePaused = false"
>
<div
class="brands__item"
v-for="brand in brands"
:key="brand.node.uuid"
>
<p>{{ brand.node.name }}</p>
</div>
</vue-marquee-slider>
</div>
</div>
</div>
</template>
<script setup>
import { VueMarqueeSlider } from 'vue3-marquee-slider';
import '../../../node_modules/vue3-marquee-slider/dist/style.css'
import {onMounted, ref} from "vue";
import {useBrands} from "@/composables/brands/index.js";
const isMarqueePaused = ref(false)
const { brands, loading, getBrands } = useBrands();
onMounted( async () => {
await getBrands()
})
</script>
<style lang="scss" scoped>
.brands {
&__item {
margin-right: 20px;
flex-shrink: 0;
}
}
</style>

View file

@ -0,0 +1,106 @@
<template>
<div class="tag">
<h2 class="tag__title">{{ tag.name }}</h2>
<div class="tag__block">
<div class="tag__block-inner">
<swiper
class="swiper"
:effect="'cards'"
:grabCursor="true"
:modules="[EffectCards, Mousewheel]"
:cardsEffect="{
slideShadows: false
}"
:mousewheel="true"
>
<swiper-slide
class="swiper__slide"
v-for="product in tag.productSet.edges"
:key="product.node.uuid"
>
<product-card
:product="product.node"
/>
</swiper-slide>
</swiper>
</div>
</div>
</div>
</template>
<script setup>
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectCards, Mousewheel } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/scrollbar';
import ProductCard from "@/components/cards/product-card.vue";
const props = defineProps({
tag: Object
})
const swiperOptions = {
speed: 500,
spaceBetween: 30,
slidesPerView: 3,
scrollbar: {
hide: true,
}
}
</script>
<style lang="scss" scoped>
.tag {
width: 500px;
&__title {
margin-bottom: 10px;
text-align: center;
color: $accent;
font-size: 56px;
font-weight: 700;
}
&__block {
border-radius: $default_border_radius;
background-color: $accentLight;
padding: 10px;
&-inner {
border-radius: $default_border_radius;
border: 5px solid $white;
padding-inline:20px;
}
}
}
.swiper {
width: 100%;
padding-block: 30px;
&__slide {
display: grid;
place-items: center;
& .card {
&:after {
content: '';
position: absolute;
z-index: 2;
inset: 0;
background-color: rgba(0, 0, 0, 0.2);
width: 100%;
height: 100%;
transition: 0.2s;
}
}
}
}
:deep(.swiper-slide-active) {
& .card:after {
background-color: transparent;
z-index: -1;
}
}
</style>

View file

@ -0,0 +1,104 @@
<template>
<div class="collection">
<div class="container">
<div class="collection__wrapper">
<h2 class="collection__title">{{ t('home.collection.title') }}</h2>
<div class="collection__inner">
<home-collection-inner
v-for="tag in tags"
:key="tag.uuid"
:tag="tag.node"
/>
<home-collection-inner
v-if="newProducts.length > 0"
:tag="newProductsTag"
/>
<home-collection-inner
v-if="priceProducts.length > 0"
:tag="priceProductsTag"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {useI18n} from "vue-i18n";
import HomeCollectionInner from "@/components/home/home-collection-inner.vue";
import {useProducts, useProductTags} from "@/composables/products";
import {computed, onMounted} from "vue";
const {t} = useI18n()
const { tags, loading: tagsLoading, getProductTags } = useProductTags();
const {
products: newProducts,
loading: newLoading,
getProducts: getNewProducts
} = useProducts();
const {
products: priceProducts,
loading: priceLoading,
getProducts: getPriceProducts
} = useProducts();
const newProductsTag = computed(() => {
return {
name: t('home.collection.newTag'),
uuid: 'new-products',
productSet: {
edges: newProducts.value
}
}
});
const priceProductsTag = computed(() => {
return {
name: t('home.collection.cheapTag'),
uuid: 'price-products',
productSet: {
edges: priceProducts.value
}
}
});
onMounted( async () => {
await getProductTags()
await Promise.all([
getProductTags(),
getNewProducts({
orderBy: '-modified'
}),
getPriceProducts({
orderBy: '-price'
})
]);
})
</script>
<style lang="scss" scoped>
.collection {
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
&__title {
font-size: 72px;
font-weight: 900;
color: #dd6878;
}
&__inner {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
gap: 100px;
}
}
</style>

View file

@ -0,0 +1,42 @@
<template>
<div class="hero">
<div class="container">
<div class="hero__wrapper">
<img src="@images/evibes-big.png" alt="logo">
</div>
</div>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.hero {
background-image: url(@images/homeBg.png);
background-repeat: no-repeat;
-webkit-background-size: cover;
background-size: cover;
position: relative;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba($black, 0.5);
backdrop-filter: blur(5px);
}
&__wrapper {
position: relative;
z-index: 1;
padding-block: 100px;
display: grid;
place-items: center;
}
}
</style>

View file

@ -0,0 +1,31 @@
<template>
<el-skeleton class="sk" animated>
<template #template>
<el-skeleton-item
variant="p"
class="sk__text"
v-for="idx in 3"
:key="idx"
/>
</template>
</el-skeleton>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
display: flex;
flex-direction: column;
gap: 15px;
padding: 10px 20px;
&__text {
width: 100%;
height: 32px;
}
}
</style>

View file

@ -0,0 +1,30 @@
<template>
<el-skeleton class="sk" animated>
<template #template>
<el-skeleton-item
variant="p"
class="sk__text"
/>
</template>
</el-skeleton>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.sk {
width: 20px;
height: 20px;
position: absolute !important;
top: -10px;
right: -15px;
&__text {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
</style>

View file

@ -0,0 +1,26 @@
<template>
<el-skeleton class="sk" animated>
<template #template>
<el-skeleton-item
variant="p"
class="sk__text"
/>
</template>
</el-skeleton>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.sk {
width: 40px;
height: 20px;
&__text {
width: 100%;
height: 100%;
}
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<button
class="button"
:disabled="isDisabled"
:class="[{active: isLoading}]"
type="submit"
>
<ui-loader class="button__loader" v-if="isLoading" />
<slot v-else />
</button>
</template>
<script setup>
import UiLoader from "@/components/ui/ui-loader.vue";
const props = defineProps({
isDisabled: Boolean,
isLoading: Boolean
})
</script>
<style lang="scss" scoped>
.button {
position: relative;
width: 100%;
cursor: pointer;
flex-shrink: 0;
transition: 0.2s;
border: 1px solid $accent;
background-color: $accent;
border-radius: $default_border_radius;
padding-block: 7px;
display: grid;
place-items: center;
z-index: 1;
color: $white;
text-align: center;
font-size: 14px;
font-weight: 700;
@include hover {
background-color: $accentLight;
}
&.active {
background-color: $accentLight;
}
&:disabled {
cursor: not-allowed;
background-color: $accentDisabled;
color: $white;
}
&:disabled:hover, &.active {
background-color: $accentDisabled;
color: $white;
}
&__loader {
margin-bottom: 10px;
}
}
</style>

View file

@ -0,0 +1,80 @@
<template>
<div class="checkbox">
<input
:id="id ? `checkbox + id` : 'checkbox'"
class="checkbox__input"
type="checkbox"
:value="modelValue"
@input="onInput"
:checked="modelValue"
>
<span class="checkbox__block" @click="toggleCheckbox"></span>
<label :for="id ? `checkbox + id` : 'checkbox'" class="checkbox__label">
<slot />
</label>
</div>
</template>
<script setup>
const $emit = defineEmits()
const props = defineProps({
id: [Number, String],
modelValue: Boolean
})
const onInput = (event) => {
$emit('update:modelValue', event.target.checked);
};
const toggleCheckbox = () => {
$emit('update:modelValue', !props.modelValue);
};
</script>
<style lang="scss" scoped>
.checkbox {
display: flex;
align-items: center;
gap: 5px;
&__input {
display: none;
opacity: 0;
}
&__block {
cursor: pointer;
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid $black;
border-radius: $default_border_radius;
position: relative;
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background-color: $accent;
border-radius: 2px;
opacity: 0;
}
}
&__label {
cursor: pointer;
color: #2B2B2B;
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
}
.checkbox__input:checked + .checkbox__block::after {
opacity: 1;
}
</style>

View file

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

View file

@ -0,0 +1,148 @@
<template>
<div class="block">
<div class="block__inner">
<input
:placeholder="placeholder"
:type="isPasswordVisible"
:value="modelValue"
@input="onInput"
@keydown="numberOnly ? onlyNumbersKeydown($event) : null"
class="block__input"
>
<button
@click.prevent="setPasswordVisible"
class="block__eyes"
v-if="type === 'password' && modelValue"
>
<i v-if="isPasswordVisible === 'password'" class="pi pi-eye-slash"></i>
<i v-else class="pi pi-eye"></i>
</button>
</div>
<p v-if="!validate" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup>
import {ref} from "vue";
const $emit = defineEmits()
const props = defineProps({
type: String,
placeholder: String,
isError: Boolean,
error: String,
modelValue: [String, Number],
rules: Array,
numberOnly: Boolean
})
const isPasswordVisible = ref(props.type)
const setPasswordVisible = () => {
if (isPasswordVisible.value === 'password') {
isPasswordVisible.value = 'text'
return
}
isPasswordVisible.value = 'password'
}
const onlyNumbersKeydown = (event) => {
if (!/^\d$/.test(event.key) &&
!['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
}
}
const validate = ref(true)
const errorMessage = ref('')
const onInput = (e) => {
let value = e.target.value;
if (props.numberOnly) {
const newValue = value.replace(/\D/g, '');
if (newValue !== value) {
e.target.value = newValue;
value = newValue;
}
}
let result = true
props.rules?.forEach((rule) => {
result = rule((e.target).value)
if (result !== true) {
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;
&__inner {
width: 100%;
position: relative;
}
&__input {
width: 100%;
padding: 6px 12px;
border: 1px solid #e0e0e0;
//border: 1px solid #b2b2b2;
border-radius: $default_border_radius;
background-color: $white;
color: #1f1f1f;
font-size: 12px;
font-weight: 400;
line-height: 20px;
&::placeholder {
color: #2B2B2B;
}
}
&__eyes {
cursor: pointer;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
background-color: transparent;
display: grid;
place-items: center;
font-size: 18px;
color: #838383;
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

@ -0,0 +1,125 @@
<template>
<div class="switcher" ref="switcherRef">
<div
@click="setSwitcherVisible(!isSwitcherVisible)"
class="switcher__button"
:class="[{ active: isSwitcherVisible }]"
>
<img
v-if="currentLocale"
:src="currentLocale.flag"
:alt="currentLocale.code"
>
<language-switcher-skeleton v-else />
</div>
<div
class="switcher__menu"
:class="[{active: isSwitcherVisible}]"
>
<img
class="switcher__menu-button"
v-for="locale of locales"
:key="locale.code"
@click="switchLanguage(locale.code)"
:src="locale.flag"
:alt="locale.code"
/>
</div>
</div>
</template>
<script setup>
import {computed, ref} from "vue";
import {onClickOutside} from "@vueuse/core";
import {useLanguageStore} from "@/stores/languages.js";
import {useLanguageSwitch} from "@/composables/languages/index.js";
import LanguageSwitcherSkeleton from "@/components/skeletons/ui/language-switcher-skeleton.vue";
const languageStore = useLanguageStore()
const locales = computed(() => languageStore.languages)
const currentLocale = computed(() => languageStore.currentLocale)
const isSwitcherVisible = ref(false)
const setSwitcherVisible = (state) => {
isSwitcherVisible.value = state
}
const switcherRef = ref(null)
onClickOutside(switcherRef, () => isSwitcherVisible.value = false)
const { switchLanguage } = useLanguageSwitch()
</script>
<style lang="scss" scoped>
.switcher {
position: relative;
z-index: 1;
width: 52px;
flex-shrink: 0;
&__button {
width: 100%;
cursor: pointer;
display: flex;
gap: 5px;
border: 1px solid $accent;
background-color: #ddd9ef;
padding: 5px;
border-radius: $default_border_radius;
transition: 0.2s;
@include hover {
background-color: $accent;
}
&.active {
background-color: $accent;
}
& img {
width: 100%;
}
}
&__menu {
position: absolute;
z-index: 3;
top: 110%;
left: 50%;
transform: translateX(-50%);
border: 0 solid $accent;
display: flex;
flex-direction: column;
width: 100%;
max-height: 0;
overflow: hidden;
transition: 0.3s;
border-radius: $default_border_radius;
&.active {
max-height: 1000px;
border: 1px solid $accent;
}
&-button {
width: 100%;
cursor: pointer;
padding: 5px 8px;
background-color: #ddd9ef;
transition: 0.1s;
&:first-child {
padding-top: 10px;
}
&:last-child {
padding-bottom: 10px;
}
&:hover {
background-color: $accent;
}
}
}
}
</style>

View file

@ -0,0 +1,42 @@
<template>
<div @click="redirect" class="link">
<slot></slot>
</div>
</template>
<script setup>
import {useRouter} from "vue-router";
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY} from "@/config/index.js";
const router = useRouter()
const props = defineProps({
routeName: String
})
const redirect = () => {
if (props.routeName) {
router.push({
name: props.routeName,
params: {
locale: localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY) || DEFAULT_LOCALE
}
})
}
}
</script>
<style lang="scss" scoped>
.link {
width: fit-content;
transition: 0.2s;
cursor: pointer;
color: $accent;
font-size: 12px;
font-weight: 500;
@include hover {
color: #5539ce;
}
}
</style>

View file

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

View file

@ -0,0 +1,90 @@
<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>
import {ref} from "vue";
const $emit = defineEmits()
const props = defineProps({
placeholder: String,
isError: Boolean,
error: String,
modelValue: [String, Number],
rules: Array
})
const validate = ref(true)
const errorMessage = ref('')
const onInput = (e) => {
let result = true
props.rules?.forEach((rule) => {
result = rule((e.target).value)
if (result !== true) {
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 $black;
background-color: $white;
color: #1f1f1f;
font-size: 12px;
font-weight: 400;
line-height: 20px;
&::placeholder {
color: #2B2B2B;
}
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

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

View file

@ -0,0 +1,93 @@
import {useMutation} from "@vue/apollo-composable";
import {LOGIN} from "@/graphql/mutations/auth.js";
import {ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useUserStore} from "@/stores/user.js";
import translations from "@/core/helpers/translations.js";
import {
DEFAULT_LOCALE, LOCALE_STORAGE_ACCESS_TOKEN_KEY,
LOCALE_STORAGE_LOCALE_KEY,
LOCALE_STORAGE_REFRESH_TOKEN_KEY,
} from "@/config/index.js";
import {useRoute, useRouter} from "vue-router";
import {usePendingOrder} from "@/composables/orders";
import {useWishlist} from "@/composables/wishlist";
export function useLogin() {
const router = useRouter();
const route = useRoute();
const userStore = useUserStore()
const {t} = useI18n();
const { mutate: loginMutation } = useMutation(LOGIN);
const { getPendingOrder } = usePendingOrder();
const { getWishlist } = useWishlist();
const loading = ref(false);
async function login(
email,
password,
isStayLogin
) {
loading.value = true;
try {
const response = await loginMutation({
email,
password
});
if (isStayLogin) {
localStorage.setItem(LOCALE_STORAGE_REFRESH_TOKEN_KEY, response.data.obtainJwtToken.refreshToken)
}
if (response.data?.obtainJwtToken) {
userStore.setUser({
user: response.data.obtainJwtToken.user
});
localStorage.setItem(LOCALE_STORAGE_ACCESS_TOKEN_KEY, response.data.obtainJwtToken.accessToken)
ElNotification({
message: t('popup.success.login'),
type: 'success'
})
await router.push({
name: 'home',
params: {
locale: localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY) || DEFAULT_LOCALE
}
})
if (response.data.obtainJwtToken.user.language !== translations.currentLocale) {
translations.switchLanguage(response.data.obtainJwtToken.user.language, router, route)
}
await getPendingOrder(response.data.obtainJwtToken.user.email);
await getWishlist();
}
} catch (error) {
console.error("useLogin error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
login,
loading
};
}

View file

@ -0,0 +1,33 @@
import {useUserStore} from "@/stores/user.js";
import {
DEFAULT_LOCALE, LOCALE_STORAGE_ACCESS_TOKEN_KEY,
LOCALE_STORAGE_LOCALE_KEY,
LOCALE_STORAGE_REFRESH_TOKEN_KEY
} from "@/config/index.js";
import {useRouter} from "vue-router";
export function useLogout() {
const userStore = useUserStore()
const router = useRouter()
async function logout() {
userStore.setUser({
user: null,
accessToken: null
})
localStorage.removeItem(LOCALE_STORAGE_REFRESH_TOKEN_KEY)
localStorage.removeItem(LOCALE_STORAGE_ACCESS_TOKEN_KEY)
await router.push({
name: 'home',
params: {
locale: localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY) || DEFAULT_LOCALE
}
})
}
return {
logout
};
}

View file

@ -0,0 +1,73 @@
import {useMutation} from "@vue/apollo-composable";
import {NEW_PASSWORD} from "@/graphql/mutations/auth.js";
import {computed, ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useRoute, useRouter} from "vue-router";
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY} from "@/config/index.js";
export function useNewPassword() {
const {t} = useI18n();
const route = useRoute();
const router = useRouter();
const { mutate: newPasswordMutation } = useMutation(NEW_PASSWORD);
const token = computed(() =>
route.query.token ? (route.query.token) : undefined,
);
const uid = computed(() =>
route.query.uid ? (route.query.uid) : undefined,
);
const loading = ref(false);
async function newPassword(
password,
confirmPassword
) {
loading.value = true;
try {
const response = await newPasswordMutation({
password,
confirmPassword,
token: token.value,
uid: uid.value
});
if (response.data?.confirmResetPassword.success) {
ElNotification({
message: t('popup.success.newPassword'),
type: 'success'
})
await router.push({
name: 'home',
params: {
locale: localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY) || DEFAULT_LOCALE
}
})
}
} catch (error) {
console.error("useNewPassword error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
newPassword,
loading
};
}

View file

@ -0,0 +1,51 @@
import {useMutation} from "@vue/apollo-composable";
import {RESET_PASSWORD} from "@/graphql/mutations/auth.js";
import {ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
export function usePasswordReset() {
const {t} = useI18n();
const { mutate: resetPasswordMutation } = useMutation(RESET_PASSWORD);
const loading = ref(false);
async function resetPassword(
email
) {
loading.value = true;
try {
const response = await resetPasswordMutation({
email
});
if (response.data?.resetPassword.success) {
ElNotification({
message: t('popup.success.reset'),
type: 'success'
})
}
} catch (error) {
console.error("usePasswordReset error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
resetPassword,
loading
};
}

View file

@ -0,0 +1,74 @@
import {useMutation} from "@vue/apollo-composable";
import {REFRESH} from "@/graphql/mutations/auth.js";
import {computed, ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useUserStore} from "@/stores/user.js";
import {LOCALE_STORAGE_REFRESH_TOKEN_KEY} from "@/config/index.js";
import {useRoute, useRouter} from "vue-router";
import translations from "@/core/helpers/translations.js";
import {usePendingOrder} from "@/composables/orders";
import {useWishlist} from "@/composables/wishlist";
export function useRefresh() {
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const {t} = useI18n();
const { mutate: refreshMutation } = useMutation(REFRESH);
const { getPendingOrder } = usePendingOrder();
const { getWishlist } = useWishlist();
const loading = ref(false);
async function refresh() {
loading.value = true;
const refreshToken = computed(() => localStorage.getItem(LOCALE_STORAGE_REFRESH_TOKEN_KEY))
if (!refreshToken.value) return
try {
const response = await refreshMutation({
refreshToken: refreshToken.value
});
if (response.data?.refreshJwtToken) {
userStore.setUser({
user: response.data.refreshJwtToken.user,
accessToken: response.data.refreshJwtToken.accessToken
})
if (response.data.refreshJwtToken.user.language !== translations.currentLocale) {
translations.switchLanguage(response.data.refreshJwtToken.user.language, router, route)
}
localStorage.setItem(LOCALE_STORAGE_REFRESH_TOKEN_KEY, response.data.refreshJwtToken.refreshToken)
await getPendingOrder(response.data.refreshJwtToken.user.email);
await getWishlist();
}
} catch (error) {
console.error("useRefresh error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
refresh,
loading
};
}

View file

@ -0,0 +1,85 @@
import {useMutation} from "@vue/apollo-composable";
import {REGISTER} from "@/graphql/mutations/auth.js";
import {h, ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useMailClient} from "@/composables/utils";
export function useRegister() {
const {t} = useI18n();
const { mutate: registerMutation } = useMutation(REGISTER);
const { mailClientUrl, detectMailClient, openMailClient } = useMailClient();
const loading = ref(false);
async function register(
firstName,
lastName,
phoneNumber,
email,
password,
confirmPassword
) {
loading.value = true;
try {
const response = await registerMutation({
firstName,
lastName,
phoneNumber,
email,
password,
confirmPassword
});
if (response.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'
})
}
} catch (error) {
console.error("useRegister error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
register,
loading
};
}

View file

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

View file

@ -0,0 +1,24 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {GET_POST_BY_SLUG} from "@/graphql/queries/blog.js";
import {computed} from "vue";
export function usePostbySlug() {
const { result, loading, error, load } = useLazyQuery(GET_POST_BY_SLUG);
const post = computed(() => result.value?.posts.edges[0].node ?? []);
if (error.value) {
console.error("usePostbySlug error:", error.value);
}
const getPost = (slug) => {
return load(null, { slug });
};
return {
post,
loading,
error,
getPost
};
}

View file

@ -0,0 +1,20 @@
import { useLazyQuery } from "@vue/apollo-composable";
import { GET_POSTS } from "@/graphql/queries/blog.js";
import {computed} from "vue";
export function usePosts() {
const { result, loading, error, load } = useLazyQuery(GET_POSTS);
const posts = computed(() => result.value?.posts.edges ?? []);
if (error.value) {
console.error("usePosts error:", error.value);
}
return {
posts,
loading,
error,
getPosts: load
};
}

View file

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

View file

@ -0,0 +1,24 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {computed} from "vue";
import {GET_BRAND_BY_UUID} from "@/graphql/queries/brands.js";
export function useBrandByUuid() {
const { result, loading, error, load } = useLazyQuery(GET_BRAND_BY_UUID);
const brand = computed(() => result.value?.brands.edges[0].node ?? []);
if (error.value) {
console.error("usePostbySlug error:", error.value);
}
const getBrand = (uuid) => {
return load(null, { uuid });
};
return {
brand,
loading,
error,
getBrand
};
}

View file

@ -0,0 +1,20 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {computed} from "vue";
import {GET_BRANDS} from "@/graphql/queries/brands.js";
export function useBrands() {
const { result, loading, error, load } = useLazyQuery(GET_BRANDS);
const brands = computed(() => result.value?.brands.edges ?? []);
if (error.value) {
console.error("useBrands error:", error.value);
}
return {
brands,
loading,
error,
getBrands: load
};
}

View file

@ -0,0 +1,2 @@
export * from './useCategories.js'
export * from './useCategorybySlug.js'

View file

@ -0,0 +1,20 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {computed} from "vue";
import {GET_CATEGORIES} from "@/graphql/queries/categories.js";
export function useCategories() {
const { result, loading, error, load } = useLazyQuery(GET_CATEGORIES);
const categories = computed(() => result.value?.categories.edges ?? []);
if (error.value) {
console.error("useCategories error:", error.value);
}
return {
categories,
loading,
error,
getCategories: load
};
}

View file

@ -0,0 +1,24 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {computed} from "vue";
import {GET_CATEGORY_BY_SLUG} from "@/graphql/queries/categories.js";
export function useCategorybySlug() {
const { result, loading, error, load } = useLazyQuery(GET_CATEGORY_BY_SLUG);
const category = computed(() => result.value?.categories.edges[0].node ?? []);
if (error.value) {
console.error("useCategorybySlug error:", error.value);
}
const getCategory = (slug) => {
return load(null, { slug });
};
return {
category,
loading,
error,
getCategory
};
}

View file

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

View file

@ -0,0 +1,26 @@
import {useLazyQuery} from "@vue/apollo-composable";
import {GET_COMPANY_INFO} from "@/graphql/queries/company.js";
import {useCompanyStore} from "@/stores/company.js";
import {watchEffect} from "vue";
export function useCompanyInfo() {
const companyStore = useCompanyStore()
const { result, loading, error, load } = useLazyQuery(GET_COMPANY_INFO);
if (error.value) {
console.error("useCompanyInfo error:", error.value);
}
watchEffect(() => {
if (result.value?.parameters) {
companyStore.setCompanyInfo(result.value.parameters);
}
});
return {
loading,
error,
getCompanyInfo: load
};
}

View file

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

View file

@ -0,0 +1,59 @@
import {useMutation} from "@vue/apollo-composable";
import {ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {CONTACT_US} from "@/graphql/mutations/contact.js";
export function useContactUs() {
const {t} = useI18n();
const { mutate: contactUsMutation } = useMutation(CONTACT_US);
const loading = ref(false);
async function contactUs(
name,
email,
phoneNumber,
subject,
message
) {
loading.value = true;
try {
const response = await contactUsMutation({
name,
email,
phoneNumber,
subject,
message
});
if (response.data?.contactUs.received) {
ElNotification({
message: t('popup.success.contactUs'),
type: 'success'
})
}
} catch (error) {
console.error("useContactUs error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
contactUs,
loading
};
}

View file

@ -0,0 +1,2 @@
export * from './useLanguageSwitch.js'
export * from './useLanguages.js'

View file

@ -0,0 +1,62 @@
import {useMutation} from "@vue/apollo-composable";
import {computed, ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useUserStore} from "@/stores/user.js";
import translations from "@/core/helpers/translations.js";
import {SWITCH_LANGUAGE} from "@/graphql/mutations/languages.js";
import {LOCALE_STORAGE_ACCESS_TOKEN_KEY} from "@/config/index.js";
export function useLanguageSwitch() {
const userStore = useUserStore()
const {t} = useI18n();
const { mutate: languageSwitchMutation } = useMutation(SWITCH_LANGUAGE);
const accessToken = computed(() => localStorage.getItem(LOCALE_STORAGE_ACCESS_TOKEN_KEY))
const userUuid = computed(() => userStore.user?.uuid)
const loading = ref(false);
async function switchLanguage(
locale
) {
loading.value = true;
try {
translations.switchLanguage(locale)
if (accessToken.value) {
const response = await languageSwitchMutation(
userUuid.value,
locale
);
if (response.data?.updateUser) {
userStore.setUser({
user: response.data.updateUser.user,
accessToken: accessToken.value
})
}
}
} catch (error) {
console.error("useLanguageSet error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
switchLanguage,
loading
};
}

View file

@ -0,0 +1,35 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {watchEffect} from "vue";
import {GET_LANGUAGES} from "@/graphql/queries/languages.js";
import {useLanguageStore} from "@/stores/languages.js";
import {LOCALE_STORAGE_LOCALE_KEY, SUPPORTED_LOCALES} from "@/config/index.js";
export function useLanguages() {
const languageStore = useLanguageStore()
const { result, loading, error, load } = useLazyQuery(GET_LANGUAGES);
if (error.value) {
console.error("useLanguages error:", error.value);
}
watchEffect(() => {
if (result.value?.languages) {
languageStore.setLanguages(
result.value.languages.filter((locale) =>
SUPPORTED_LOCALES.some(supportedLocale =>
supportedLocale.code === locale.code
)
)
);
languageStore.setCurrentLocale(languageStore.languages.find((locale) => locale.code === localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY)))
}
});
return {
loading,
error,
getLanguages: load
};
}

View file

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

View file

@ -0,0 +1,24 @@
import {useMutation} from "@vue/apollo-composable";
import {GET_ORDERS} from "@/graphql/queries/orders.js";
import {useCartStore} from "@/stores/cart.js";
export function usePendingOrder() {
const cartStore = useCartStore()
const { mutate: pendingOrderMutation } = useMutation(GET_ORDERS);
async function getPendingOrder(userEmail) {
const response = await pendingOrderMutation({
status: "PENDING",
userEmail
});
if (!response.errors) {
cartStore.setCurrentOrders(response.data.orders.edges[0].node)
}
}
return {
getPendingOrder
};
}

View file

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

View file

@ -0,0 +1,24 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {computed} from "vue";
import {GET_PRODUCT_BY_SLUG} from "@/graphql/queries/products.js";
export function useProductbySlug() {
const { result, loading, error, load } = useLazyQuery(GET_PRODUCT_BY_SLUG);
const product = computed(() => result.value?.products.edges[0].node ?? []);
if (error.value) {
console.error("useProductbySlug error:", error.value);
}
const getProduct = (slug) => {
return load(null, { slug });
};
return {
product,
loading,
error,
getProduct
};
}

View file

@ -0,0 +1,20 @@
import { useLazyQuery } from "@vue/apollo-composable";
import {computed} from "vue";
import {GET_PRODUCT_BY_SLUG, GET_PRODUCT_TAGS} from "@/graphql/queries/products.js";
export function useProductTags() {
const { result, loading, error, load } = useLazyQuery(GET_PRODUCT_TAGS);
const tags = computed(() => result.value?.productTags.edges ?? []);
if (error.value) {
console.error("useProductTags error:", error.value);
}
return {
tags,
loading,
error,
getProductTags: load
};
}

View file

@ -0,0 +1,47 @@
import { useLazyQuery } from "@vue/apollo-composable";
import { computed, ref } from "vue";
import { GET_PRODUCTS } from "@/graphql/queries/products.js";
export function useProducts() {
const variables = ref({
first: 12
});
const { result, loading, error, load } = useLazyQuery(
GET_PRODUCTS,
() => variables.value
);
const products = computed(() => result.value?.products.edges ?? []);
const pageInfo = computed(() => result.value?.products.pageInfo ?? {});
if (error.value) {
console.error("useProducts error:", error.value);
}
const getProducts = async (params = {}) => {
const newVariables = { ...variables.value };
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
newVariables[key] = value;
}
});
variables.value = newVariables;
if (result.value) {
await refetch();
} else {
await load();
}
};
return {
products,
pageInfo,
loading,
error,
getProducts
};
}

View file

@ -0,0 +1,2 @@
export * from './useSearch.js'
export * from './useSearchUi.js'

View file

@ -0,0 +1,52 @@
import {useMutation} from "@vue/apollo-composable";
import {ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {SEARCH} from "@/graphql/mutations/search.js";
export function useSearch() {
const {t} = useI18n();
const { mutate: searchMutation } = useMutation(SEARCH);
const loading = ref(false);
const searchResults = ref(null);
async function search(
query
) {
loading.value = true;
searchResults.value = null;
try {
const response = await searchMutation({
query
});
if (response.data?.search) {
searchResults.value = response.data.search.results;
return response.data.search;
}
} catch (error) {
console.error("useSearch error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
search,
loading,
searchResults
};
}

View file

@ -0,0 +1,67 @@
import { computed, ref, watch } from 'vue';
import { useSearch } from './useSearch.js';
import { useDebounceFn } from '@vueuse/core';
export function useSearchUI() {
const query = ref('');
const isSearchActive = ref(false);
const { search, loading, searchResults } = useSearch();
const filteredSearchResults = computed(() => {
if (!searchResults.value) return {};
return Object.entries(searchResults.value)
.reduce((acc, [category, blocks]) => {
if (blocks.length > 0) {
acc[category] = blocks;
}
return acc;
}, {});
});
const hasResults = computed(() => {
if (!searchResults.value) return false;
return Object.keys(searchResults.value).some(key => {
return Array.isArray(searchResults.value[key]) &&
searchResults.value[key].length > 0;
});
});
function getBlockTitle(category) {
return category.charAt(0).toUpperCase() + category.slice(1);
}
function clearSearch() {
query.value = '';
searchResults.value = null;
}
function toggleSearch(value) {
isSearchActive.value = value !== undefined ? value : !isSearchActive.value;
}
const debouncedSearch = useDebounceFn(async () => {
if (query.value) {
await search(query.value);
} else {
searchResults.value = null;
}
}, 750);
watch(() => query.value, async () => {
await debouncedSearch();
}, { immediate: false });
return {
query,
isSearchActive,
loading,
searchResults,
filteredSearchResults,
hasResults,
getBlockTitle,
clearSearch,
toggleSearch
};
}

View file

@ -0,0 +1,3 @@
export * from './useUserUpdating.js';
export * from './useUserActivation.js';
export * from '../languages/useLanguageSwitch.js';

View file

@ -0,0 +1,48 @@
import {useMutation} from "@vue/apollo-composable";
import {ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {DEPOSIT} from "@/graphql/mutations/deposit.js";
export function useDeposit() {
const {t} = useI18n();
const { mutate: depositMutation } = useMutation(DEPOSIT);
const loading = ref(false);
async function deposit(
amount
) {
loading.value = true;
try {
const response = await depositMutation(
amount
);
if (response.data?.deposit) {
window.open(response.data.deposit.transaction.process.url)
}
} catch (error) {
console.error("useDeposit error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
deposit,
loading
};
}

View file

@ -0,0 +1,59 @@
import {useMutation} from "@vue/apollo-composable";
import {computed, ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import {ACTIVATE_USER} from "@/graphql/mutations/user.js";
export function useUserActivation() {
const {t} = useI18n();
const route = useRoute();
const { mutate: userActivationMutation } = useMutation(ACTIVATE_USER);
const token = computed(() =>
route.query.token ? (route.query.token) : undefined,
);
const uid = computed(() =>
route.query.uid ? (route.query.uid) : undefined,
);
const loading = ref(false);
async function activateUser() {
loading.value = true;
try {
const response = await userActivationMutation({
token: token.value,
uid: uid.value
});
if (response.data?.activateUser) {
ElNotification({
message: t("popup.activationSuccess"),
type: "success"
});
}
} catch (error) {
console.error("useUserActivation error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
activateUser,
loading
};
}

View file

@ -0,0 +1,121 @@
import {useMutation} from "@vue/apollo-composable";
import {computed, ref} from "vue";
import {ElNotification} from "element-plus";
import {useI18n} from "vue-i18n";
import {useUserStore} from "@/stores/user.js";
import translations from "@/core/helpers/translations.js";
import {useRoute, useRouter} from "vue-router";
import {useLogout} from "@/composables/auth";
import {UPDATE_USER} from "@/graphql/mutations/user.js";
import {LOCALE_STORAGE_ACCESS_TOKEN_KEY} from "@/config/index.js";
export function useUserUpdating() {
const router = useRouter();
const route = useRoute();
const userStore = useUserStore()
const {t} = useI18n();
const { mutate: userUpdatingMutation } = useMutation(UPDATE_USER);
const { logout } = useLogout();
const accessToken = computed(() => localStorage.getItem(LOCALE_STORAGE_ACCESS_TOKEN_KEY))
const userUuid = computed(() => userStore.user?.uuid)
const userEmail = computed(() => userStore.user?.email)
const loading = ref(false);
async function updateUser(
firstName,
lastName,
email,
phoneNumber,
password,
confirmPassword
) {
loading.value = true;
try {
const fields = {
uuid: userUuid.value,
firstName,
lastName,
email,
phoneNumber,
password,
confirmPassword
};
const params = Object.fromEntries(
Object.entries(fields).filter(([_, value]) =>
value !== undefined && value !== null && value !== ''
)
);
// if (('password' in params && !('passwordConfirm' in params)) ||
// (!('password' in params) && 'passwordConfirm' in params)) {
// ElNotification({
// title: t('popup.errors.main'),
// message: t('popup.errors.noDataToUpdate'),
// type: 'error'
// });
// }
if (Object.keys(params).length === 0) {
ElNotification({
title: t('popup.errors.main'),
message: t('popup.errors.noDataToUpdate'),
type: 'error'
});
}
const response = await userUpdatingMutation(
params
);
if (response.data?.updateUser) {
if (userEmail.value !== email) {
await logout()
ElNotification({
message: t("popup.success.confirmEmail"),
type: "success"
});
} else {
userStore.setUser({
user: response.data.updateUser.user,
accessToken: accessToken.value
})
ElNotification({
message: t("popup.successUpdate"),
type: "success"
});
if (response.data.updateUser.user.language !== translations.currentLocale) {
translations.switchLanguage(response.data.updateUser.user.language, router, route)
}
}
}
} catch (error) {
console.error("useUserUpdating error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.errors.defaultError');
ElNotification({
title: t('popup.errors.main'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
updateUser,
loading
};
}

View file

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

View file

@ -0,0 +1,42 @@
import { ref } from 'vue';
export function useMailClient() {
const mailClientUrl = ref(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) {
mailClientUrl.value = null;
if (!email) return;
const domain = email.split('@')[1];
Object.entries(mailClients).forEach((el) => {
if (domain === el[0]) mailClientUrl.value = el[1];
});
return mailClientUrl.value;
}
function openMailClient() {
if (mailClientUrl.value) {
window.open(mailClientUrl.value);
}
}
return {
mailClientUrl,
detectMailClient,
openMailClient
};
}

View file

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

View file

@ -0,0 +1,21 @@
import {useMutation} from "@vue/apollo-composable";
import {GET_WISHLIST} from "@/graphql/queries/wishlist.js";
import {useWishlistStore} from "@/stores/wishlist.js";
export function useWishlist() {
const wishlistStore = useWishlistStore()
const { mutate: wishlistMutation } = useMutation(GET_WISHLIST);
async function getWishlist() {
const response = await wishlistMutation();
if (!response.errors) {
wishlistStore.setWishlist(response.data.wishlists.edges[0].node)
}
}
return {
getWishlist
};
}

View file

@ -0,0 +1,88 @@
// APP
export const APP_NAME = import.meta.env.EVIBES_PROJECT_NAME
export const APP_NAME_KEY = APP_NAME.toLowerCase()
// LOCALES
export const SUPPORTED_LOCALES = [
{
code: 'en-gb',
default: true
},
{
code: 'ar-ar',
default: false
},
{
code: 'cs-cz',
default: false
},
{
code: 'da-dk',
default: false
},
{
code: 'de-de',
default: false
},
{
code: 'en-us',
default: false
},
{
code: 'es-es',
default: false
},
{
code: 'fr-fr',
default: false
},
{
code: 'it-it',
default: false
},
{
code: 'ja-jp',
default: false
},
{
code: 'nl-nl',
default: false
},
{
code: 'pl-pl',
default: false
},
{
code: 'pt-br',
default: false
},
{
code: 'ro-ro',
default: false
},
{
code: 'ru-ru',
default: false
},
{
code: 'zh-hans',
default: false
}
]
export const DEFAULT_LOCALE = SUPPORTED_LOCALES.find(locale => locale.default)?.code || 'en-gb'
// LOCAL STORAGE
export const LOCALE_STORAGE_LOCALE_KEY = `${APP_NAME_KEY}-user-locale`;
export const LOCALE_STORAGE_REFRESH_TOKEN_KEY = `${APP_NAME_KEY}-refresh`;
export const LOCALE_STORAGE_ACCESS_TOKEN_KEY = `${APP_NAME_KEY}-access`;

View file

@ -0,0 +1,30 @@
export async function loadLocaleMessages(locale) {
try {
const messages = await import(`../locales/${locale}.json`)
return messages.default || messages
} catch (error) {
console.error(`Не удалось загрузить локаль: ${locale}`, error)
return {}
}
}
export function getLocaleFilename(localeCode, localesConfig) {
const localeInfo = localesConfig.find(locale => locale.code === localeCode)
return localeInfo?.file || `${localeCode}.json`
}
export async function loadAllLocaleMessages(supportedLocales) {
const messages = {}
for (const locale of supportedLocales) {
try {
const localeMessages = await import(`../../locales/${locale.code}.json`)
messages[locale.code] = localeMessages.default || localeMessages
} catch (error) {
console.error(`Не удалось загрузить локаль: ${locale.code}`, error)
messages[locale.code] = {}
}
}
return messages
}

View file

@ -0,0 +1,107 @@
import i18n from '@/core/plugins/i18n.config';
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY, SUPPORTED_LOCALES} from "@/config/index.js";
const translations = {
get currentLocale() {
return i18n.global.locale.value
},
set currentLocale(newLocale) {
i18n.global.locale.value = newLocale
},
switchLanguage(newLocale, router = null, route = null) {
translations.currentLocale = newLocale
document.querySelector('html').setAttribute('lang', newLocale)
localStorage.setItem(LOCALE_STORAGE_LOCALE_KEY, newLocale)
if (router && route) {
const newRoute = {
...route,
params: {
...route.params,
locale: newLocale
}
};
router.push(newRoute).catch(err => {
if (err.name !== 'NavigationDuplicated') {
console.error('Navigation error:', err);
}
});
}
},
isLocaleSupported(locale) {
if (locale) {
return SUPPORTED_LOCALES.some(supportedLocale => supportedLocale.code === locale);
}
return false
},
getUserLocale() {
const locale =
window.navigator.language ||
DEFAULT_LOCALE.code
return {
locale: locale,
localeNoRegion: locale.split('-')[0]
}
},
getPersistedLocale() {
const persistedLocale = localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY)
if (translations.isLocaleSupported(persistedLocale)) {
return persistedLocale
} else {
return null
}
},
guessDefaultLocale() {
const userPersistedLocale = translations.getPersistedLocale()
if (userPersistedLocale) {
return userPersistedLocale
}
const userPreferredLocale = translations.getUserLocale()
if (translations.isLocaleSupported(userPreferredLocale.locale)) {
return userPreferredLocale.locale
}
if (translations.isLocaleSupported(userPreferredLocale.localeNoRegion)) {
return userPreferredLocale.localeNoRegion
}
return DEFAULT_LOCALE.code
},
async routeMiddleware(to, _from, next) {
const paramLocale = to.params.locale
if (!translations.isLocaleSupported(paramLocale)) {
return next(translations.guessDefaultLocale())
}
await translations.switchLanguage(paramLocale)
return next()
},
i18nRoute(to) {
return {
...to,
params: {
locale: translations.currentLocale,
...to.params
}
}
}
}
export default translations

View file

@ -0,0 +1,33 @@
import { createI18n } from 'vue-i18n'
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY, SUPPORTED_LOCALES} from "@/config/index.js";
import {loadAllLocaleMessages} from "@/core/helpers/i18n-utils.js";
const savedLocale = localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY)
const currentLocale = savedLocale && SUPPORTED_LOCALES.some(locale => locale.code === savedLocale)
? savedLocale
: DEFAULT_LOCALE
if (!savedLocale) {
localStorage.setItem(LOCALE_STORAGE_LOCALE_KEY, DEFAULT_LOCALE)
}
const i18n = createI18n({
locale: currentLocale,
fallbackLocale: DEFAULT_LOCALE,
allowComposition: true,
legacy: false,
globalInjection: true,
messages: {}
})
export async function setupI18n() {
const messages = await loadAllLocaleMessages(SUPPORTED_LOCALES)
Object.keys(messages).forEach(locale => {
i18n.global.setLocaleMessage(locale, messages[locale])
})
return i18n
}
export default i18n

View file

@ -0,0 +1,42 @@
import i18n from '@/core/plugins/i18n.config'
const isEmail = (email) => {
if (!email) return required(email);
if (/.+@.+\..+/.test(email)) return true
const { t } = i18n.global
return t('errors.mail')
}
const required = (text) => {
if (text) return true
const { t } = i18n.global
return t('errors.required')
}
const isPasswordValid = (pass) => {
const { t } = i18n.global
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
}
export { required, isEmail, isPasswordValid }

View file

@ -0,0 +1,8 @@
import gql from 'graphql-tag'
export const BRAND_FRAGMENT = gql`
fragment Brand on BrandType {
uuid
name
}
`

View file

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
export const CATEGORY_FRAGMENT = gql`
fragment Category on CategoryType {
name
uuid
image
description
slug
}
`

View file

@ -0,0 +1,28 @@
import gql from 'graphql-tag'
import {PRODUCT_FRAGMENT} from "@/graphql/fragments/products.fragment.js";
export const ORDER_FRAGMENT = gql`
fragment Order on OrderType {
totalPrice
uuid
status
buyTime
totalPrice
humanReadableId
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
...Product
}
}
}
}
}
${PRODUCT_FRAGMENT}
`

View file

@ -0,0 +1,53 @@
import gql from 'graphql-tag'
export const PRODUCT_FRAGMENT = gql`
fragment Product on ProductType {
uuid
name
price
quantity
slug
category {
name
}
images {
edges {
node {
image
}
}
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
feedbacks {
edges {
node {
uuid
rating
}
}
}
tags {
edges {
node {
tagName
name
}
}
}
}
`

View file

@ -0,0 +1,17 @@
import gql from 'graphql-tag'
export const USER_FRAGMENT = gql`
fragment User on UserType {
avatar
uuid
attributes
language
email
firstName
lastName
phoneNumber
balance {
amount
}
}
`

View file

@ -0,0 +1,16 @@
import gql from 'graphql-tag'
import {PRODUCT_FRAGMENT} from "@/graphql/fragments/products.fragment.js";
export const WISHLIST_FRAGMENT = gql`
fragment Wishlist on WishlistType {
uuid
products {
edges {
node {
...Product
}
}
}
}
${PRODUCT_FRAGMENT}
`

View file

@ -0,0 +1,90 @@
import gql from 'graphql-tag'
import {USER_FRAGMENT} from "@/graphql/fragments/user.fragment.js";
export const REGISTER = gql`
mutation register(
$firstName: String!,
$lastName: String!,
$email: String!,
$phoneNumber: String!,
$password: String!,
$confirmPassword: String!
) {
createUser(
firstName: $firstName,
lastName: $lastName,
email: $email,
phoneNumber: $phoneNumber,
password: $password,
confirmPassword: $confirmPassword
) {
success
}
}
`
export const LOGIN = gql`
mutation login(
$email: String!,
$password: String!
) {
obtainJwtToken(
email: $email,
password: $password
) {
accessToken
refreshToken
user {
...User
}
}
}
${USER_FRAGMENT}
`
export const REFRESH = gql`
mutation refresh(
$refreshToken: String!
) {
refreshJwtToken(
refreshToken: $refreshToken
) {
accessToken
refreshToken
user {
...User
}
}
}
${USER_FRAGMENT}
`
export const RESET_PASSWORD = gql`
mutation resetPassword(
$email: String!,
) {
resetPassword(
email: $email,
) {
success
}
}
`
export const NEW_PASSWORD = gql`
mutation confirmResetPassword(
$password: String!,
$confirmPassword: String!,
$token: String!,
$uid: String!,
) {
confirmResetPassword(
password: $password,
confirmPassword: $confirmPassword,
token: $token,
uid: $uid
) {
success
}
}
`

View file

@ -0,0 +1,68 @@
import gql from 'graphql-tag'
import {ORDER_FRAGMENT} from "@/graphql/fragments/orders.fragment.js";
export const ADD_TO_CART = gql`
mutation addToCart(
$orderUuid: String!,
$productUuid: String!
) {
addOrderProduct(
orderUuid: $orderUuid,
productUuid: $productUuid
) {
order {
...Order
}
}
}
${ORDER_FRAGMENT}
`
export const REMOVE_FROM_CART = gql`
mutation removeFromCart(
$orderUuid: String!,
$productUuid: String!
) {
removeOrderProduct(
orderUuid: $orderUuid,
productUuid: $productUuid
) {
order {
...Order
}
}
}
${ORDER_FRAGMENT}
`
export const REMOVE_KIND_FROM_CART = gql`
mutation removeKindFromCart(
$orderUuid: String!,
$productUuid: String!
) {
removeOrderProductsOfAKind(
orderUuid: $orderUuid,
productUuid: $productUuid
) {
order {
...Order
}
}
}
${ORDER_FRAGMENT}
`
export const REMOVE_ALL_FROM_CART = gql`
mutation removeAllFromCart(
$orderUuid: String!
) {
removeAllOrderProducts(
orderUuid: $orderUuid
) {
order {
...Order
}
}
}
${ORDER_FRAGMENT}
`

View file

@ -0,0 +1,22 @@
import gql from 'graphql-tag'
export const CONTACT_US = gql`
mutation contactUs(
$name: String!,
$email: String!,
$phoneNumber: String,
$subject: String!,
$message: String!,
) {
contactUs(
name: $name,
email: $email,
phoneNumber: $phoneNumber,
subject: $subject,
message: $message
) {
error
received
}
}
`

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