Set up storefront with localization, global styles, and GraphQL integration

This commit is contained in:
Alexandr Waltz 2025-05-27 01:02:48 +10:00 committed by Egor Gorbunov
parent afcf8c9bd2
commit d2deb25e33
52 changed files with 6438 additions and 0 deletions

13
storefront/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%EVIBES_PROJECT_NAME%</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
storefront/jsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4105
storefront/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
storefront/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "evibes-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@apollo/client": "^3.13.8",
"@vue/apollo-composable": "^4.2.2",
"@vueuse/core": "^13.2.0",
"element-plus": "^2.9.11",
"graphql": "^16.11.0",
"graphql-tag": "^2.12.6",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.4",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"sass": "^1.83.0",
"sass-loader": "^16.0.4",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

29
storefront/src/App.vue Normal file
View file

@ -0,0 +1,29 @@
<script setup>
import { RouterView } from 'vue-router'
import {useRefresh} from "@/composables/auth/useRefresh.js";
import {onMounted} from "vue";
const { refresh } = useRefresh();
onMounted(async () => {
await refresh()
setInterval(async () => {
await refresh()
}, 600000)
})
</script>
<template>
<main class="main" id="top">
<RouterView v-slot="{ Component }">
<Transition name="opacity" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,44 @@
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY} from "@/config/index.js";
import {computed} from "vue";
import { useAuthStore } from "@/stores/auth.js";
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 authStore = useAuthStore()
const accessToken = computed(() => {
return authStore.accessToken
})
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',
}
}
})
}

View file

@ -0,0 +1,5 @@
<svg width="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M19.7071 5.70711C20.0976 5.31658 20.0976 4.68342 19.7071 4.29289C19.3166 3.90237 18.6834 3.90237 18.2929 4.29289L14.032 8.55382C13.4365 8.20193 12.7418 8 12 8C9.79086 8 8 9.79086 8 12C8 12.7418 8.20193 13.4365 8.55382 14.032L4.29289 18.2929C3.90237 18.6834 3.90237 19.3166 4.29289 19.7071C4.68342 20.0976 5.31658 20.0976 5.70711 19.7071L9.96803 15.4462C10.5635 15.7981 11.2582 16 12 16C14.2091 16 16 14.2091 16 12C16 11.2582 15.7981 10.5635 15.4462 9.96803L19.7071 5.70711ZM12.518 10.0677C12.3528 10.0236 12.1792 10 12 10C10.8954 10 10 10.8954 10 12C10 12.1792 10.0236 12.3528 10.0677 12.518L12.518 10.0677ZM11.482 13.9323L13.9323 11.482C13.9764 11.6472 14 11.8208 14 12C14 13.1046 13.1046 14 12 14C11.8208 14 11.6472 13.9764 11.482 13.9323ZM15.7651 4.8207C14.6287 4.32049 13.3675 4 12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C1.92276 13.7326 2.86706 15.0637 4.21194 16.3739L5.62626 14.9596C4.4555 13.8229 3.61144 12.6531 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C12.7719 6 13.5135 6.13385 14.2193 6.36658L15.7651 4.8207ZM12 18C11.2282 18 10.4866 17.8661 9.78083 17.6334L8.23496 19.1793C9.37136 19.6795 10.6326 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C22.0773 10.2674 21.133 8.93627 19.7881 7.62611L18.3738 9.04043C19.5446 10.1771 20.3887 11.3469 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18Z"
fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" fill="none">
<path fill="#000000" fill-rule="evenodd" d="M3.415 10.242c-.067-.086-.13-.167-.186-.242a16.806 16.806 0 011.803-2.025C6.429 6.648 8.187 5.5 10 5.5c1.813 0 3.57 1.148 4.968 2.475A16.816 16.816 0 0116.771 10a16.9 16.9 0 01-1.803 2.025C13.57 13.352 11.813 14.5 10 14.5c-1.813 0-3.57-1.148-4.968-2.475a16.799 16.799 0 01-1.617-1.783zm15.423-.788L18 10l.838.546-.002.003-.003.004-.01.016-.037.054a17.123 17.123 0 01-.628.854 18.805 18.805 0 01-1.812 1.998C14.848 14.898 12.606 16.5 10 16.5s-4.848-1.602-6.346-3.025a18.806 18.806 0 01-2.44-2.852 6.01 6.01 0 01-.037-.054l-.01-.016-.003-.004-.001-.002c0-.001-.001-.001.837-.547l-.838-.546.002-.003.003-.004.01-.016a6.84 6.84 0 01.17-.245 18.804 18.804 0 012.308-2.66C5.151 5.1 7.394 3.499 10 3.499s4.848 1.602 6.346 3.025a18.803 18.803 0 012.44 2.852l.037.054.01.016.003.004.001.002zM18 10l.838-.546.355.546-.355.546L18 10zM1.162 9.454L2 10l-.838.546L.807 10l.355-.546zM9 10a1 1 0 112 0 1 1 0 01-2 0zm1-3a3 3 0 100 6 3 3 0 000-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

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

View file

@ -0,0 +1,5 @@
$font_default: '', sans-serif;
$white: #ffffff;
$black: #000000;
$error: #f13838;

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,59 @@
<template>
<div class="form">
<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-button
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
@click="handleLogin()"
>
{{ t('buttons.signIn') }}
</ui-button>
</div>
</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 {useLogin} from "@/composables/auth/useLogin.js";
import UiButton from "@/components/ui/ui-button.vue";
const {t} = useI18n()
const email = ref('')
const password = ref('')
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);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View file

@ -0,0 +1,103 @@
<template>
<div class="form">
<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="'text'"
:placeholder="t('fields.phone')"
:rules="[required]"
v-model="phoneNumber"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
<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"
@click="handleRegister()"
>
{{ t('buttons.signUp') }}
</ui-button>
</div>
</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/useRegister.js";
const {t} = useI18n()
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;
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<button class="button" :disabled="isDisabled" :class="[{active: isLoading}]">
<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 $black;
background-color: $white;
padding-block: 5px;
z-index: 1;
color: $black;
text-align: center;
font-size: 14px;
font-weight: 700;
&:hover, &.active {
background-color: $black;
}
&:disabled {
cursor: not-allowed;
background-color: rgba($black, 0.5);
}
&:disabled:hover, &.active {
background-color: rgba($black, 0.5);
}
&__loader {
margin-bottom: 10px;
}
}
</style>

View file

@ -0,0 +1,126 @@
<template>
<div class="block">
<div class="block__inner">
<input
:placeholder="placeholder"
:type="isPasswordVisible"
:value="modelValue"
@input="onInput"
class="block__input"
>
<button
@click.prevent="setPasswordVisible"
class="block__eyes"
v-if="type === 'password'"
>
<img v-if="isPasswordVisible === 'password'" src="@icons/eyeClosed.svg" alt="eye" loading="lazy">
<img v-else src="@icons/eyeOpened.svg" alt="eye" loading="lazy">
</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
})
const isPasswordVisible = ref(props.type)
const setPasswordVisible = () => {
if (isPasswordVisible.value === 'password') {
isPasswordVisible.value = 'text'
return
}
isPasswordVisible.value = 'password'
}
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;
&__inner {
width: 100%;
position: relative;
}
&__input {
width: 100%;
padding: 6px 12px;
border: 1px solid $black;
background-color: $white;
color: #1f1f1f;
font-size: 12px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.14px;
&::placeholder {
color: #2B2B2B;
}
}
&__eyes {
cursor: pointer;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
background-color: transparent;
display: grid;
place-items: center;
}
&__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,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,24 @@
import {useMutation} from "@vue/apollo-composable";
import {GET_ORDERS} from "@/graphql/queries/orders.js";
import {useCartStore} from "@/stores/cart.js";
export function useAuthOrder() {
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,21 @@
import {useMutation} from "@vue/apollo-composable";
import {GET_WISHLIST} from "@/graphql/queries/wishlist.js";
import {useWishlistStore} from "@/stores/wishlist.js";
export function useAuthWishlist() {
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,78 @@
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 {useAuthStore} from "@/stores/auth.js";
import translations from "@/core/helpers/translations.js";
import {LOCALE_STORAGE_REFRESH_KEY} from "@/config/index.js";
import { useAuthOrder } from './useAuthOrder';
import { useAuthWishlist } from './useAuthWishlist';
export function useLogin() {
const loading = ref(false);
const userData = ref(null);
const authStore = useAuthStore()
const {t} = useI18n();
const { mutate: loginMutation } = useMutation(LOGIN);
const { getPendingOrder } = useAuthOrder();
const { getWishlist } = useAuthWishlist();
async function login(
email,
password
) {
loading.value = true;
try {
const response = await loginMutation({
email,
password
});
if (response.data?.obtainJwtToken) {
authStore.setUser({
user: response.data.obtainJwtToken.user,
accessToken: response.data.obtainJwtToken.accessToken
});
localStorage.setItem(LOCALE_STORAGE_REFRESH_KEY, response.data.obtainJwtToken.refreshToken)
ElNotification({
message: t('popup.login.text'),
type: 'success'
})
if (response.data.obtainJwtToken.user.language !== translations.currentLocale) {
translations.switchLanguage(response.data.obtainJwtToken.user.language)
}
await getPendingOrder(response.data.obtainJwtToken.user.email);
await getWishlist();
}
} catch (error) {
console.error("Login error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.genericError');
ElNotification({
title: t('popup.error'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
login,
loading,
userData
};
}

View file

@ -0,0 +1,38 @@
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/',
'yahoo.com': 'https://mail.yahoo.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,84 @@
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 {useAuthStore} from "@/stores/auth.js";
import { useAuthOrder } from './useAuthOrder';
import { useAuthWishlist } from './useAuthWishlist';
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY, LOCALE_STORAGE_REFRESH_KEY} from "@/config/index.js";
import {useRoute, useRouter} from "vue-router";
import translations from "@/core/helpers/translations.js";
export function useRefresh() {
const loading = ref(false);
const userData = ref(null);
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const {t} = useI18n();
const { mutate: refreshMutation } = useMutation(REFRESH);
const { getPendingOrder } = useAuthOrder();
const { getWishlist } = useAuthWishlist();
async function refresh() {
loading.value = true;
const refreshToken = computed(() => {
return localStorage.getItem(LOCALE_STORAGE_REFRESH_KEY)
})
if (!refreshToken.value) return
try {
const response = await refreshMutation({
refreshToken: refreshToken.value
});
if (response.data?.refreshJwtToken) {
authStore.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)
await router.push({
name: route.name,
params: {
locale: localStorage.getItem(LOCALE_STORAGE_LOCALE_KEY) || DEFAULT_LOCALE
}
})
}
localStorage.setItem(LOCALE_STORAGE_REFRESH_KEY, response.data.refreshJwtToken.refreshToken)
await getPendingOrder(response.data.refreshJwtToken.user.email);
await getWishlist();
}
} catch (error) {
console.error("Refresh error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.genericError');
ElNotification({
title: t('popup.error'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
refresh,
loading,
userData
};
}

View file

@ -0,0 +1,87 @@
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/auth/useMainClient.js";
export function useRegister() {
const loading = ref(false);
const mailClient = ref(null)
const {t} = useI18n();
const { mutate: registerMutation } = useMutation(REGISTER);
const { mailClientUrl, detectMailClient, openMailClient } = useMailClient();
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({
title: t('popup.register.title'),
message: h('div', [
h('p', t('popup.register.text')),
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("Register error:", error);
const errorMessage = error.graphQLErrors?.[0]?.message ||
error.message ||
t('popup.genericError');
ElNotification({
title: t('popup.error'),
message: errorMessage,
type: 'error'
});
} finally {
loading.value = false;
}
}
return {
register,
loading
};
}

View file

@ -0,0 +1,26 @@
// APP
export const APP_NAME = import.meta.env.EVIBES_PROJECT_NAME
export const APP_NAME_KEY = import.meta.env.EVIBES_PROJECT_NAME.toLowerCase()
// LOCALES
export const SUPPORTED_LOCALES = [
{
code: 'en-gb',
default: true
}
]
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_KEY = `${APP_NAME_KEY}-refresh`;

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,91 @@
import i18n from '@/core/plugins/i18n.config';
import {DEFAULT_LOCALE, LOCALE_STORAGE_LOCALE_KEY, SUPPORTED_LOCALES} from "@/config/index.js";
const translation = {
get currentLocale() {
return i18n.global.locale.value
},
set currentLocale(newLocale) {
i18n.global.locale.value = newLocale
},
switchLanguage(newLocale) {
translation.currentLocale = newLocale
document.querySelector('html').setAttribute('lang', newLocale)
localStorage.setItem(LOCALE_STORAGE_LOCALE_KEY, newLocale)
},
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 (translation.isLocaleSupported(persistedLocale)) {
return persistedLocale
} else {
return null
}
},
guessDefaultLocale() {
const userPersistedLocale = translation.getPersistedLocale()
if (userPersistedLocale) {
return userPersistedLocale
}
const userPreferredLocale = translation.getUserLocale()
if (translation.isLocaleSupported(userPreferredLocale.locale)) {
return userPreferredLocale.locale
}
if (translation.isLocaleSupported(userPreferredLocale.localeNoRegion)) {
return userPreferredLocale.localeNoRegion
}
return DEFAULT_LOCALE.code
},
async routeMiddleware(to, _from, next) {
const paramLocale = to.params.locale
if (!translation.isLocaleSupported(paramLocale)) {
return next(translation.guessDefaultLocale())
}
await translation.switchLanguage(paramLocale)
return next()
},
i18nRoute(to) {
return {
...to,
params: {
locale: translation.currentLocale,
...to.params
}
}
}
}
export default translation

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,155 @@
import gql from 'graphql-tag'
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 {
avatar
uuid
attributes
language
email
firstName
lastName
phoneNumber
balance {
amount
}
}
}
}
`
export const REFRESH = gql`
mutation refresh(
$refreshToken: String!
) {
refreshJwtToken(
refreshToken: $refreshToken
) {
accessToken
refreshToken
user {
avatar
uuid
attributes
language
email
firstName
lastName
phoneNumber
balance {
amount
}
}
}
}
`
export const ACTIVATE_USER = gql`
mutation activateUser(
$token: String!,
$uid: String!
) {
activateUser(
token: $token,
uid: $uid
) {
success
}
}
`
export const UPDATE_USER = gql`
mutation updateUser(
$firstName: String,
$lastName: String,
$email: String,
$phoneNumber: String,
$password: String,
$confirmPassword: String,
) {
updateUser(
firstName: $firstName,
lastName: $lastName,
email: $email,
phoneNumber: $phoneNumber,
password: $password,
confirmPassword: $confirmPassword,
) {
user {
avatar
uuid
attributes
language
email
firstName
lastName
phoneNumber
balance {
amount
}
}
}
}
`
export const RESET_PASSWORD = gql`
mutation resetPassword(
$email: String!,
) {
resetPassword(
email: $email,
) {
success
}
}
`
export const CONFIRM_RESET_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,267 @@
import gql from 'graphql-tag'
export const ADD_TO_CART = gql`
mutation addToCart(
$orderUuid: String!,
$productUuid: String!
) {
addOrderProduct(
orderUuid: $orderUuid,
productUuid: $productUuid
) {
order {
status
uuid
totalPrice
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
uuid
price
name
description
quantity
slug
category {
name
}
images {
edges {
node {
uuid
image
}
}
}
category {
name
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
`
export const REMOVE_FROM_CART = gql`
mutation removeFromCart(
$orderUuid: String!,
$productUuid: String!
) {
removeOrderProduct(
orderUuid: $orderUuid,
productUuid: $productUuid
) {
order {
status
uuid
totalPrice
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
uuid
price
name
description
quantity
slug
category {
name
}
images {
edges {
node {
uuid
image
}
}
}
category {
name
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
`
export const REMOVE_KIND_FROM_CART = gql`
mutation removeKindFromCart(
$orderUuid: String!,
$productUuid: String!
) {
removeOrderProductsOfAKind(
orderUuid: $orderUuid,
productUuid: $productUuid
) {
order {
status
uuid
totalPrice
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
uuid
price
name
description
quantity
slug
category {
name
}
images {
edges {
node {
uuid
image
}
}
}
category {
name
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
`
export const REMOVE_ALL_FROM_CART = gql`
mutation removeAllFromCart(
$orderUuid: String!
) {
removeAllOrderProducts(
orderUuid: $orderUuid
) {
order {
status
uuid
totalPrice
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
uuid
price
name
description
quantity
slug
category {
name
}
images {
edges {
node {
uuid
image
}
}
}
category {
name
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
`

View file

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

View file

@ -0,0 +1,14 @@
import gql from 'graphql-tag'
export const DEPOSIT = gql`
mutation deposit(
$amount: Number!
) {
contactUs(
amount: $amount,
) {
error
received
}
}
`

View file

@ -0,0 +1,138 @@
import gql from 'graphql-tag'
export const ADD_TO_WISHLIST = gql`
mutation addToWishlist(
$wishlistUuid: String!,
$productUuid: String!
) {
addWishlistProduct(
wishlistUuid: $wishlistUuid,
productUuid: $productUuid
) {
wishlist {
uuid
products {
edges {
node {
uuid
price
name
description
quantity
slug
images {
edges {
node {
uuid
image
}
}
}
}
}
}
}
}
}
`
export const REMOVE_FROM_WISHLIST = gql`
mutation removeFromWishlist(
$wishlistUuid: String!,
$productUuid: String!
) {
removeWishlistProduct(
wishlistUuid: $wishlistUuid,
productUuid: $productUuid
) {
wishlist {
uuid
products {
edges {
node {
uuid
price
name
description
quantity
slug
images {
edges {
node {
uuid
image
}
}
}
}
}
}
}
}
}
`
export const REMOVE_ALL_FROM_WISHLIST = gql`
mutation removeAllFromCart(
$wishlistUuid: String!
) {
removeAllWishlistProducts(
wishlistUuid: $wishlistUuid
) {
order {
status
uuid
totalPrice
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
uuid
price
name
description
quantity
slug
category {
name
}
images {
edges {
node {
uuid
image
}
}
}
category {
name
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
`

View file

@ -0,0 +1,45 @@
import gql from 'graphql-tag'
export const GET_CATEGORIES = gql`
query getCategories {
categories {
edges {
node {
name
uuid
image
description
slug
}
}
}
}
`
export const GET_CATEGORY_BY_SLUG = gql`
query getCategoryBySlug(
$slug: String!
) {
categories(
slug: $slug
) {
edges {
node {
name
uuid
image
description
slug
filterableAttributes {
possibleValues
attributeName
}
minMaxPrices {
maxPrice
minPrice
}
}
}
}
}
`

View file

@ -0,0 +1,14 @@
import gql from 'graphql-tag'
export const GET_COMPANY_INFO = gql`
query getCompanyInfo {
parameters {
companyAddress
companyName
companyPhoneNumber
emailFrom
emailHostUser
projectName
}
}
`

View file

@ -0,0 +1,17 @@
import gql from 'graphql-tag'
export const GET_DOCS = gql`
query getDocs(
$slug: String!
) {
posts(
slug: $slug
) {
edges {
node {
content
}
}
}
}
`

View file

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
export const GET_LANGUAGES = gql`
query getLanguages {
languages {
code
flag
name
}
}
`

View file

@ -0,0 +1,74 @@
import gql from 'graphql-tag'
export const GET_ORDERS = gql`
query getOrders(
$status: String!,
$userEmail: String!
) {
orders(
status: $status,
orderBy: "-buyTime",
userEmail: $userEmail
) {
edges {
node {
totalPrice
uuid
status
buyTime
totalPrice
humanReadableId
orderProducts {
edges {
node {
uuid
notifications
attributes
quantity
status
product {
uuid
price
name
description
quantity
slug
category {
name
}
images {
edges {
node {
uuid
image
}
}
}
category {
name
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
}
`

View file

@ -0,0 +1,116 @@
import gql from 'graphql-tag'
export const GET_PRODUCTS = gql`
query getProducts(
$after: String,
$first: Number,
$categorySlugs: String,
$orderBy: String,
$minPrice: String,
$maxPrice: String,
$name: String
) {
products(
after: $after,
first: $first,
categorySlugs: $categorySlugs,
orderBy: $orderBy,
minPrice: $minPrice,
maxPrice: $maxPrice,
name: $name
) {
edges {
cursor
node {
uuid
name
price
quantity
slug
images {
edges {
node {
image
}
}
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
export const GET_PRODUCT_BY_SLUG = gql`
query getProductBySlug(
$slug: String!
) {
products(
slug: $slug
) {
edges {
node {
uuid
name
price
quantity
description
slug
category {
name
slug
}
images {
edges {
node {
image
}
}
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
feedbacks {
edges {
node {
rating
}
}
}
}
}
}
}
`

View file

@ -0,0 +1,48 @@
import gql from 'graphql-tag'
export const GET_WISHLIST = gql`
query getWishlist {
wishlists {
edges {
node {
uuid
products {
edges {
node {
uuid
price
name
description
slug
images {
edges {
node {
uuid
image
}
}
}
attributeGroups {
edges {
node {
name
uuid
attributes {
name
uuid
values {
value
uuid
}
}
}
}
}
}
}
}
}
}
}
}
`

View file

@ -0,0 +1,58 @@
{
"buttons": {
"signIn": "Sign In",
"signUp": "Sign Up",
"addToCart": "Add To Cart",
"send": "Send",
"goEmail": "Take me to my inbox",
"logout": "Log Out",
"buy": "Buy Now",
"save": "Save"
},
"errors": {
"required": "This field is required!",
"mail": "Email must be valid!",
"compare": "Passwords don't match!",
"needLower": "Please include lowercase letter.",
"needUpper": "Please include uppercase letter.",
"needNumber": "Please include number.",
"needMin": "Min. 8 characters",
"needSpecial": "Please include a special character: #.?!$%^&*'()_+=:;\"'/>.<,|\\-",
"pageNotFound": "Page not found"
},
"fields": {
"search": "Search Cards",
"firstName": "First name",
"lastName": "Last name",
"phone": "Phone number",
"email": "Email",
"message": "Your message",
"password": "Password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"confirmNewPassword": "Confirm new password"
},
"popup": {
"error": "Error!",
"login": {
"title": "Wellcome back!",
"text": "Sign in successes"
},
"register": {
"title": "Welcome!",
"text": "Account successfully created. Please confirm your Email before Sign In!"
},
"addToCart": "{product} has been added to the cart!",
"addToCartLimit": "Total quantity limit is {quantity}!",
"failAdd": "Please log in to make a purchase",
"contactSuccess": "Your message was sent successfully!",
"activationSuccess": "E-mail successfully verified. Please Sign In!",
"successUpdate": "Profile successfully updated!",
"confirmEmail": "Verification E-mail link successfully sent!",
"payment": "Your purchase is being processed! Please stand by",
"reset": "If specified email exists in our system, we will send a password recovery email!",
"successNewPassword": "You have successfully changed your password!",
"successCheckout": "Order purchase successful!",
"addToWishlist": "{product} has been added to the wishlist!"
}
}

32
storefront/src/main.js Normal file
View file

@ -0,0 +1,32 @@
import '@/assets/styles/global/fonts.scss'
import '@/assets/styles/main.scss'
import {createApp, h, provide} from 'vue'
import { DefaultApolloClient } from '@vue/apollo-composable'
import { createApolloClient } from './apollo'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import {setupI18n} from "@/core/plugins/i18n.config.js";
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
const pinia = createPinia()
const i18n = await setupI18n()
const app = createApp({
setup() {
const apolloClient = createApolloClient()
provide(DefaultApolloClient, apolloClient)
},
render: () => h(App)
})
app
.use(pinia)
.use(i18n)
.use(router)
.use(ElementPlus)
app.mount('#app')

View file

@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,41 @@
import {createRouter, createWebHistory, RouterView} from 'vue-router'
import HomePage from "@/pages/home-page.vue";
import translation from "@/core/helpers/translations.js";
import {APP_NAME} from "@/config/index.js";
const routes = [
{
path: '/:locale?',
component: RouterView,
beforeEnter: translation.routeMiddleware,
children: [
{
path: '',
name: 'home',
component: HomePage,
meta: {
title: "Home"
}
}
]
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior() {
document.querySelector('#top').scrollIntoView({ behavior: 'smooth' })
return {
top: 0,
left: 0,
behavior: 'smooth'
}
}
})
router.beforeEach((to, from) => {
document.title = to.meta.title ? `${APP_NAME} | ` + to.meta?.title : APP_NAME
})
export default router

View file

@ -0,0 +1,14 @@
import {defineStore} from "pinia";
import {ref} from "vue";
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const accessToken = ref(null)
const setUser = (payload) => {
user.value = payload.user
accessToken.value = payload.accessToken
}
return { user, accessToken, setUser }
})

View file

@ -0,0 +1,14 @@
import {defineStore} from "pinia";
import {ref} from "vue";
export const useCartStore = defineStore('cart', () => {
const currentOrder = ref({})
const setCurrentOrders = (order) => {
currentOrder.value = order
}
return {
currentOrder,
setCurrentOrders
}
})

View file

@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCompanyStore = defineStore('company', () => {
const companyInfo = ref([])
const setCompanyInfo = (payload) => {
companyInfo.value = payload
}
return {
companyInfo,
setCompanyInfo
}
})

View file

@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useWishlistStore = defineStore('wishlist', () => {
const wishlist = ref({})
const setWishlist = (order) => {
wishlist.value = order
}
return {
wishlist,
setWishlist
}
})

37
storefront/vite.config.js Normal file
View file

@ -0,0 +1,37 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
envDir: '../',
envPrefix: 'EVIBES_',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@core': fileURLToPath(new URL('./src/core', import.meta.url)),
'@graphql': fileURLToPath(new URL('./src/graphql', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/assets/styles', import.meta.url)),
'@icons': fileURLToPath(new URL('./src/assets/icons', import.meta.url)),
'@images': fileURLToPath(new URL('./src/assets/images', import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/assets/styles/global/variables.scss" as *;
@use "@/assets/styles/global/mixins.scss" as *;
`
}
}
},
build: {
sourcemap: true,
target: 'ES2022',
}
})