first commit
This commit is contained in:
123
src/App.vue
Normal file
123
src/App.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<div id="modals"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore, useUIStore } from "@/store";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
init();
|
||||
function init() {
|
||||
userStore.loadData();
|
||||
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme) {
|
||||
uiStore.setTheme(savedTheme);
|
||||
} else {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
uiStore.setTheme("dark");
|
||||
} else {
|
||||
uiStore.setTheme("light");
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/styles/themes.scss";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
html {
|
||||
font-size: 2vw;
|
||||
|
||||
@media (min-width: 500px) {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
body {
|
||||
background: color("main-background");
|
||||
|
||||
font-size: r(22);
|
||||
font-family: Roboto, sans-serif;
|
||||
font-weight: normal;
|
||||
color: color("main-color");
|
||||
|
||||
@media (min-width: 500px) {
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid grey;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fa-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&__view {
|
||||
$__view: &;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&--transition-direction {
|
||||
&--left {
|
||||
#{$__view}--transition {
|
||||
&-enter-from {
|
||||
transform: translateX(-100vw);
|
||||
}
|
||||
&-leave-to {
|
||||
transform: translateX(100vw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--right {
|
||||
#{$__view}--transition {
|
||||
&-enter-from {
|
||||
transform: translateX(100vw);
|
||||
}
|
||||
&-leave-to {
|
||||
transform: translateX(-100vw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.18s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__navigation-bar {
|
||||
flex: 0 1;
|
||||
}
|
||||
}
|
||||
</style>
|
BIN
src/assets/fonts/Roboto-Bold.ttf
Normal file
BIN
src/assets/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Bold.woff2
Normal file
BIN
src/assets/fonts/Roboto-Bold.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Italic.ttf
Normal file
BIN
src/assets/fonts/Roboto-Italic.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Italic.woff2
Normal file
BIN
src/assets/fonts/Roboto-Italic.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Light.ttf
Normal file
BIN
src/assets/fonts/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Light.woff2
Normal file
BIN
src/assets/fonts/Roboto-Light.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Regular.ttf
Normal file
BIN
src/assets/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Regular.woff2
Normal file
BIN
src/assets/fonts/Roboto-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/images/smoke.webp
Normal file
BIN
src/assets/images/smoke.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 780 KiB |
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.7 KiB |
19
src/components.d.ts
vendored
Normal file
19
src/components.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
// components.d.ts
|
||||
declare module "@vue/runtime-core" {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import("vue-router")["RouterLink"];
|
||||
RouterView: typeof import("vue-router")["RouterView"];
|
||||
Component: typeof import("vue")["component"];
|
||||
FontAwesomeIcon: typeof import("@fortawesome/vue-fontawesome")["FontAwesomeIcon"];
|
||||
FontAwesomeLayers: typeof import("@fortawesome/vue-fontawesome")["FontAwesomeLayers"];
|
||||
InputComp: typeof import("@/components/UI/InputComp.vue");
|
||||
InputFileComp: typeof import("@/components/UI/InputFileComp.vue");
|
||||
ButtonComp: typeof import("@/components/UI/ButtonComp.vue");
|
||||
ImageComp: typeof import("@/components/UI/ImageComp.vue");
|
||||
ModalComp: typeof import("@/components/UI/ModalComp.vue");
|
||||
SliderComp: typeof import("@/components/UI/Slider/SliderComp.vue");
|
||||
SliderTabComp: typeof import("@/components/UI/Slider/SliderTabComp.vue");
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
230
src/components/Authorization/AuthorizationMain.vue
Normal file
230
src/components/Authorization/AuthorizationMain.vue
Normal file
@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="authorization">
|
||||
<div class="authorization__head">
|
||||
<FontAwesomeIcon class="authorization__head-link" icon="chevron-left" @click="goToMain()" />
|
||||
<div class="authorization__head-text" v-if="isLogin">Вход</div>
|
||||
<div class="authorization__head-text" v-else>Регистрация</div>
|
||||
</div>
|
||||
|
||||
<div class="authorization__content" v-if="isLogin">
|
||||
<InputComp type="email" placeholder="Ваш email" :hasWarning="fieldHasErrors('Email')" v-model="dataLogin.email" />
|
||||
<InputComp type="password" placeholder="Пароль" :hasWarning="fieldHasErrors('Пароль')" v-model="dataLogin.password" />
|
||||
|
||||
<ButtonComp class="authorization__button" value="Войти" type="primary" @click="submit()" />
|
||||
<div class="authorization__toggler">
|
||||
Ещё нет аккаунта?
|
||||
<span class="authorization__toggler-link" @click="toggleType()">Зарегистрироваться</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="authorization__content" v-else>
|
||||
<SwitchableImage class="authorization__photo" :value="dataRegister.image" :edited="true" @change="setRegisterImage" />
|
||||
<InputComp type="text" placeholder="Ваше имя *" :hasWarning="fieldHasErrors('Имя')" v-model="dataRegister.name" />
|
||||
<InputComp type="text" placeholder="Ваша фамилия *" :hasWarning="fieldHasErrors('Фамилия')" v-model="dataRegister.surname" />
|
||||
<InputComp type="email" placeholder="Ваш email *" :hasWarning="fieldHasErrors('Email')" v-model="dataRegister.email" />
|
||||
<InputComp type="password" placeholder="Пароль *" :hasWarning="fieldHasErrors('Пароль')" v-model="dataRegister.password" />
|
||||
<InputComp
|
||||
type="password"
|
||||
placeholder="Повторите пароль *"
|
||||
:hasWarning="fieldHasErrors('Пароль')"
|
||||
v-model="dataRegister.password2"
|
||||
/>
|
||||
|
||||
<ButtonComp class="authorization__button" value="Зарегистрироваться" type="primary" @click="submit()" />
|
||||
<div class="authorization__toggler">
|
||||
Уже есть аккаунт?
|
||||
<span class="authorization__toggler-link" @click="toggleType()">Войти</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsMain class="authorization__notifications" v-model="notifications" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import InputImage from "@/components/Authorization/InputImage.vue";
|
||||
import NotificationsMain from "@/components/Notifications/NotificationsMain.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useUserStore } from "@/store";
|
||||
import { useUserApi } from "@/composables/api";
|
||||
import { FieldsContainer, FieldChecks } from "@/modules/validator";
|
||||
import { notification } from "@/types";
|
||||
import SwitchableImage from "@/components/Common/SwitchableImage.vue";
|
||||
import { convertFileToBase64String } from "@/composables/utils";
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const userApi = useUserApi();
|
||||
|
||||
const props = defineProps<{ type: string }>();
|
||||
|
||||
const isLogin = computed(() => props.type === "login");
|
||||
|
||||
function toggleType() {
|
||||
if (isLogin.value) {
|
||||
router.push("/register");
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
notifications.value = [];
|
||||
|
||||
resetData();
|
||||
}
|
||||
|
||||
function goToMain() {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
//#region login and registration
|
||||
const dataLogin = ref<{
|
||||
email: string;
|
||||
password: string;
|
||||
}>({ email: "", password: "" });
|
||||
|
||||
const dataRegister = ref<{
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password2: string;
|
||||
image: string;
|
||||
imageFile?: File;
|
||||
}>({ name: "", surname: "", email: "", password: "", password2: "", image: "", imageFile: undefined });
|
||||
|
||||
async function setRegisterImage(file: File) {
|
||||
console.log(file);
|
||||
|
||||
dataRegister.value.imageFile = file;
|
||||
dataRegister.value.image = await convertFileToBase64String(file);
|
||||
}
|
||||
|
||||
const notifications = ref<notification[]>([]);
|
||||
|
||||
async function submit() {
|
||||
let errors = getFieldsErrors();
|
||||
|
||||
notifications.value = errors;
|
||||
|
||||
if (errors.length > 0) return;
|
||||
|
||||
if (isLogin.value) {
|
||||
const resp = await userApi.login(dataLogin.value);
|
||||
userStore.setData(resp);
|
||||
router.push("/");
|
||||
} else {
|
||||
const resp = await userApi.register(dataRegister.value);
|
||||
notifications.value.push({ text: "Регистрация прошла успешно. Теперь вы можете войти.", type: "success" });
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
resetData();
|
||||
}
|
||||
|
||||
function getFieldsErrors() {
|
||||
const container = new FieldsContainer();
|
||||
if (isLogin.value) {
|
||||
container.addField("Email", dataLogin.value.email, [FieldChecks.nonEmpty, FieldChecks.email]);
|
||||
container.addField("Пароль", dataLogin.value.password, [FieldChecks.nonEmpty, FieldChecks.minLength(6), FieldChecks.maxLength(32)]);
|
||||
} else {
|
||||
container.addField("Имя", dataRegister.value.name, [
|
||||
FieldChecks.nonEmpty,
|
||||
FieldChecks.onlyLettersWhitespaces,
|
||||
FieldChecks.minLength(2),
|
||||
FieldChecks.maxLength(20),
|
||||
]);
|
||||
container.addField("Фамилия", dataRegister.value.surname, [
|
||||
FieldChecks.nonEmpty,
|
||||
FieldChecks.onlyLettersWhitespaces,
|
||||
FieldChecks.minLength(2),
|
||||
FieldChecks.maxLength(20),
|
||||
]);
|
||||
container.addField("Email", dataRegister.value.email, [FieldChecks.nonEmpty, FieldChecks.email]);
|
||||
container.addField("Пароль", dataRegister.value.password, [
|
||||
FieldChecks.nonEmpty,
|
||||
FieldChecks.minLength(6),
|
||||
FieldChecks.maxLength(32),
|
||||
]);
|
||||
}
|
||||
|
||||
let errors: notification[] = [];
|
||||
for (let [key, value] of container.fields) {
|
||||
const error = value.tryGetFirstError();
|
||||
if (error !== "") {
|
||||
errors.push({ field: key, text: error, type: "warning" });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLogin.value) {
|
||||
if (dataRegister.value.password !== dataRegister.value.password2) {
|
||||
errors.push({ field: "Пароль", text: "Пароли не совпадают.", type: "warning" });
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function fieldHasErrors(field: string) {
|
||||
return notifications.value.some((x) => x.field === field);
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
dataLogin.value = { email: "", password: "" };
|
||||
dataRegister.value = { name: "", surname: "", email: "", password: "", password2: "", image: "", imageFile: undefined };
|
||||
}
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.authorization {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: r(40) r(70);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: r(20);
|
||||
font-size: r(20);
|
||||
}
|
||||
|
||||
&__head-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__photo {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&__button {
|
||||
align-self: center;
|
||||
margin-top: r(30);
|
||||
min-width: 50%;
|
||||
}
|
||||
|
||||
&__toggler {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: r(5);
|
||||
margin-top: r(15);
|
||||
color: color("ui-background");
|
||||
font-size: r(19);
|
||||
}
|
||||
|
||||
&__toggler-link {
|
||||
color: color("theme-color");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__notifications {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
68
src/components/Authorization/InputImage.vue
Normal file
68
src/components/Authorization/InputImage.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<label class="input-image">
|
||||
<img class="input-image__image" :src="imageSrc" v-if="imageFile" />
|
||||
<FontAwesomeIcon class="input-image__icon" icon="user" v-else />
|
||||
<input class="input-image__input" type="file" accept=".jpg, .jpeg, .png, image/png, image/jpg, image/jpeg" @change="valueChanged" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { convertFileToBase64String } from "@/composables/utils";
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
const imageFile = ref<File | undefined>(undefined);
|
||||
const imageSrc = ref("");
|
||||
|
||||
async function valueChanged(event: Event) {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
|
||||
if (file) {
|
||||
if (!file.type.includes("image")) {
|
||||
alert("Необходимо выбрать изображение");
|
||||
return;
|
||||
}
|
||||
|
||||
imageFile.value = file;
|
||||
|
||||
const fileString = await convertFileToBase64String(file);
|
||||
|
||||
imageSrc.value = fileString;
|
||||
emit("change", file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.input-image {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(100);
|
||||
height: r(100);
|
||||
margin: r(10);
|
||||
border-radius: 50%;
|
||||
border: r(1) solid color("ui-background");
|
||||
overflow: hidden;
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
height: 30%;
|
||||
width: 30%;
|
||||
color: color("ui-background");
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
84
src/components/Common/SwitchableImage.vue
Normal file
84
src/components/Common/SwitchableImage.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="switchable-image">
|
||||
<img class="switchable-image__image" :src="value" v-if="value" />
|
||||
<FontAwesomeIcon class="switchable-image__placeholder" icon="user" v-else />
|
||||
<label class="switchable-image__input-field" v-if="edited">
|
||||
<FontAwesomeIcon class="switchable-image__input-icon" icon="pen" />
|
||||
<input
|
||||
class="switchable-image__input"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png, image/png, image/jpg, image/jpeg"
|
||||
@change="valueChanged"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ value: string; edited: boolean }>();
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
function valueChanged(event: Event) {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
if (file) {
|
||||
emit("change", file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.switchable-image {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(150);
|
||||
height: r(150);
|
||||
border-radius: 50%;
|
||||
border: r(1) solid color("theme-color");
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 50%;
|
||||
|
||||
&[src=""] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
color: color("theme-color");
|
||||
}
|
||||
|
||||
&__input-field {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__input-icon {
|
||||
position: absolute;
|
||||
top: r(5);
|
||||
right: r(5);
|
||||
width: 15%;
|
||||
height: 15%;
|
||||
border-radius: 50%;
|
||||
padding: r(10);
|
||||
|
||||
background: color("theme-color");
|
||||
color: color("ui-color-on-background");
|
||||
}
|
||||
}
|
||||
</style>
|
22
src/components/Common/ThemeSwitcher.vue
Normal file
22
src/components/Common/ThemeSwitcher.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="theme-switcher" @click="toggleTheme()">
|
||||
<FontAwesomeIcon class="fa-icon" icon="sun" v-if="uiStore.theme === 'dark'" />
|
||||
<FontAwesomeIcon class="fa-icon" icon="moon" v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
const uiStore = useUIStore();
|
||||
|
||||
function toggleTheme() {
|
||||
const nextTheme = uiStore.theme === "light" ? "dark" : "light";
|
||||
uiStore.setTheme(nextTheme);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.theme-switcher {
|
||||
}
|
||||
</style>
|
56
src/components/HomeView/AuthorizationNeeded.vue
Normal file
56
src/components/HomeView/AuthorizationNeeded.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="authorization-needed">
|
||||
<div class="authorization-needed__container">
|
||||
<FontAwesomeIcon class="authorization-needed__icon" icon="user-lock" />
|
||||
<div class="authorization-needed__title">Необходима авторизация</div>
|
||||
<div class="authorization-needed__text">
|
||||
Для просмотра этой страницы
|
||||
<br />
|
||||
вам необходимо
|
||||
<RouterLink to="/login" class="authorization-needed__link">войти</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.authorization-needed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: r(20);
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: r(15);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: color("theme-color");
|
||||
font-size: r(35);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-align: center;
|
||||
font-size: r(18);
|
||||
line-height: r(26);
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: color("theme-color");
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
75
src/components/HomeView/Header/HeaderMain.vue
Normal file
75
src/components/HomeView/Header/HeaderMain.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="header" v-if="userStore.isLoggedIn" @click="goToUserPage()">
|
||||
<div class="header__user-image">
|
||||
<div class="header__image-container">
|
||||
<img class="header__image" :src="userStore.userData.image" v-if="userStore.userData.image" />
|
||||
<FontAwesomeIcon class="header__image-placeholder" icon="user" v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div class="header__name">{{ userStore.userData.name }} {{ userStore.userData.surname }}</div>
|
||||
<div class="header__coins">
|
||||
<FontAwesomeIcon icon="coins" />
|
||||
{{ userStore.laCoins }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "@/store";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
function goToUserPage() {
|
||||
router.push("/user");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: r(60);
|
||||
padding: r(5) r(20);
|
||||
color: color("ui-color-on-background");
|
||||
background: linear-gradient(60deg, color("theme-color"), color("theme-color", $lightness: -13%));
|
||||
box-shadow: 0 r(4) r(20) 0 hsla(0, 0%, 0%, 0.14), 0 r(7) r(12) r(-5) color("theme-color", $alpha: -0.54);
|
||||
user-select: none;
|
||||
z-index: 50;
|
||||
|
||||
&__user-image {
|
||||
position: relative;
|
||||
width: r(60);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(50);
|
||||
height: r(50);
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__image-placeholder {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
color: color("theme-color");
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
line-height: r(21);
|
||||
}
|
||||
}
|
||||
</style>
|
92
src/components/HomeView/HomeViewMain.vue
Normal file
92
src/components/HomeView/HomeViewMain.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="home-view">
|
||||
<HeaderMain />
|
||||
<div class="home-view__view" :class="'home-view__view--transition-direction--' + navigationDirection">
|
||||
<RouterView v-slot="{ Component }" class="home-view__page">
|
||||
<Transition name="home-view__view--transition">
|
||||
<KeepAlive>
|
||||
<Component :is="Component" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
<NavigationBarMain class="home-view__navigation-bar" @direction="setDirection" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HeaderMain from "@/components/HomeView/Header/HeaderMain.vue";
|
||||
import NavigationBarMain from "@/components/HomeView/NavigationBar/NavigationBarMain.vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const navigationDirection = ref("none");
|
||||
|
||||
function setDirection(direction: string) {
|
||||
navigationDirection.value = direction;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.home-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__view {
|
||||
$__view: &;
|
||||
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: color("second-background");
|
||||
|
||||
&--transition-direction {
|
||||
&--left {
|
||||
#{$__view}--transition {
|
||||
&-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
&-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--right {
|
||||
#{$__view}--transition {
|
||||
&-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
&-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
&__navigation-bar {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
180
src/components/HomeView/NavigationBar/NavigationBarMain.vue
Normal file
180
src/components/HomeView/NavigationBar/NavigationBarMain.vue
Normal file
@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="navigation-bar">
|
||||
<template v-for="tab in tabs" :key="tab.index">
|
||||
<RouterLink
|
||||
class="navigation-bar__item"
|
||||
:class="{
|
||||
'navigation-bar__item--center': tab.isCenter,
|
||||
}"
|
||||
:to="tab.path"
|
||||
:active-class="'navigation-bar__item--active'"
|
||||
>
|
||||
<div class="navigation-bar__icon-container">
|
||||
<FontAwesomeIcon class="navigation-bar__icon" :icon="tab.icon" />
|
||||
<div class="navigation-bar__icon-counter" v-if="tab.counter?.value">
|
||||
{{ tab.counter }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="navigation-bar__item-name">
|
||||
{{ tab.name }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOrdersStore } from "@/store";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
// router
|
||||
import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const emit = defineEmits(["direction"]);
|
||||
const ordersStore = useOrdersStore();
|
||||
|
||||
const tabs = [
|
||||
{ path: "/home", name: "Лента", icon: "house" },
|
||||
{ path: "/menu", name: "Меню", icon: "bars" },
|
||||
{ path: "/hookah", name: "Кальян", isCenter: true, icon: "bottle-droplet" },
|
||||
{
|
||||
path: "/orders",
|
||||
name: "Заказы",
|
||||
icon: "file-lines",
|
||||
counter: computed(() => ordersStore.orders.filter((x) => x.status !== "completed").length),
|
||||
},
|
||||
{ path: "/user", name: "Профиль", icon: "user" },
|
||||
];
|
||||
|
||||
const currentTabIndex = computed(() => {
|
||||
let tab = tabs.find((x) => route.path.includes(x.path));
|
||||
if (!tab) return -1;
|
||||
return tabs.indexOf(tab);
|
||||
});
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
const tabTo = tabs.find((x) => to.path.includes(x.path));
|
||||
if (tabTo) {
|
||||
if (currentTabIndex.value === tabs.indexOf(tabTo)) return;
|
||||
|
||||
if (currentTabIndex.value > tabs.indexOf(tabTo)) {
|
||||
emit("direction", "left");
|
||||
} else {
|
||||
emit("direction", "right");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
emit("direction", "none");
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.navigation-bar {
|
||||
$p: &;
|
||||
|
||||
display: flex;
|
||||
height: r(60);
|
||||
font-size: r(16);
|
||||
color: color("ui-background");
|
||||
user-select: none;
|
||||
box-shadow: 0 r(-4) r(20) 0 hsla(0, 0%, 0%, 0.12), 0 r(-7) r(12) r(-5) hsla(0, 0%, 0%, 0.15);
|
||||
|
||||
z-index: 50;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: r(5) 0;
|
||||
cursor: pointer;
|
||||
|
||||
&--center {
|
||||
#{$p}__icon {
|
||||
width: r(40);
|
||||
height: r(40);
|
||||
padding: r(6);
|
||||
color: color("main-background");
|
||||
background: color("ui-background");
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
#{$p}__icon-container {
|
||||
bottom: r(22);
|
||||
width: r(60);
|
||||
height: r(60);
|
||||
background: color("main-background");
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 r(-4) r(20) 0 hsla(0, 0%, 0%, 0.12), 0 r(-7) r(12) r(-5) hsla(0, 0%, 0%, 0.15);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: r(22);
|
||||
width: 150%;
|
||||
height: r(58);
|
||||
background: color("main-background");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
#{$p}__item-name {
|
||||
color: color("main-color");
|
||||
}
|
||||
}
|
||||
|
||||
&--active#{&}:not(&--center) {
|
||||
#{$p}__icon {
|
||||
color: color("theme-color");
|
||||
}
|
||||
}
|
||||
|
||||
&--active#{&}--center {
|
||||
#{$p}__icon {
|
||||
background: color("theme-color");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__item-name {
|
||||
transition: color 0.18s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
&__icon-counter {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: -25%;
|
||||
right: -25%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(20);
|
||||
height: r(20);
|
||||
border-radius: 50%;
|
||||
color: color("ui-color-on-background");
|
||||
background: color("warning-color");
|
||||
}
|
||||
|
||||
&__icon-container {
|
||||
position: absolute;
|
||||
bottom: r(25);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(28);
|
||||
height: r(28);
|
||||
}
|
||||
}
|
||||
</style>
|
142
src/components/HomeView/Tabs/Home/HomeMain.vue
Normal file
142
src/components/HomeView/Tabs/Home/HomeMain.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="home__slider-promo">
|
||||
<div class="home__slider-promo-title">Акции</div>
|
||||
<SliderComp type="infinite" :automatic="true">
|
||||
<SliderTabComp>
|
||||
<ImageComp class="home__slider-promo-image" src="/media/images/promo.webp" @click="openModal(0)" />
|
||||
<ModalComp :ref="(elem: any) => {refModal.push(elem as InstanceType<typeof ModalComp>)}">
|
||||
<ImageComp class="home__slider-promo-image" src="/media/images/promo.webp" />
|
||||
<div class="home__slider-promo-text">Текст акции 1</div>
|
||||
</ModalComp>
|
||||
</SliderTabComp>
|
||||
<SliderTabComp>
|
||||
<ImageComp class="home__slider-promo-image" src="/media/images/promo.webp" @click="openModal(1)" />
|
||||
<ModalComp :ref="(elem: any) => {refModal.push(elem as InstanceType<typeof ModalComp>)}">
|
||||
<ImageComp class="home__slider-promo-image" src="/media/images/promo.webp" />
|
||||
<div class="home__slider-promo-text" home__slider-image>Текст акции 2</div>
|
||||
</ModalComp>
|
||||
</SliderTabComp>
|
||||
<SliderTabComp>
|
||||
<ImageComp class="home__slider-promo-image" src="/media/images/promo.webp" @click="openModal(2)" />
|
||||
<ModalComp :ref="(elem: any) => {refModal.push(elem as InstanceType<typeof ModalComp>)}">
|
||||
<ImageComp class="home__slider-promo-image" src="/media/images/promo.webp" />
|
||||
<div class="home__slider-promo-text">Текст акции 3</div>
|
||||
</ModalComp>
|
||||
</SliderTabComp>
|
||||
</SliderComp>
|
||||
</div>
|
||||
<div class="home__slider" v-if="ordersStore.orders.length > 0">
|
||||
<div class="home__slider-title">Последние заказы</div>
|
||||
<div class="home__slider-list">
|
||||
<div class="home__slider-item" v-for="order in ordersStore.orders.sort((a, b) => b.id - a.id)" :key="order.id">
|
||||
<div>Заказ № {{ order.id }}</div>
|
||||
<div>Стол: {{ order.table }}</div>
|
||||
<div>Тяжесть: {{ order.heaviness.title }}</div>
|
||||
<div>Вкус: {{ order.tastes.map((x) => x.title).join(", ") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home__slider-footer"></div>
|
||||
</div>
|
||||
<div class="home__slider" v-if="ordersStore.favouriteOrders.length > 0">
|
||||
<div class="home__slider-title">Любимые кальяны</div>
|
||||
<div class="home__slider-list">
|
||||
<div class="home__slider-item" v-for="order in ordersStore.favouriteOrders.sort((a, b) => b.id - a.id)" :key="order.id">
|
||||
<div>Тяжесть: {{ order.heaviness.title }}</div>
|
||||
<div>Вкус: {{ order.tastes.map((x) => x.title).join(", ") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home__slider-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOrdersStore } from "@/store";
|
||||
import { ref } from "vue";
|
||||
import ModalComp from "@/components/UI/ModalComp.vue";
|
||||
|
||||
const refModal = ref<InstanceType<typeof ModalComp>[]>([]);
|
||||
|
||||
const ordersStore = useOrdersStore();
|
||||
|
||||
function openModal(index: number) {
|
||||
refModal.value[index].open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.home {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(20);
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
padding: r(20) 0;
|
||||
|
||||
&__slider-promo {
|
||||
background: color("main-background");
|
||||
box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.14), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.2), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&__slider-promo-title {
|
||||
font-size: r(25);
|
||||
padding: r(10) r(20);
|
||||
}
|
||||
|
||||
&__slider-promo-image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__slider-promo-text {
|
||||
margin-top: r(20);
|
||||
}
|
||||
|
||||
&__slider-title {
|
||||
padding: r(10) r(30);
|
||||
//background: color("main-background");
|
||||
//box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.14), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.2), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&__slider {
|
||||
@include main-container;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(1);
|
||||
background: color("main-background");
|
||||
}
|
||||
|
||||
&__slider-list {
|
||||
display: flex;
|
||||
gap: r(20);
|
||||
padding: r(5) r(20);
|
||||
overflow-x: auto;
|
||||
|
||||
@media (max-width: $mobileWidth) {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__slider-footer {
|
||||
height: r(15);
|
||||
//background: color("main-background");
|
||||
//box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.14), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.2), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&__slider-item {
|
||||
@include main-container;
|
||||
|
||||
min-width: 60%;
|
||||
padding: r(10);
|
||||
box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.34), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.5), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
</style>
|
62
src/components/HomeView/Tabs/Hookah/HeavinessTab.vue
Normal file
62
src/components/HomeView/Tabs/Hookah/HeavinessTab.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="hookah-heaviness">
|
||||
<div class="hookah-heaviness__container">
|
||||
<div
|
||||
class="hookah-heaviness__item"
|
||||
v-for="item in hookahStore.heavinessList"
|
||||
:key="item.title"
|
||||
:class="{ 'hookah-heaviness__item--active': hookahStore.selectedHeaviness === item }"
|
||||
@click="changeHeaviness(item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHookahStore } from "@/store";
|
||||
import { heaviness } from "@/types";
|
||||
|
||||
const hookahStore = useHookahStore();
|
||||
|
||||
function changeHeaviness(heaviness: heaviness) {
|
||||
hookahStore.selectedHeaviness = heaviness;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hookah-heaviness {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(10);
|
||||
width: 80%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__item {
|
||||
@include main-container;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: r(10) r(10);
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
transition-property: background-color, color, box-shadow;
|
||||
|
||||
&--active {
|
||||
background: color("theme-color");
|
||||
color: color("ui-color-on-background");
|
||||
box-shadow: 0 r(2) r(2) 0 color("theme-color", $alpha: -0.86), 0 r(3) r(1) r(-2) color("theme-color", $alpha: -0.8),
|
||||
0 r(1) r(5) 0 color("theme-color", $alpha: -0.88);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
416
src/components/HomeView/Tabs/Hookah/HookahMain.vue
Normal file
416
src/components/HomeView/Tabs/Hookah/HookahMain.vue
Normal file
@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<div class="hookah" :class="'hookah--transition-direction--' + direction">
|
||||
<div class="hookah__navigation">
|
||||
<div class="hookah__navigation-title">Создаём заказ. {{ currentTabIndex < 3 ? "Выберите..." : "Проверьте..." }}</div>
|
||||
<div class="hookah__navigation-buttons">
|
||||
<RouterLink
|
||||
class="hookah__navigation-item hookah-navigation-item"
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.name"
|
||||
:to="tab.path"
|
||||
active-class="hookah-navigation-item--active"
|
||||
:class="{ 'hookah-navigation-item--completed': isTabCompleted(index) }"
|
||||
@click.prevent="changeTab(index)"
|
||||
>
|
||||
<div class="hookah-navigation-item__side" v-html="getSideShape('rect')" v-if="index === 0"></div>
|
||||
<div class="hookah-navigation-item__side" v-html="getSideShape('left')" v-else></div>
|
||||
<div class="hookah-navigation-item__text">{{ tab.name }}</div>
|
||||
<div class="hookah-navigation-item__side" v-html="getSideShape('rect')" v-if="index === tabs.length - 1"></div>
|
||||
<div class="hookah-navigation-item__side" v-html="getSideShape('right')" v-else></div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hookah__view">
|
||||
<RouterView v-slot="{ Component }" class="hookah__page">
|
||||
<Transition name="hookah--transition">
|
||||
<Component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
|
||||
<Transition name="hookah__view-button-container--transition">
|
||||
<div class="hookah__view-button-container" v-show="navigationButtonShowed" @click="nextButtonClicked()">
|
||||
<ButtonComp class="hookah__view-button" type="primary" value="Далее" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="num in 3"
|
||||
:key="num"
|
||||
class="hookah__background-smoke"
|
||||
:class="`hookah__background-smoke--${num}`"
|
||||
:style="`opacity: ${getSmokeOpacity(num)}`"
|
||||
></div>
|
||||
<div class="hookah__background-table" :style="`opacity: ${backgroundTableOpacity}`">
|
||||
{{ backgroundTableNumber }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AuthorizationNeeded />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, ref, watch } from "vue";
|
||||
import AuthorizationNeeded from "@/components/HomeView/AuthorizationNeeded.vue";
|
||||
import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router";
|
||||
import { useHookahStore, useUIStore, useUserStore } from "@/store";
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const hookahStore = useHookahStore();
|
||||
const userStore = useUserStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const html = (val: any) => val;
|
||||
|
||||
const tabs = [
|
||||
{ name: "Стол", path: "/hookah/table" },
|
||||
{ name: "Тяжесть", path: "/hookah/heaviness" },
|
||||
{ name: "Вкус", path: "/hookah/taste" },
|
||||
{ name: "Итог", path: "/hookah/summary" },
|
||||
];
|
||||
|
||||
const direction = ref("none");
|
||||
const currentTabIndex = computed(() => {
|
||||
return getTabIndexByPath(route.path);
|
||||
});
|
||||
|
||||
init();
|
||||
function init() {
|
||||
router.push(getPreviousNotCompletedTab(3)?.path ?? tabs[3].path);
|
||||
}
|
||||
onActivated(() => init());
|
||||
|
||||
function getTabIndexByPath(path: string) {
|
||||
let tab = tabs.find((x) => path === x.path);
|
||||
return tab ? tabs.indexOf(tab) : -1;
|
||||
}
|
||||
|
||||
function changeTab(index: number) {
|
||||
if (currentTabIndex.value === index) return;
|
||||
|
||||
if (currentTabIndex.value > index) {
|
||||
direction.value = "left";
|
||||
} else {
|
||||
direction.value = "right";
|
||||
}
|
||||
|
||||
router.push(tabs[index].path);
|
||||
|
||||
setTimeout(() => {
|
||||
direction.value = "none";
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function getSideShape(type: "right" | "left" | "rect") {
|
||||
switch (type) {
|
||||
case "right":
|
||||
return html`
|
||||
<svg class="hookah-navigation-item__shape" viewbox="0 0 55 100">
|
||||
<polygon points="0,0 5,0 55,50 5,100 0,100" />
|
||||
</svg>
|
||||
`;
|
||||
case "left":
|
||||
return html`
|
||||
<svg class="hookah-navigation-item__shape" viewbox="0 0 55 100">
|
||||
<polygon points="0,0 55,0 55,100 0,100 50,50" />
|
||||
</svg>
|
||||
`;
|
||||
case "rect":
|
||||
return html`
|
||||
<svg class="hookah-navigation-item__shape" viewbox="0 0 51 100">
|
||||
<polygon points="1,0 51,0 51,100 1,100" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const navigationButtonLeftShowed = computed(() => {
|
||||
if (direction.value != "none") return false;
|
||||
return currentTabIndex.value > 0;
|
||||
});
|
||||
|
||||
const navigationButtonRightShowed = computed(() => {
|
||||
if (currentTabIndex.value >= tabs.length - 1) return false;
|
||||
if (direction.value != "none") return false;
|
||||
|
||||
return isTabCompleted(currentTabIndex.value);
|
||||
});
|
||||
|
||||
const navigationButtonShowed = computed(() => {
|
||||
if (currentTabIndex.value >= tabs.length - 1) return false;
|
||||
if (direction.value !== "none") return false;
|
||||
|
||||
return isTabCompleted(currentTabIndex.value);
|
||||
});
|
||||
|
||||
function nextButtonClicked() {
|
||||
const nextIncompletedTab = getNextNotCompletedTab(currentTabIndex.value);
|
||||
changeTab(tabs.indexOf(nextIncompletedTab));
|
||||
}
|
||||
|
||||
function isTabCompleted(index: number) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return hookahStore.selectedTableNumber > 0;
|
||||
case 1:
|
||||
return hookahStore.selectedHeaviness != null;
|
||||
case 2:
|
||||
return hookahStore.selectedTastes.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviousNotCompletedTab(index: number) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (!isTabCompleted(i)) {
|
||||
return tabs[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNextNotCompletedTab(index: number) {
|
||||
for (let i = index; i < 3; i++) {
|
||||
if (!isTabCompleted(i)) {
|
||||
return tabs[i];
|
||||
}
|
||||
}
|
||||
return tabs[3];
|
||||
}
|
||||
|
||||
function getSmokeOpacity(num: number) {
|
||||
if (hookahStore.selectedHeaviness && hookahStore.selectedHeaviness.id >= num - 1) {
|
||||
return uiStore.theme === "light" ? 1 : 0.1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const backgroundTableOpacity = ref(0);
|
||||
const backgroundTableNumber = ref(0);
|
||||
|
||||
let backgroundTableTimeout: number;
|
||||
|
||||
function changeBackgroundTableNumber(to: number) {
|
||||
clearTimeout(backgroundTableTimeout);
|
||||
backgroundTableOpacity.value = 0;
|
||||
backgroundTableTimeout = setTimeout(() => {
|
||||
backgroundTableNumber.value = to;
|
||||
backgroundTableOpacity.value = to > 0 ? 1 : 0;
|
||||
}, 380);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => hookahStore.selectedTableNumber,
|
||||
(to) => {
|
||||
changeBackgroundTableNumber(to);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
const tab = getPreviousNotCompletedTab(getTabIndexByPath(to.path));
|
||||
if (tab && to.path !== tab.path) {
|
||||
return tab.path;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hookah {
|
||||
$p: &;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__navigation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: r(10) 0;
|
||||
gap: r(10);
|
||||
|
||||
background: color("main-background");
|
||||
box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.14), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.2), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.12);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__navigation-buttons {
|
||||
--height: 4rem;
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
}
|
||||
|
||||
&__navigation-item {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&__view {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__view-button-container {
|
||||
position: absolute;
|
||||
inset: auto 0 r(40) 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: color("ui-color");
|
||||
cursor: pointer;
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__view-button {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
&__page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: r(20);
|
||||
}
|
||||
|
||||
&__background-smoke {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("@/assets/images/smoke.webp");
|
||||
background-size: r(1200);
|
||||
background-repeat: no-repeat;
|
||||
transition: opacity 0.38s ease;
|
||||
|
||||
&--1 {
|
||||
background-position: 50% 40vh;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&--2 {
|
||||
background-position: 25% 10vh;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&--3 {
|
||||
background-position: 75% -20vh;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__background-table {
|
||||
position: absolute;
|
||||
inset: auto 0 0 auto;
|
||||
padding: r(20);
|
||||
|
||||
color: transparent;
|
||||
font-size: 30vh;
|
||||
font-style: italic;
|
||||
text-shadow: 0 0 r(8) color("ui-color", $alpha: -0.9);
|
||||
|
||||
transition: opacity 0.38s ease;
|
||||
z-index: 4;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&--transition-direction {
|
||||
&--left {
|
||||
#{$p}--transition {
|
||||
&-enter-from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
&-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--right {
|
||||
#{$p}--transition {
|
||||
&-enter-from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
&-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hookah-navigation-item {
|
||||
$p: &;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&__side {
|
||||
height: var(--height);
|
||||
width: calc(var(--height + r(5)) / 2);
|
||||
margin: 0 r(-1);
|
||||
}
|
||||
|
||||
&__shape {
|
||||
display: block;
|
||||
height: 100%;
|
||||
fill: color("ui-background");
|
||||
transition: fill 0.18s ease;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
background: color("ui-background");
|
||||
color: color("ui-color-on-background");
|
||||
font-size: r(24);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
#{$p}__text {
|
||||
background: color("theme-color", $saturation: -50%);
|
||||
}
|
||||
|
||||
#{$p}__shape {
|
||||
fill: color("theme-color", $saturation: -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
#{$p}__text {
|
||||
background: color("theme-color");
|
||||
}
|
||||
|
||||
#{$p}__shape {
|
||||
fill: color("theme-color");
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
68
src/components/HomeView/Tabs/Hookah/SummaryTab.vue
Normal file
68
src/components/HomeView/Tabs/Hookah/SummaryTab.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="hookah-summary">
|
||||
<div class="hookah-summary__container">
|
||||
<div>Стол: {{ hookahStore.selectedTableNumber }}</div>
|
||||
<div>Тяжесть: {{ hookahStore.selectedHeaviness?.title }}</div>
|
||||
<div>Вкус: {{ hookahStore.selectedTastes.map((x) => x.title).join(", ") }}</div>
|
||||
<div>Цена: {{ 123 }}</div>
|
||||
<ButtonComp :type="'primary'" :value="'Заказать'" @click="createOrder()" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHookahStore, useOrdersStore } from "@/store";
|
||||
import { useOrdersApi } from "@/composables/api";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const hookahStore = useHookahStore();
|
||||
const ordersStore = useOrdersStore();
|
||||
const ordersApi = useOrdersApi();
|
||||
const router = useRouter();
|
||||
|
||||
async function createOrder() {
|
||||
const order = await tryCreateOrder();
|
||||
if (order) {
|
||||
ordersStore.createOrder(order);
|
||||
setTimeout(() => {
|
||||
hookahStore.clearSelected();
|
||||
}, 200);
|
||||
|
||||
router.push("/orders");
|
||||
}
|
||||
}
|
||||
|
||||
async function tryCreateOrder() {
|
||||
if (hookahStore.selectedHeaviness == null) return;
|
||||
|
||||
const resp = await ordersApi.createOrder({
|
||||
table: hookahStore.selectedTableNumber,
|
||||
heavinessId: hookahStore.selectedHeaviness.id,
|
||||
tasteIds: hookahStore.selectedTastes.map((x) => x.id),
|
||||
});
|
||||
|
||||
return resp;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hookah-summary {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&__container {
|
||||
@include main-container;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: r(10);
|
||||
width: 80%;
|
||||
padding: r(15);
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
</style>
|
62
src/components/HomeView/Tabs/Hookah/TableTab.vue
Normal file
62
src/components/HomeView/Tabs/Hookah/TableTab.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="hookah-table">
|
||||
<div class="hookah-table__container">
|
||||
<div
|
||||
class="hookah-table__item"
|
||||
:class="{ 'hookah-table__item--active': hookahStore.selectedTableNumber === num }"
|
||||
v-for="num in 15"
|
||||
:key="num"
|
||||
@click="setTable(num)"
|
||||
>
|
||||
{{ num }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHookahStore } from "@/store";
|
||||
|
||||
const hookahStore = useHookahStore();
|
||||
|
||||
function setTable(num: number) {
|
||||
hookahStore.selectedTableNumber = num;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hookah-table {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
flex-wrap: wrap;
|
||||
gap: r(10);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&__item {
|
||||
@include main-container;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(45);
|
||||
height: r(45);
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
transition-property: background-color, color, box-shadow;
|
||||
|
||||
&--active {
|
||||
background: color("theme-color");
|
||||
color: color("ui-color-on-background");
|
||||
box-shadow: 0 r(2) r(2) 0 color("theme-color", $alpha: -0.86), 0 r(3) r(1) r(-2) color("theme-color", $alpha: -0.8),
|
||||
0 r(1) r(5) 0 color("theme-color", $alpha: -0.88);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
73
src/components/HomeView/Tabs/Hookah/TasteTab.vue
Normal file
73
src/components/HomeView/Tabs/Hookah/TasteTab.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="hookah-taste">
|
||||
<div class="hookah-taste__container">
|
||||
<div
|
||||
class="hookah-taste__item"
|
||||
:class="{ 'hookah-taste__item--active': hookahStore.selectedTastes.includes(item) }"
|
||||
v-for="item in hookahStore.tastesList"
|
||||
:key="item.id"
|
||||
@click="toggleTaste(item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHookahStore } from "@/store";
|
||||
import { taste } from "@/types";
|
||||
|
||||
const hookahStore = useHookahStore();
|
||||
|
||||
let maxSelectedTastesCount = 2;
|
||||
|
||||
function toggleTaste(taste: taste) {
|
||||
if (hookahStore.selectedTastes.includes(taste)) {
|
||||
hookahStore.selectedTastes = hookahStore.selectedTastes.filter((x) => x !== taste);
|
||||
} else {
|
||||
if (hookahStore.selectedTastes.length < maxSelectedTastesCount) {
|
||||
hookahStore.selectedTastes.push(taste);
|
||||
} else {
|
||||
console.log("can't select more than ", maxSelectedTastesCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hookah-taste {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: r(10);
|
||||
width: 80%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__item {
|
||||
@include main-container;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: r(5) r(10);
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
transition-property: background-color, color, box-shadow;
|
||||
|
||||
&--active {
|
||||
background: color("theme-color");
|
||||
color: color("ui-color-on-background");
|
||||
box-shadow: 0 r(2) r(2) 0 color("theme-color", $alpha: -0.86), 0 r(3) r(1) r(-2) color("theme-color", $alpha: -0.8),
|
||||
0 r(1) r(5) 0 color("theme-color", $alpha: -0.88);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
75
src/components/HomeView/Tabs/Menu/MenuItem.vue
Normal file
75
src/components/HomeView/Tabs/Menu/MenuItem.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="menu-item" @click="openModal()">
|
||||
<ImageComp class="menu-item__image" src="/media/images/tobaco1.webp" />
|
||||
<div class="menu-item__title">{{ item.title }}</div>
|
||||
<div class="menu-item__description">{{ item.description }}</div>
|
||||
<div class="menu-item__price">
|
||||
{{ item.price }}
|
||||
<FontAwesomeIcon class="menu-item__price-icon" icon="ruble-sign" />
|
||||
</div>
|
||||
<ModalComp ref="refModal">
|
||||
<div class="menu-item__modal">
|
||||
<ImageComp class="menu-item__image" src="/media/images/tobaco1.webp" />
|
||||
<div class="menu-item__title">{{ item.title }}</div>
|
||||
<div class="menu-item__description">{{ item.description }}</div>
|
||||
<div class="menu-item__price">
|
||||
{{ item.price }}
|
||||
<FontAwesomeIcon class="menu-item__price-icon" icon="ruble-sign" />
|
||||
</div>
|
||||
</div>
|
||||
</ModalComp>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { menuItem } from "@/types";
|
||||
import { onDeactivated, ref } from "vue";
|
||||
import ModalComp from "@/components/UI/ModalComp.vue";
|
||||
|
||||
const refModal = ref<InstanceType<typeof ModalComp> | undefined>();
|
||||
const props = defineProps<{ item: menuItem }>();
|
||||
|
||||
onDeactivated(() => {
|
||||
refModal.value?.close();
|
||||
});
|
||||
|
||||
function openModal() {
|
||||
refModal.value?.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(10);
|
||||
width: 90%;
|
||||
margin: r(10) 0;
|
||||
padding: r(15);
|
||||
border-radius: r(6);
|
||||
background: color("main-background");
|
||||
box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.14), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.2), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.12);
|
||||
|
||||
&__image {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: r(23);
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-size: r(23);
|
||||
}
|
||||
|
||||
&__price-icon {
|
||||
font-size: r(20);
|
||||
}
|
||||
|
||||
&__modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(10);
|
||||
}
|
||||
}
|
||||
</style>
|
163
src/components/HomeView/Tabs/Menu/MenuMain.vue
Normal file
163
src/components/HomeView/Tabs/Menu/MenuMain.vue
Normal file
@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="menu">
|
||||
<div class="menu__navigation">
|
||||
<div class="menu__navigation-container">
|
||||
<div
|
||||
class="menu__navigation-item"
|
||||
v-for="category in menuStore.categories"
|
||||
:class="{ 'menu__navigation-item--active': selectedCategory === category }"
|
||||
:key="category.id"
|
||||
@click="changeCategory(category)"
|
||||
:ref="(el)=>refCategories.push(el as Element)"
|
||||
>
|
||||
{{ category.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu__list" ref="refMenuList">
|
||||
<TransitionGroup name="menu__list-item--transition">
|
||||
<MenuItem
|
||||
class="menu__list-item"
|
||||
v-for="item in selectedMenuItems"
|
||||
:key="Math.random() * (item.id + 1)"
|
||||
:item="item"
|
||||
></MenuItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { category } from "@/types";
|
||||
import { useMenuApi } from "@/composables/api";
|
||||
import { useMenuStore } from "@/store";
|
||||
import MenuItem from "./MenuItem.vue";
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
const menuApi = useMenuApi();
|
||||
|
||||
const refCategories = ref<Element[]>([]);
|
||||
const refMenuList = ref<Element>();
|
||||
|
||||
init();
|
||||
function init() {
|
||||
menuStore.categories = menuApi.getCategories();
|
||||
menuStore.menuItems = menuApi.getMenuItems();
|
||||
}
|
||||
|
||||
const selectedMenuItems = computed(() =>
|
||||
selectedCategory.value == null ? menuStore.menuItems : menuStore.menuItems.filter((x) => x.categoryId === selectedCategory.value?.id)
|
||||
);
|
||||
const selectedCategory = ref<category | undefined>(undefined);
|
||||
|
||||
function changeCategory(category: category) {
|
||||
if (selectedCategory.value === category) {
|
||||
selectedCategory.value = undefined;
|
||||
} else {
|
||||
selectedCategory.value = category;
|
||||
const selectedCategoryElement = refCategories.value[menuStore.categories.indexOf(selectedCategory.value)];
|
||||
selectedCategoryElement.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
||||
}
|
||||
setTimeout(() => {
|
||||
refMenuList.value?.scroll(0, 0);
|
||||
}, 180);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&__navigation {
|
||||
position: relative;
|
||||
flex: 0 1 auto;
|
||||
background: color("main-background");
|
||||
box-shadow: 0 r(4) r(18) 0 rgba(0, 0, 0, 0.12), 0 r(7) r(10) r(-5) rgba(0, 0, 0, 0.15);
|
||||
z-index: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__navigation-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 0 r(35);
|
||||
|
||||
@media (max-width: $mobileWidth) {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__navigation-item {
|
||||
position: relative;
|
||||
padding: r(15);
|
||||
font-size: r(24);
|
||||
font-weight: 300;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: r(4);
|
||||
border-radius: r(2);
|
||||
background: color("theme-color");
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
&--active {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: r(10) 0 r(20) 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__list-item {
|
||||
flex: 0 0 auto;
|
||||
|
||||
--transition-duration: 0.18s;
|
||||
|
||||
&--transition {
|
||||
&-enter-active {
|
||||
transition: opacity var(--transition-duration) var(--transition-duration) ease;
|
||||
}
|
||||
&-leave-active {
|
||||
transition: opacity var(--transition-duration) ease;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
159
src/components/HomeView/Tabs/Orders/OrdersMain.vue
Normal file
159
src/components/HomeView/Tabs/Orders/OrdersMain.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<div class="orders">
|
||||
<div class="orders__list">
|
||||
<div class="orders__item" v-for="(order, index) in ordersStore.orders" :key="order.id">
|
||||
<div class="orders__item-main">
|
||||
<div class="orders__item-head">
|
||||
<div>Заказ № {{ order.id }}</div>
|
||||
<div>статус: {{ order.status }}</div>
|
||||
</div>
|
||||
<div>Стол: {{ order.table }}</div>
|
||||
<div>Тяжесть: {{ order.heaviness.title }}</div>
|
||||
<div>Вкус: {{ order.tastes.map((x) => x.title).join(", ") }}</div>
|
||||
<div>Цена: {{ order.price }}</div>
|
||||
<div
|
||||
class="orders__item-favourite"
|
||||
:class="{ 'orders__item-favourite--active': order.favourite }"
|
||||
@click="toggleFavourite(order)"
|
||||
>
|
||||
<FontAwesomeIcon icon="star" />
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
class="orders__item-expand"
|
||||
:class="{ 'orders__item-expand--active': index === expandedIndex }"
|
||||
icon="chevron-down"
|
||||
@click="toggleExpanded(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="orders__item-add" v-show="index === expandedIndex">
|
||||
<ButtonComp type="primary" value="Повторить заказ" @click="repeatOrder(order)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="orders__item" v-if="ordersStore.orders.length === 0">
|
||||
У вас пока нет ни одного заказа...
|
||||
<RouterLink class="orders__item-link" to="/hookah/table">Сделать заказ</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AuthorizationNeeded />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AuthorizationNeeded from "@/components/HomeView/AuthorizationNeeded.vue";
|
||||
import { useOrdersApi } from "@/composables/api";
|
||||
import { useUserStore, useOrdersStore, useHookahStore } from "@/store";
|
||||
import { order } from "@/types";
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const ordersStore = useOrdersStore();
|
||||
const hookahStore = useHookahStore();
|
||||
const ordersApi = useOrdersApi();
|
||||
const router = useRouter();
|
||||
|
||||
const expandedIndex = ref(-1);
|
||||
|
||||
async function toggleFavourite(order: order) {
|
||||
const resp = await ordersApi.toggleFavourite(order);
|
||||
|
||||
if (resp) {
|
||||
order.favourite = !order.favourite;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(index: number) {
|
||||
if (index === expandedIndex.value) {
|
||||
expandedIndex.value = -1;
|
||||
} else {
|
||||
expandedIndex.value = index;
|
||||
}
|
||||
}
|
||||
|
||||
function repeatOrder(order: order) {
|
||||
hookahStore.selectedHeaviness = order.heaviness;
|
||||
hookahStore.selectedTastes = order.tastes;
|
||||
router.push("/hookah/table");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.orders {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: r(20);
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(20);
|
||||
}
|
||||
|
||||
&__item {
|
||||
@include main-container;
|
||||
|
||||
position: relative;
|
||||
padding: r(10);
|
||||
}
|
||||
|
||||
&__item-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: r(5);
|
||||
}
|
||||
|
||||
&__item-add {
|
||||
margin-top: r(10);
|
||||
}
|
||||
|
||||
&__item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: calc(100% - r(30));
|
||||
}
|
||||
|
||||
&__item-favourite {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: r(20);
|
||||
height: r(20);
|
||||
color: color("ui-background");
|
||||
transition: color 0.18s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
color: color("theme-color");
|
||||
}
|
||||
}
|
||||
|
||||
&__item-expand {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: r(20);
|
||||
height: r(20);
|
||||
color: color("ui-background");
|
||||
transition: transform 0.18s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__item-link {
|
||||
color: color("theme-color");
|
||||
}
|
||||
}
|
||||
</style>
|
340
src/components/HomeView/Tabs/User/UserMain.vue
Normal file
340
src/components/HomeView/Tabs/User/UserMain.vue
Normal file
@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<div class="user">
|
||||
<div class="user__container">
|
||||
<div class="user__user-data">
|
||||
<SwitchableImage
|
||||
class="user__image"
|
||||
:value="localUserData.image"
|
||||
:edited="userDataEdited"
|
||||
@change="changeLocalImageValue"
|
||||
/>
|
||||
<InputComp
|
||||
class="user__name"
|
||||
v-model="localUserData.name"
|
||||
placeholder="Имя"
|
||||
:hasWarning="fieldHasErrors('Имя')"
|
||||
:readOnly="!userDataEdited"
|
||||
></InputComp>
|
||||
<InputComp
|
||||
class="user__surname"
|
||||
v-model="localUserData.surname"
|
||||
placeholder="Фамилия"
|
||||
:hasWarning="fieldHasErrors('Фамилия')"
|
||||
:readOnly="!userDataEdited"
|
||||
></InputComp>
|
||||
|
||||
<InputComp class="user__email" v-model="userStore.userData.email" :placeholder="'Почта'" :readOnly="true"></InputComp>
|
||||
|
||||
<ButtonComp
|
||||
class="user__button"
|
||||
v-if="userDataEdited"
|
||||
value="Сохранить изменения"
|
||||
type="primary"
|
||||
@click="tryChangeUserData()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="user__password" v-if="userDataEdited">
|
||||
<template v-if="userPasswordEdited">
|
||||
<InputComp
|
||||
class="user__name"
|
||||
v-model="localPasswords.oldPassword"
|
||||
type="password"
|
||||
:hasWarning="fieldHasErrors('Старый пароль')"
|
||||
placeholder="Старый пароль"
|
||||
></InputComp>
|
||||
<InputComp
|
||||
class="user__surname"
|
||||
v-model="localPasswords.newPassword"
|
||||
type="password"
|
||||
:hasWarning="fieldHasErrors('Новый пароль')"
|
||||
placeholder="Новый пароль"
|
||||
></InputComp>
|
||||
<InputComp
|
||||
class="user__surname"
|
||||
v-model="localPasswords.newPassword2"
|
||||
type="password"
|
||||
:hasWarning="fieldHasErrors('Новый пароль')"
|
||||
placeholder="Повторите новый пароль"
|
||||
></InputComp>
|
||||
</template>
|
||||
|
||||
<ButtonComp
|
||||
class="user__button"
|
||||
value="Сменить пароль"
|
||||
:type="passwordButtonStyle"
|
||||
@click="changePasswordButtonHandler()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="user__edit-toggle">
|
||||
<FontAwesomeIcon class="fa-icon" icon="user-pen" v-if="!userDataEdited" @click="startEdit()" />
|
||||
<FontAwesomeIcon class="fa-icon" icon="xmark" v-else @click="stopEdit()" />
|
||||
</div>
|
||||
|
||||
<ButtonComp class="user__button" v-if="!userDataEdited" value="Выйти" type="primary" @click="tryLogout()" />
|
||||
<ThemeSwitcher class="user__theme-switcher" />
|
||||
|
||||
<NotificationsMain class="user__notifications" v-model="notifications" />
|
||||
<ModalComp ref="refModal">Вы действительно хотите выйти?</ModalComp>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AuthorizationNeeded />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onDeactivated, ref, watch } from "vue";
|
||||
import SwitchableImage from "@/components/Common/SwitchableImage.vue";
|
||||
import AuthorizationNeeded from "@/components/HomeView/AuthorizationNeeded.vue";
|
||||
import NotificationsMain from "@/components/Notifications/NotificationsMain.vue";
|
||||
import { useUserStore } from "@/store";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useUserApi } from "@/composables/api";
|
||||
import { convertFileToBase64String } from "@/composables/utils";
|
||||
import { FieldChecks, FieldsContainer } from "@/modules/validator";
|
||||
import { notification } from "@/types";
|
||||
import ModalComp from "@/components/UI/ModalComp.vue";
|
||||
import ThemeSwitcher from "@/components/Common/ThemeSwitcher.vue";
|
||||
|
||||
const refModal = ref<InstanceType<typeof ModalComp> | undefined>();
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const userApi = useUserApi();
|
||||
|
||||
async function tryLogout() {
|
||||
const result = await refModal.value?.open({ type: "cancel_leave" });
|
||||
if (result === "leave") {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
router.push("/login");
|
||||
userStore.resetData();
|
||||
}
|
||||
|
||||
const userDataEdited = ref(false);
|
||||
const userPasswordEdited = ref(false);
|
||||
|
||||
const localUserData = ref<{ name: string; surname: string; image: string; imageFile?: File }>({
|
||||
...userStore.userData,
|
||||
imageFile: undefined,
|
||||
});
|
||||
const localPasswords = ref({ oldPassword: "", newPassword: "", newPassword2: "" });
|
||||
|
||||
const passwordButtonStyle = computed(() =>
|
||||
!userPasswordEdited.value ||
|
||||
(localPasswords.value.oldPassword !== "" && localPasswords.value.newPassword !== "" && localPasswords.value.newPassword2 !== "")
|
||||
? "primary"
|
||||
: "disabled"
|
||||
);
|
||||
|
||||
function startEdit() {
|
||||
userDataEdited.value = true;
|
||||
}
|
||||
function stopEdit() {
|
||||
userDataEdited.value = false;
|
||||
userPasswordEdited.value = false;
|
||||
localUserData.value = { ...userStore.userData };
|
||||
localPasswords.value = { oldPassword: "", newPassword: "", newPassword2: "" };
|
||||
}
|
||||
|
||||
const notifications = ref<notification[]>([]);
|
||||
|
||||
async function tryChangeUserData() {
|
||||
let errors = getUserDataFieldErrors();
|
||||
|
||||
notifications.value = errors;
|
||||
|
||||
if (errors.length > 0) return;
|
||||
|
||||
const resp = await userApi.changeUserData(localUserData.value);
|
||||
userStore.setFieldValue("name", resp.name);
|
||||
userStore.setFieldValue("surname", resp.surname);
|
||||
userStore.setFieldValue("image", resp.image);
|
||||
|
||||
notifications.value = [{ text: "Данные успешно изменены", type: "success" }];
|
||||
|
||||
stopEdit();
|
||||
}
|
||||
|
||||
async function changeLocalImageValue(file: File) {
|
||||
console.log(file);
|
||||
if (!file.type.includes("image")) {
|
||||
alert("Необходимо выбрать изображение");
|
||||
return;
|
||||
}
|
||||
|
||||
localUserData.value.imageFile = file;
|
||||
localUserData.value.image = await convertFileToBase64String(file);
|
||||
}
|
||||
|
||||
function getUserDataFieldErrors() {
|
||||
const container = new FieldsContainer();
|
||||
|
||||
container.addField("Имя", localUserData.value.name, [
|
||||
FieldChecks.nonEmpty,
|
||||
FieldChecks.onlyLettersWhitespaces,
|
||||
FieldChecks.minLength(2),
|
||||
FieldChecks.maxLength(20),
|
||||
]);
|
||||
container.addField("Фамилия", localUserData.value.surname, [
|
||||
FieldChecks.nonEmpty,
|
||||
FieldChecks.onlyLettersWhitespaces,
|
||||
FieldChecks.minLength(2),
|
||||
FieldChecks.maxLength(20),
|
||||
]);
|
||||
|
||||
let errors: notification[] = [];
|
||||
for (let [key, value] of container.fields) {
|
||||
const error = value.tryGetFirstError();
|
||||
if (error !== "") {
|
||||
errors.push({ field: key, text: error, type: "warning" });
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function changePasswordButtonHandler() {
|
||||
if (!userPasswordEdited.value) {
|
||||
userPasswordEdited.value = true;
|
||||
} else {
|
||||
tryChangeUserPassword();
|
||||
}
|
||||
}
|
||||
|
||||
async function tryChangeUserPassword() {
|
||||
let errors = getUserPasswordFieldErrors();
|
||||
|
||||
notifications.value = errors;
|
||||
|
||||
if (errors.length > 0) return;
|
||||
|
||||
const resp = await userApi.changeUserPassword(localPasswords.value);
|
||||
|
||||
notifications.value = [{ text: "Пароль успешно изменён", type: "success" }];
|
||||
|
||||
stopEdit();
|
||||
}
|
||||
|
||||
function getUserPasswordFieldErrors() {
|
||||
const container = new FieldsContainer();
|
||||
|
||||
container.addField("Старый пароль", localPasswords.value.oldPassword, [FieldChecks.nonEmpty]);
|
||||
|
||||
container.addField("Новый пароль", localPasswords.value.newPassword, [
|
||||
FieldChecks.nonEmpty,
|
||||
FieldChecks.minLength(6),
|
||||
FieldChecks.maxLength(32),
|
||||
]);
|
||||
|
||||
let errors: notification[] = [];
|
||||
for (let [key, value] of container.fields) {
|
||||
const error = value.tryGetFirstError();
|
||||
if (error !== "") {
|
||||
errors.push({ field: key, text: error, type: "warning" });
|
||||
}
|
||||
}
|
||||
|
||||
if (localPasswords.value.newPassword !== localPasswords.value.newPassword2) {
|
||||
errors.push({ field: "Новый пароль", text: "Пароли не совпадают.", type: "warning" });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function fieldHasErrors(field: string) {
|
||||
return notifications.value.some((x) => x.field === field);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userStore.userData.name,
|
||||
(to) => (localUserData.value.name = to)
|
||||
);
|
||||
watch(
|
||||
() => userStore.userData.surname,
|
||||
(to) => (localUserData.value.surname = to)
|
||||
);
|
||||
|
||||
onDeactivated(() => {
|
||||
stopEdit();
|
||||
notifications.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.user {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: r(20);
|
||||
|
||||
&__container {
|
||||
@include main-container;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: r(20);
|
||||
}
|
||||
|
||||
&__user-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__image {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 auto r(15) auto;
|
||||
}
|
||||
|
||||
&__email {
|
||||
//display: flex;
|
||||
//align-items: center;
|
||||
}
|
||||
|
||||
&__password {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: r(25);
|
||||
}
|
||||
|
||||
&__button {
|
||||
align-self: center;
|
||||
margin-top: r(15);
|
||||
}
|
||||
|
||||
&__edit-toggle {
|
||||
position: absolute;
|
||||
top: r(20);
|
||||
right: r(20);
|
||||
width: r(30);
|
||||
height: r(30);
|
||||
color: color("ui-background");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__theme-switcher {
|
||||
position: absolute;
|
||||
top: r(20);
|
||||
left: r(20);
|
||||
width: r(30);
|
||||
height: r(30);
|
||||
color: color("ui-background");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__notifications {
|
||||
position: fixed;
|
||||
inset: auto 0 r(90) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
129
src/components/Intro/IntroMain.vue
Normal file
129
src/components/Intro/IntroMain.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="intro">
|
||||
<SliderComp class="intro__slider" type="default" :automatic="false" @index="sliderIndexChanged">
|
||||
<SliderTabComp>
|
||||
<div class="intro__tab-content">
|
||||
<div class="intro__tab-image-container">
|
||||
<img class="intro__tab-image" src="/media/images/intro-image-1.webp" />
|
||||
</div>
|
||||
<div class="intro__tab-text">Подпись для первой картинки</div>
|
||||
</div>
|
||||
</SliderTabComp>
|
||||
<SliderTabComp>
|
||||
<div class="intro__tab-content">
|
||||
<div class="intro__tab-image-container">
|
||||
<img class="intro__tab-image" src="/media/images/intro-image-2.webp" />
|
||||
</div>
|
||||
<div class="intro__tab-text">Подпись для второй картинки</div>
|
||||
</div>
|
||||
</SliderTabComp>
|
||||
<SliderTabComp>
|
||||
<div class="intro__tab-content">
|
||||
<div class="intro__tab-image-container">
|
||||
<img class="intro__tab-image" src="/media/images/intro-image-3.webp" />
|
||||
</div>
|
||||
<div class="intro__tab-text">Подпись для третьей картинки</div>
|
||||
</div>
|
||||
</SliderTabComp>
|
||||
</SliderComp>
|
||||
<Transition name="intro__additional-container--transition">
|
||||
<div class="intro__additional-container" v-show="isAdditionalShowed">
|
||||
<div class="intro__additional-button" @click="additionalAction()">вход</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "@/store";
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const isAdditionalShowed = ref(false);
|
||||
|
||||
function sliderIndexChanged(index: number) {
|
||||
isAdditionalShowed.value = index === 2;
|
||||
}
|
||||
|
||||
function additionalAction() {
|
||||
userStore.introWatched = true;
|
||||
userStore.saveData();
|
||||
router.push("/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.intro {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&__slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: r(60) 0;
|
||||
}
|
||||
|
||||
&__tab-image-container {
|
||||
flex: 8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__tab-image {
|
||||
max-width: 80%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&__tab-text {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__additional-container {
|
||||
position: absolute;
|
||||
bottom: r(60);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 0.28s ease;
|
||||
transition-property: bottom, opacity;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
bottom: r(30);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__additional-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: r(150);
|
||||
padding: r(5) 0;
|
||||
border: r(1) solid color("main-color");
|
||||
border-radius: r(16);
|
||||
color: color("main-color");
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
123
src/components/Notifications/NotificationsMain.vue
Normal file
123
src/components/Notifications/NotificationsMain.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="notifications">
|
||||
<TransitionGroup name="notifications--transition">
|
||||
<div
|
||||
class="notifications__item"
|
||||
:class="'notifications__item--' + notification.type"
|
||||
v-for="notification in notifications"
|
||||
:key="notification.field + notification.text"
|
||||
>
|
||||
<div class="notifications__item-name" v-if="notification.field">{{ notification.field }}:</div>
|
||||
<div class="notifications__item-text">{{ notification.text }}</div>
|
||||
<FontAwesomeIcon class="notifications__item-close" icon="xmark" @click="closeNotification(notification)" />
|
||||
<div class="notifications__item-countdown"></div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { notification } from "@/types";
|
||||
|
||||
const props = defineProps<{ modelValue: notification[] }>();
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const notifications = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (to) => {
|
||||
emit("update:modelValue", to);
|
||||
},
|
||||
});
|
||||
|
||||
function setNotificationsTimeout() {
|
||||
nextTick(() => {
|
||||
notifications.value.forEach((x) => {
|
||||
setTimeout(() => {
|
||||
closeNotification(x);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
setNotificationsTimeout();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => notifications.value,
|
||||
(to, from) => {
|
||||
if (from.length > to.length) emit("update:modelValue", to);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function closeNotification(notification: notification) {
|
||||
notifications.value = notifications.value.filter((x) => x !== notification);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.notifications {
|
||||
z-index: 100;
|
||||
|
||||
&--transition {
|
||||
&-enter-active {
|
||||
transition: 0.18s 0.18s ease;
|
||||
}
|
||||
&-leave-active {
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
|
||||
&-enter-from {
|
||||
opacity: 0;
|
||||
margin-bottom: r(-38);
|
||||
}
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-move {
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
$__item: &;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: r(5);
|
||||
padding: r(10) r(20);
|
||||
margin-top: r(1);
|
||||
font-size: r(18);
|
||||
color: color("ui-color-on-background");
|
||||
|
||||
&-field {
|
||||
flex: 0 0 r(70);
|
||||
}
|
||||
|
||||
&-text {
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
&-close {
|
||||
height: r(14);
|
||||
width: r(20);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: color("success-color");
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: color("warning-color");
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
152
src/components/UI/ButtonComp.vue
Normal file
152
src/components/UI/ButtonComp.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<button class="button-comp" :class="type != null ? `button-comp--${type}` : `button-comp--default`">
|
||||
<div class="button-comp__ripple" @mousedown="startWave" ref="refRipple">
|
||||
<div class="button-comp__content">{{ value }}</div>
|
||||
</div>
|
||||
<TransitionGroup name="button-comp__ripple-wave--transition">
|
||||
<div
|
||||
class="button-comp__ripple-wave"
|
||||
v-for="wave in waves"
|
||||
:key="wave.id"
|
||||
:style="{ left: `${wave.x}px`, top: `${wave.y}px` }"
|
||||
></div>
|
||||
</TransitionGroup>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
interface wave {
|
||||
x: number;
|
||||
y: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
defineProps<{ value: string; type: "default" | "primary" | "disabled" | undefined }>();
|
||||
|
||||
//#region handling ripple waves
|
||||
const refRipple = ref<HTMLDivElement | undefined>(undefined);
|
||||
const waves = ref<wave[]>([]);
|
||||
|
||||
function startWave(event: MouseEvent | TouchEvent) {
|
||||
if (!refRipple.value || (event.type !== "mousedown" && event.type !== "touchstart")) return;
|
||||
|
||||
const rect = refRipple.value.getBoundingClientRect();
|
||||
let x, y;
|
||||
const id = Math.random();
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
x = event.clientX - rect.left - 80;
|
||||
y = event.clientY - rect.top - 80;
|
||||
} else {
|
||||
x = event.changedTouches[0].clientX - rect.left - 80;
|
||||
y = event.changedTouches[0].clientY - rect.top - 80;
|
||||
}
|
||||
|
||||
let wave = { x, y, id };
|
||||
addWave(wave);
|
||||
|
||||
setTimeout(() => {
|
||||
removeWave(wave);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function addWave(wave: wave) {
|
||||
waves.value.push(wave);
|
||||
}
|
||||
|
||||
function removeWave(wave: wave) {
|
||||
waves.value = waves.value.filter((x) => x.id !== wave.id);
|
||||
}
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.button-comp {
|
||||
position: relative;
|
||||
border-radius: r(3);
|
||||
height: auto;
|
||||
min-width: r(88);
|
||||
line-height: 1.4;
|
||||
font-size: r(18);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
color: color("ui-color-on-background");
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
transition: background-color 0.18s ease;
|
||||
|
||||
&--default {
|
||||
background: color("main-background", $lightness: -0%);
|
||||
// box-shadow: 0 r(2) r(2) 0 color("ui-color", $alpha: -0.86), 0 r(3) r(1) r(-2) color("ui-color", $alpha: -0.8),
|
||||
// 0 r(1) r(5) 0 color("ui-color", $alpha: -0.88);
|
||||
color: color("main-color");
|
||||
|
||||
&:hover {
|
||||
background: color("main-background", $lightness: -6%);
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: color("theme-color");
|
||||
box-shadow: 0 r(2) r(2) 0 color("theme-color", $alpha: -0.86), 0 r(3) r(1) r(-2) color("theme-color", $alpha: -0.8),
|
||||
0 r(1) r(5) 0 color("theme-color", $alpha: -0.88);
|
||||
|
||||
&:hover {
|
||||
background: color("theme-color", $lightness: -3%);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: color("ui-background");
|
||||
box-shadow: 0 r(2) r(2) 0 color("ui-background", $alpha: -0.86), 0 r(3) r(1) r(-2) color("ui-background", $alpha: -0.8),
|
||||
0 r(1) r(5) 0 color("ui-background", $alpha: -0.88);
|
||||
|
||||
&:hover {
|
||||
background: color("ui-background", $lightness: -3%);
|
||||
}
|
||||
}
|
||||
|
||||
&__ripple {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: r(12) r(30);
|
||||
}
|
||||
|
||||
&__ripple-wave {
|
||||
position: absolute;
|
||||
width: r(160);
|
||||
height: r(160);
|
||||
border-radius: 50%;
|
||||
background: black;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: scale(2);
|
||||
|
||||
&--transition {
|
||||
&-enter-active {
|
||||
transition: 0.8s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
transition-property: opacity, transform;
|
||||
}
|
||||
&-enter-from {
|
||||
opacity: 0.26;
|
||||
transform: scale(0.26);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
125
src/components/UI/ComboBoxComp.vue
Normal file
125
src/components/UI/ComboBoxComp.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="combo-box-comp" :class="{ 'combo-box-comp--opened': opened }" v-click-outside="closeList">
|
||||
<div class="combo-box-comp__input" @click="toggleOpened()">
|
||||
<div class="combo-box-comp__input-text">
|
||||
{{ displayedItem?.title }}
|
||||
</div>
|
||||
<FontAwesomeIcon class="combo-box-comp__input-toggle" icon="chevron-down" />
|
||||
</div>
|
||||
<Transition name="combo-box-comp__list--transition">
|
||||
<div class="combo-box-comp__list" v-if="opened">
|
||||
<div class="combo-box-comp__item" v-for="item in props.list" :key="item" @click="selectItem(item.id)">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ComboBoxItem } from "@/types/index";
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
|
||||
const props = defineProps<{ list: ComboBoxItem[]; modelValue: ComboBoxItem | undefined }>();
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const displayedItem = computed(() => props.modelValue);
|
||||
|
||||
const opened = ref(false);
|
||||
|
||||
function toggleOpened() {
|
||||
opened.value ? closeList() : openList();
|
||||
}
|
||||
|
||||
function openList() {
|
||||
opened.value = true;
|
||||
}
|
||||
|
||||
function closeList() {
|
||||
opened.value = false;
|
||||
}
|
||||
|
||||
function selectItem(id: number) {
|
||||
emit(
|
||||
"update:modelValue",
|
||||
props.list.find((x) => x.id === id)
|
||||
);
|
||||
closeList();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.combo-box-comp {
|
||||
$p: &;
|
||||
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: r(50);
|
||||
min-width: r(200);
|
||||
font-size: r(10);
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 r(15);
|
||||
background: color("ui-background");
|
||||
border-radius: r(10);
|
||||
border: r(2) solid color("border");
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border: r(2) solid color("border-hover");
|
||||
}
|
||||
}
|
||||
|
||||
&__input-toggle {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
&__list {
|
||||
position: relative;
|
||||
margin: 0 r(8);
|
||||
background: color("ui-background");
|
||||
border: r(2) solid color("border-active");
|
||||
border-top-width: 0;
|
||||
z-index: 10;
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
cursor: pointer;
|
||||
padding: r(7);
|
||||
|
||||
transition: background-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: color("ui-background-hover");
|
||||
}
|
||||
}
|
||||
|
||||
&--opened {
|
||||
#{$p}__input {
|
||||
border: r(2) solid color("border-active");
|
||||
}
|
||||
#{$p}__input-toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
33
src/components/UI/ImageComp.vue
Normal file
33
src/components/UI/ImageComp.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="image-comp">
|
||||
<img class="image-comp__image" :src="src" @load="imageLoaded()" />
|
||||
<FontAwesomeIcon class="image-comp__loader fa-spin-pulse" icon="spinner" v-show="!loaded" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{ src: string }>();
|
||||
|
||||
const loaded = ref(false);
|
||||
|
||||
function imageLoaded() {
|
||||
loaded.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.image-comp {
|
||||
&__image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&__loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
159
src/components/UI/InputComp.vue
Normal file
159
src/components/UI/InputComp.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="input-comp" :class="classes">
|
||||
<label class="input-comp__label">{{ placeholder }}</label>
|
||||
<input
|
||||
class="input-comp__input"
|
||||
v-model="localValue"
|
||||
:type="localType"
|
||||
:readonly="readOnly"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<FontAwesomeIcon class="input-comp__revealer" icon="eye" @click="togglePasswordVisibility()" v-if="localType === 'password'" />
|
||||
<FontAwesomeIcon class="input-comp__revealer" icon="eye-slash" @click="togglePasswordVisibility()" v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String,
|
||||
placeholder: String,
|
||||
type: { type: String, default: "text" },
|
||||
readOnly: { type: Boolean, default: false },
|
||||
hasWarning: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const focused = ref(false);
|
||||
|
||||
const classes = computed(() => ({
|
||||
"input-comp--has-value": props.modelValue !== "",
|
||||
"input-comp--has-focus": focused.value && !props.readOnly,
|
||||
"input-comp--has-warning": props.hasWarning,
|
||||
"input-comp--readonly": props.readOnly,
|
||||
"input-comp--password": props.type === "password",
|
||||
}));
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (to) => {
|
||||
emit("update:modelValue", to);
|
||||
},
|
||||
});
|
||||
|
||||
const localType = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
localType.value = props.type;
|
||||
});
|
||||
|
||||
function togglePasswordVisibility() {
|
||||
if (props.type === "password") {
|
||||
localType.value = localType.value === "password" ? "text" : "password";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.input-comp {
|
||||
$p: &;
|
||||
|
||||
position: relative;
|
||||
height: r(64);
|
||||
min-width: r(200);
|
||||
|
||||
&__label {
|
||||
position: absolute;
|
||||
bottom: r(21);
|
||||
font-size: r(22);
|
||||
color: color("ui-background");
|
||||
z-index: 1;
|
||||
transition: 0.18s ease;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
position: absolute;
|
||||
bottom: r(15);
|
||||
width: 100%;
|
||||
height: r(32);
|
||||
font-size: r(22);
|
||||
background: color("main-background");
|
||||
color: color("main-color");
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&__revealer {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: r(18);
|
||||
height: r(16);
|
||||
width: r(20);
|
||||
padding: r(5);
|
||||
color: color("ui-background");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: r(10);
|
||||
width: 100%;
|
||||
height: r(1);
|
||||
background: color("ui-background");
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: r(10);
|
||||
width: 100%;
|
||||
height: r(2);
|
||||
background: color("main-color");
|
||||
transform: scaleX(0);
|
||||
z-index: 1;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
&--has-focus,
|
||||
&--has-value {
|
||||
#{$p}__label {
|
||||
bottom: r(46);
|
||||
font-size: r(18);
|
||||
}
|
||||
}
|
||||
|
||||
&--has-focus {
|
||||
&::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
&--password {
|
||||
#{$p}__revealer {
|
||||
display: block;
|
||||
}
|
||||
#{$p}__input {
|
||||
width: calc(100% - r(30));
|
||||
}
|
||||
}
|
||||
|
||||
&--readonly {
|
||||
&::before {
|
||||
background: color("ui-color");
|
||||
}
|
||||
}
|
||||
|
||||
&--has-warning {
|
||||
&::before {
|
||||
background: color("warning-color");
|
||||
height: r(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
98
src/components/UI/InputFileComp.vue
Normal file
98
src/components/UI/InputFileComp.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<label class="input-file-comp" :class="classes">
|
||||
<div class="input-file-comp__label">{{ label }}</div>
|
||||
<div class="input-file-comp__value" v-show="value !== ''">
|
||||
<div class="input-file-comp__value-text">{{ value }}</div>
|
||||
<FontAwesomeIcon class="input-file-comp__value-icon" icon="xmark" @click.prevent="clearValue()" />
|
||||
</div>
|
||||
<input class="input-file-comp__input" type="file" :accept="accept" @change="valueChanged" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{ label: string; accept: string }>();
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
const classes = computed(() => ({
|
||||
"input-file-comp--has-value": value.value !== "",
|
||||
}));
|
||||
|
||||
const value = ref("");
|
||||
|
||||
function valueChanged(event: Event) {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
if (file) {
|
||||
value.value = file.name;
|
||||
emit("change", file);
|
||||
}
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
value.value = "";
|
||||
emit("change", undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.input-file-comp {
|
||||
$p: &;
|
||||
|
||||
position: relative;
|
||||
height: r(59);
|
||||
min-width: r(200);
|
||||
|
||||
&__label {
|
||||
position: absolute;
|
||||
bottom: r(21);
|
||||
font-size: r(16);
|
||||
color: #999;
|
||||
z-index: 1;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__value {
|
||||
position: absolute;
|
||||
bottom: r(15);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: r(32);
|
||||
font-size: r(16);
|
||||
}
|
||||
|
||||
&__value-text {
|
||||
margin-right: r(10);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__value-icon {
|
||||
padding: r(5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: r(10);
|
||||
width: 100%;
|
||||
height: r(1);
|
||||
background: color("ui-background");
|
||||
}
|
||||
|
||||
&--has-value {
|
||||
#{$p}__label {
|
||||
bottom: r(46);
|
||||
font-size: r(13);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
109
src/components/UI/ModalComp.vue
Normal file
109
src/components/UI/ModalComp.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<Teleport to="#modals">
|
||||
<Transition name="modal-comp--transition">
|
||||
<div class="modal-comp" v-show="showed" @click.self="returnResult('')">
|
||||
<div class="modal-comp__container">
|
||||
<slot></slot>
|
||||
|
||||
<div class="modal-comp__buttons" v-if="modalType === 'yes_no'">
|
||||
<ButtonComp type="primary" value="Да" @click="returnResult('yes')" />
|
||||
<ButtonComp type="default" value="Нет" @click="returnResult('no')" />
|
||||
</div>
|
||||
|
||||
<div class="modal-comp__buttons" v-else-if="modalType === 'cancel_leave'">
|
||||
<ButtonComp type="default" value="Отмена" @click="returnResult('cancel')" />
|
||||
<ButtonComp type="primary" value="Выйти" @click="returnResult('leave')" />
|
||||
</div>
|
||||
|
||||
<FontAwesomeIcon class="modal-comp__close" icon="xmark" v-if="modalType === 'default'" @click="returnResult('')" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
type types = "default" | "yes_no" | "cancel_leave";
|
||||
|
||||
const showed = ref(false);
|
||||
const modalType = ref<types>("default");
|
||||
let promiseResolve: (value: unknown) => void = function () {
|
||||
console.log("unknown");
|
||||
};
|
||||
|
||||
function open(options: { type: types } = { type: "default" }) {
|
||||
showed.value = true;
|
||||
modalType.value = options.type;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
promiseResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function returnResult(result: string) {
|
||||
promiseResolve(result);
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
showed.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.modal-comp {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: r(20);
|
||||
background: hsla(0, 0, 0, 0.5);
|
||||
|
||||
z-index: 200;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
padding: r(20);
|
||||
background: color("main-background");
|
||||
border-radius: r(6);
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: r(20);
|
||||
margin-top: r(20);
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: r(10);
|
||||
right: r(10);
|
||||
width: r(30);
|
||||
height: r(30);
|
||||
padding: r(5);
|
||||
border-radius: 50%;
|
||||
background: color("main-background");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--transition {
|
||||
&-enter-active {
|
||||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
&-leave-active {
|
||||
transition: opacity 0.1s ease-in;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
220
src/components/UI/Slider/SliderComp.vue
Normal file
220
src/components/UI/Slider/SliderComp.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="slider-comp">
|
||||
<div class="slider-comp__tabs" ref="refTabs">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="slider-comp__controls">
|
||||
<div class="slider-comp__controls-container" :style="`--index: ${currentTabIndex}`">
|
||||
<div class="slider-comp__controls-circles">
|
||||
<div
|
||||
class="slider-comp__controls-circle"
|
||||
v-for="(i, index) in tabs.length"
|
||||
:key="index"
|
||||
:class="{ 'slider-comp__controls-circle--active': index === currentTabIndex }"
|
||||
></div>
|
||||
<div class="slider-comp__controls-pointer" v-show="tabs.length > 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
class="slider-comp__controls-button slider-comp__controls-button--left"
|
||||
icon="chevron-left"
|
||||
@click="changeTabLeft()"
|
||||
v-show="buttonShowed.left"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
class="slider-comp__controls-button slider-comp__controls-button--right"
|
||||
icon="chevron-right"
|
||||
@click="changeTabRight()"
|
||||
v-show="buttonShowed.right"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{ type: "default" | "infinite"; automatic: boolean }>();
|
||||
const emit = defineEmits(["index"]);
|
||||
|
||||
const refTabs = ref<Element | undefined>();
|
||||
const tabs = ref<Element[]>([]);
|
||||
const currentTabIndex = ref(0);
|
||||
const nextTabIndex = ref(0);
|
||||
|
||||
const buttonShowed = ref({
|
||||
left: computed(() => props.type !== "infinite" && tabs.value.length > 1 && currentTabIndex.value > 0),
|
||||
right: computed(() => props.type !== "infinite" && tabs.value.length > 1 && currentTabIndex.value < tabs.value.length - 1),
|
||||
});
|
||||
|
||||
function changeTabLeft() {
|
||||
if (currentTabIndex.value > 0) {
|
||||
changeTab(currentTabIndex.value - 1);
|
||||
} else if (props.type === "infinite") {
|
||||
changeTab(tabs.value.length - 1);
|
||||
}
|
||||
}
|
||||
function changeTabRight() {
|
||||
if (currentTabIndex.value < tabs.value.length - 1) {
|
||||
changeTab(currentTabIndex.value + 1);
|
||||
} else if (props.type === "infinite") {
|
||||
changeTab(0);
|
||||
}
|
||||
}
|
||||
function changeTab(index: number) {
|
||||
let left = (tabs.value[index] as HTMLElement).offsetLeft;
|
||||
|
||||
refTabs.value?.scroll({ left, behavior: "smooth" });
|
||||
|
||||
//tabs.value[index].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
||||
nextTabIndex.value = index;
|
||||
emit("index", index);
|
||||
}
|
||||
|
||||
function callback(entries: IntersectionObserverEntry[]) {
|
||||
const trueEntry = entries.find((x) => x.isIntersecting);
|
||||
|
||||
if (trueEntry) {
|
||||
const index = tabs.value.indexOf(trueEntry.target);
|
||||
|
||||
const tabChangedProgramatically = nextTabIndex.value !== currentTabIndex.value;
|
||||
|
||||
if (!tabChangedProgramatically) {
|
||||
changeTab(index);
|
||||
}
|
||||
|
||||
currentTabIndex.value = index;
|
||||
}
|
||||
}
|
||||
const observer = new IntersectionObserver(callback, { rootMargin: "0% -40%" });
|
||||
|
||||
onMounted(() => {
|
||||
createTabsList();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (props.automatic) automaticTabChange();
|
||||
});
|
||||
|
||||
function createTabsList() {
|
||||
if (!refTabs.value) return;
|
||||
|
||||
for (const tab of refTabs.value.children) {
|
||||
if (tab.classList.contains("slider-tab-comp")) {
|
||||
tabs.value.push(tab);
|
||||
observer.observe(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
let automaticTimeout = 0;
|
||||
|
||||
function automaticTabChange() {
|
||||
clearTimeout(automaticTimeout);
|
||||
|
||||
automaticTimeout = setTimeout(() => {
|
||||
changeTabRight();
|
||||
}, 5000);
|
||||
}
|
||||
watch(
|
||||
() => currentTabIndex.value,
|
||||
() => {
|
||||
if (props.automatic) {
|
||||
automaticTabChange();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.slider-comp {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow-x: scroll;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-snap-stop: always;
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: r(10) 0 r(20) 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__controls-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: r(15);
|
||||
}
|
||||
|
||||
&__controls-circles {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: r(15);
|
||||
}
|
||||
|
||||
&__controls-circle {
|
||||
width: r(15);
|
||||
height: r(15);
|
||||
border-radius: 50%;
|
||||
border: r(2) solid color("ui-background");
|
||||
transition: box-shadow 0.28s ease;
|
||||
|
||||
&--active {
|
||||
box-shadow: 0 0 r(5) color("ui-background");
|
||||
}
|
||||
}
|
||||
|
||||
&__controls-button {
|
||||
position: absolute;
|
||||
top: calc(50% - r(45));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 10%;
|
||||
height: r(40);
|
||||
color: color("ui-background");
|
||||
cursor: pointer;
|
||||
|
||||
&--left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls-pointer {
|
||||
position: absolute;
|
||||
top: r(3);
|
||||
left: calc(r(3) + (var(--index)) * r(30));
|
||||
|
||||
width: r(9);
|
||||
height: r(9);
|
||||
border-radius: 50%;
|
||||
|
||||
background: color("ui-background");
|
||||
|
||||
transition: left 0.28s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
16
src/components/UI/Slider/SliderTabComp.vue
Normal file
16
src/components/UI/Slider/SliderTabComp.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="slider-tab-comp">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.slider-tab-comp {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
</style>
|
9
src/components/UI/index.ts
Normal file
9
src/components/UI/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import InputComp from "@/components/UI/InputComp.vue";
|
||||
import InputFileComp from "@/components/UI/InputFileComp.vue";
|
||||
import ButtonComp from "@/components/UI/ButtonComp.vue";
|
||||
import ImageComp from "@/components/UI/ImageComp.vue";
|
||||
import ModalComp from "@/components/UI/ModalComp.vue";
|
||||
import SliderComp from "@/components/UI/Slider/SliderComp.vue";
|
||||
import SliderTabComp from "@/components/UI/Slider/SliderTabComp.vue";
|
||||
|
||||
export default { InputComp, InputFileComp, ButtonComp, ImageComp, ModalComp, SliderComp, SliderTabComp };
|
3
src/composables/api/index.ts
Normal file
3
src/composables/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { useMenuApi } from "./menu";
|
||||
export { useUserApi } from "./user";
|
||||
export { useOrdersApi } from "./orders";
|
40
src/composables/api/menu.ts
Normal file
40
src/composables/api/menu.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { category, menuItem } from "@/types";
|
||||
|
||||
export function useMenuApi() {
|
||||
function getCategories() {
|
||||
const resp = generateCategories();
|
||||
return resp;
|
||||
}
|
||||
|
||||
function getMenuItems() {
|
||||
const resp = generateMenuItems();
|
||||
return resp;
|
||||
}
|
||||
|
||||
return { getCategories, getMenuItems };
|
||||
}
|
||||
|
||||
function generateCategories() {
|
||||
const list: category[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
list.push({
|
||||
id: i,
|
||||
title: `категория ${i + 1}`,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function generateMenuItems() {
|
||||
const list: menuItem[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
list.push({
|
||||
id: i,
|
||||
title: `товар ${i + 1}`,
|
||||
description: `описание ${i + 1}`,
|
||||
categoryId: i % 5,
|
||||
price: Math.floor(Math.random() * 200 + 100),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
31
src/composables/api/orders.ts
Normal file
31
src/composables/api/orders.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { order } from "@/types";
|
||||
import { useHookahStore } from "@/store";
|
||||
|
||||
export function useOrdersApi() {
|
||||
const hookahStore = useHookahStore();
|
||||
|
||||
async function createOrder(order: { table: number; heavinessId: number; tasteIds: number[] }) {
|
||||
const heaviness = hookahStore.heavinessList.find((x) => x.id === order.heavinessId);
|
||||
const tastes = hookahStore.tastesList.filter((x) => order.tasteIds.includes(x.id));
|
||||
|
||||
const resp: order = {
|
||||
id: 0,
|
||||
status: "new",
|
||||
table: order.table,
|
||||
heaviness: heaviness ?? hookahStore.heavinessList[0],
|
||||
tastes: tastes,
|
||||
price: 123,
|
||||
favourite: false,
|
||||
};
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
async function toggleFavourite(order: order) {
|
||||
const resp = true;
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
return { createOrder, toggleFavourite };
|
||||
}
|
34
src/composables/api/user.ts
Normal file
34
src/composables/api/user.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { convertFileToBase64String } from "../utils";
|
||||
|
||||
export function useUserApi() {
|
||||
async function login(data: { email: string; password: string }) {
|
||||
const resp = { name: "Иван", surname: "Иванов", email: data.email, image: "" };
|
||||
return resp;
|
||||
}
|
||||
|
||||
async function register(data: { name: string; surname: string; email: string; password: string; imageFile?: File }) {
|
||||
const resp = {
|
||||
name: data.name,
|
||||
surname: data.surname,
|
||||
email: data.email,
|
||||
image: data.imageFile != null ? await convertFileToBase64String(data.imageFile) : "",
|
||||
};
|
||||
return resp;
|
||||
}
|
||||
|
||||
async function changeUserData(data: { name: string; surname: string; imageFile?: File }) {
|
||||
const resp = {
|
||||
name: data.name,
|
||||
surname: data.surname,
|
||||
image: data.imageFile != null ? await convertFileToBase64String(data.imageFile) : "",
|
||||
};
|
||||
return resp;
|
||||
}
|
||||
|
||||
async function changeUserPassword(data: { oldPassword: string; newPassword: string; newPassword2: string }) {
|
||||
const resp = true;
|
||||
return resp;
|
||||
}
|
||||
|
||||
return { login, register, changeUserData, changeUserPassword };
|
||||
}
|
14
src/composables/utils/index.ts
Normal file
14
src/composables/utils/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export async function convertFileToBase64String(file: File): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function () {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
resolve("");
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
16
src/directives/ClickOutside.js
Normal file
16
src/directives/ClickOutside.js
Normal file
@ -0,0 +1,16 @@
|
||||
export default {
|
||||
beforeMount(el, binding, vnode) {
|
||||
var vm = vnode.context;
|
||||
var callback = binding.value;
|
||||
|
||||
el.clickOutsideEvent = function (event) {
|
||||
if (!(el == event.target || el.contains(event.target))) {
|
||||
return callback.call(vm, event);
|
||||
}
|
||||
};
|
||||
document.body.addEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
unmounted(el) {
|
||||
document.body.removeEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
};
|
52
src/font-awesome.ts
Normal file
52
src/font-awesome.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faHouse,
|
||||
faBars,
|
||||
faFileLines,
|
||||
faUser,
|
||||
faBottleDroplet,
|
||||
faXmark,
|
||||
faUserPen,
|
||||
faCheck,
|
||||
faPen,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronDown,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faCamera,
|
||||
faUserLock,
|
||||
faRubleSign,
|
||||
faCoins,
|
||||
faStar,
|
||||
faSun,
|
||||
faMoon,
|
||||
faSpinner,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
...[
|
||||
faHouse,
|
||||
faBars,
|
||||
faFileLines,
|
||||
faUser,
|
||||
faBottleDroplet,
|
||||
faXmark,
|
||||
faUserPen,
|
||||
faCheck,
|
||||
faPen,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronDown,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faCamera,
|
||||
faUserLock,
|
||||
faRubleSign,
|
||||
faCoins,
|
||||
faStar,
|
||||
faSun,
|
||||
faMoon,
|
||||
faSpinner,
|
||||
]
|
||||
);
|
20
src/main.ts
Normal file
20
src/main.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { createPinia } from "pinia";
|
||||
import { FontAwesomeIcon, FontAwesomeLayers } from "@fortawesome/vue-fontawesome";
|
||||
require("@/font-awesome");
|
||||
import uiCompList from "@/components/UI/index";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.component("FontAwesomeIcon", FontAwesomeIcon);
|
||||
app.component("FontAwesomeLayers", FontAwesomeLayers);
|
||||
|
||||
for (const [name, comp] of Object.entries(uiCompList)) {
|
||||
app.component(name, comp);
|
||||
}
|
||||
|
||||
app.mount("#app");
|
68
src/modules/validator/fieldChecks.ts
Normal file
68
src/modules/validator/fieldChecks.ts
Normal file
@ -0,0 +1,68 @@
|
||||
export class FieldCheck {
|
||||
error = "";
|
||||
|
||||
constructor(error: string) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
check(val: string) {
|
||||
throw new Error("check() not implemented");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
class FieldCheckRegex extends FieldCheck {
|
||||
regex = /none/;
|
||||
|
||||
constructor(regex: RegExp, error: string) {
|
||||
super(error);
|
||||
this.regex = regex;
|
||||
}
|
||||
|
||||
check(val: string) {
|
||||
if (!this.regex.test(val)) return this.error;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
class FieldCheckMaxLength extends FieldCheck {
|
||||
maxLength = 0;
|
||||
|
||||
constructor(maxLength: number) {
|
||||
super(`Максимальная длина равна ${maxLength}.`);
|
||||
this.maxLength = maxLength;
|
||||
}
|
||||
|
||||
check(val: string) {
|
||||
if (val.length > this.maxLength) return this.error;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
class FieldCheckMinLength extends FieldCheck {
|
||||
minLength = 0;
|
||||
|
||||
constructor(minLength: number) {
|
||||
super(`Минимальная длина равна ${minLength}.`);
|
||||
this.minLength = minLength;
|
||||
}
|
||||
|
||||
check(val: string) {
|
||||
if (val.length < this.minLength) return this.error;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export class FieldChecks {
|
||||
static nonEmpty = new FieldCheckRegex(/\S+/, "Поле не может быть пустым.");
|
||||
static onlyLatinLettersWhitespaces = new FieldCheckRegex(/^[A-Za-z\s]+$/, "Разрешены только Латинские буквы и пробелы.");
|
||||
static onlyLettersWhitespaces = new FieldCheckRegex(/^[A-Za-zА-Яа-я\s]+$/, "Разрешены только буквы и пробелы.");
|
||||
static onlyLatinLettersNumbersUnderscores = new FieldCheckRegex(
|
||||
/^[A-Za-z0-9_\s]+$/,
|
||||
"Разрешены только Латинские буквы, цифры и нижнее подчёркивание."
|
||||
);
|
||||
static email = new FieldCheckRegex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Проверьте почтовый адрес.");
|
||||
static noSpecialChars = new FieldCheckRegex(/^[A-Za-z0-9\s.,()!%]+$/, "Специальные символы не разрешены.");
|
||||
static maxLength = (maxLength: number) => new FieldCheckMaxLength(maxLength);
|
||||
static minLength = (minLength: number) => new FieldCheckMinLength(minLength);
|
||||
}
|
45
src/modules/validator/formFields.ts
Normal file
45
src/modules/validator/formFields.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { FieldCheck } from "./fieldChecks";
|
||||
|
||||
export class FieldsContainer {
|
||||
fields = new Map<string, Field>();
|
||||
|
||||
/**
|
||||
* Add field to container.
|
||||
* @param {string} name - The field name.
|
||||
* @param {string} value - The field value.
|
||||
* @param {Array} checks - Array of necessary FieldCheck objects.
|
||||
*/
|
||||
addField(name: string, value: string, checks: FieldCheck[]) {
|
||||
this.fields.set(name, new Field(value, checks));
|
||||
}
|
||||
}
|
||||
|
||||
export class Field {
|
||||
value: string;
|
||||
checks: FieldCheck[];
|
||||
|
||||
constructor(value: string, checks: FieldCheck[]) {
|
||||
this.value = value;
|
||||
this.checks = checks;
|
||||
}
|
||||
|
||||
tryGetFirstError() {
|
||||
let result = "";
|
||||
|
||||
this.checks.every((fieldCheck) => {
|
||||
result = fieldCheck.check(this.value);
|
||||
return result === ""; //break or continue every()
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
tryGetAllErrors() {
|
||||
const result = this.checks.flatMap((fieldCheck) => {
|
||||
const err = fieldCheck.check(this.value);
|
||||
return err === "" ? [] : [err];
|
||||
});
|
||||
|
||||
return result.join(", ");
|
||||
}
|
||||
}
|
4
src/modules/validator/index.d.ts
vendored
Normal file
4
src/modules/validator/index.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import { FieldChecks } from "./fieldChecks";
|
||||
import { Field, FieldsContainer } from "./formFields";
|
||||
|
||||
export { FieldChecks, Field, FieldsContainer };
|
2
src/modules/validator/index.ts
Normal file
2
src/modules/validator/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FieldChecks } from "./fieldChecks";
|
||||
export { Field, FieldsContainer } from "./formFields";
|
25
src/router/imports.ts
Normal file
25
src/router/imports.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import HomeViewMain from "@/components/HomeView/HomeViewMain.vue";
|
||||
import HomeMain from "@/components/HomeView/Tabs/Home/HomeMain.vue";
|
||||
import MenuMain from "@/components/HomeView/Tabs/Menu/MenuMain.vue";
|
||||
import HookahMain from "@/components/HomeView/Tabs/Hookah/HookahMain.vue";
|
||||
import TableTab from "@/components/HomeView/Tabs/Hookah/TableTab.vue";
|
||||
import HeavinessTab from "@/components/HomeView/Tabs/Hookah/HeavinessTab.vue";
|
||||
import TasteTab from "@/components/HomeView/Tabs/Hookah/TasteTab.vue";
|
||||
import SummaryTab from "@/components/HomeView/Tabs/Hookah/SummaryTab.vue";
|
||||
import OrdersMain from "@/components/HomeView/Tabs/Orders/OrdersMain.vue";
|
||||
import UserMain from "@/components/HomeView/Tabs/User/UserMain.vue";
|
||||
|
||||
export default {
|
||||
HomeViewMain,
|
||||
HomeMain,
|
||||
MenuMain,
|
||||
HookahMain,
|
||||
hookah: {
|
||||
TableTab,
|
||||
HeavinessTab,
|
||||
TasteTab,
|
||||
SummaryTab,
|
||||
},
|
||||
OrdersMain,
|
||||
UserMain,
|
||||
};
|
72
src/router/index.ts
Normal file
72
src/router/index.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useUserStore } from "@/store";
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
import Components from "./imports";
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/",
|
||||
name: "HomeView",
|
||||
component: Components.HomeViewMain,
|
||||
children: [
|
||||
{
|
||||
path: "home",
|
||||
component: Components.HomeMain,
|
||||
},
|
||||
{
|
||||
path: "menu",
|
||||
component: Components.MenuMain,
|
||||
},
|
||||
{
|
||||
path: "hookah",
|
||||
component: Components.HookahMain,
|
||||
children: [
|
||||
{ path: "table", component: Components.hookah.TableTab },
|
||||
{ path: "heaviness", component: Components.hookah.HeavinessTab },
|
||||
{ path: "taste", component: Components.hookah.TasteTab },
|
||||
{ path: "summary", component: Components.hookah.SummaryTab },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "orders",
|
||||
component: Components.OrdersMain,
|
||||
},
|
||||
{
|
||||
path: "user",
|
||||
component: Components.UserMain,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: () => import("@/components/Authorization/AuthorizationMain.vue"),
|
||||
props: { type: "login" },
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
component: () => import("@/components/Authorization/AuthorizationMain.vue"),
|
||||
props: { type: "register" },
|
||||
},
|
||||
{
|
||||
path: "/intro",
|
||||
component: () => import("@/components/Intro/IntroMain.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.path === "/") {
|
||||
return "/home";
|
||||
}
|
||||
if (["/login", "/register"].includes(to.path) && useUserStore().isLoggedIn) {
|
||||
return "/home";
|
||||
}
|
||||
if (to.path !== "/intro" && !useUserStore().introWatched) {
|
||||
return "/intro";
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
6
src/shims-vue.d.ts
vendored
Normal file
6
src/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint-disable */
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
41
src/store/hookah.ts
Normal file
41
src/store/hookah.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { heaviness, taste } from "@/types";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useHookahStore = defineStore("hookah", {
|
||||
state: (): {
|
||||
selectedTableNumber: number;
|
||||
selectedHeaviness?: heaviness;
|
||||
heavinessList: heaviness[];
|
||||
selectedTastes: taste[];
|
||||
tastesList: taste[];
|
||||
} => ({
|
||||
selectedTableNumber: -1,
|
||||
selectedHeaviness: undefined,
|
||||
selectedTastes: [],
|
||||
heavinessList: [
|
||||
{ id: 0, title: "Лёгкий" },
|
||||
{ id: 1, title: "Средний" },
|
||||
{ id: 2, title: "Тяжёлый" },
|
||||
],
|
||||
tastesList: generateTastes(),
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
clearSelected() {
|
||||
this.selectedTableNumber = -1;
|
||||
this.selectedHeaviness = undefined;
|
||||
this.selectedTastes = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function generateTastes() {
|
||||
const list = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
list.push({
|
||||
id: i,
|
||||
title: `вкус ${i + 1}`,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
7
src/store/index.ts
Normal file
7
src/store/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useUserStore } from "./user";
|
||||
import { useMenuStore } from "./menu";
|
||||
import { useHookahStore } from "./hookah";
|
||||
import { useOrdersStore } from "./orders";
|
||||
import { useUIStore } from "./ui";
|
||||
|
||||
export { useUserStore, useMenuStore, useHookahStore, useOrdersStore, useUIStore };
|
14
src/store/menu.ts
Normal file
14
src/store/menu.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { category, menuItem } from "@/types";
|
||||
|
||||
export const useMenuStore = defineStore("menu", {
|
||||
state: (): {
|
||||
categories: category[];
|
||||
menuItems: menuItem[];
|
||||
} => ({
|
||||
categories: [],
|
||||
menuItems: [],
|
||||
}),
|
||||
getters: {},
|
||||
actions: {},
|
||||
});
|
18
src/store/orders.ts
Normal file
18
src/store/orders.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { order } from "@/types";
|
||||
|
||||
export const useOrdersStore = defineStore("orders", {
|
||||
state: (): {
|
||||
orders: order[];
|
||||
} => ({
|
||||
orders: [],
|
||||
}),
|
||||
getters: {
|
||||
favouriteOrders: (state) => state.orders.filter((x) => x.favourite),
|
||||
},
|
||||
actions: {
|
||||
createOrder(order: order) {
|
||||
this.orders.push(order);
|
||||
},
|
||||
},
|
||||
});
|
17
src/store/ui.ts
Normal file
17
src/store/ui.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useUIStore = defineStore("ui", {
|
||||
state: (): {
|
||||
theme: string;
|
||||
} => ({
|
||||
theme: "light",
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
setTheme(theme: string) {
|
||||
this.theme = theme;
|
||||
document.documentElement.setAttribute("theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
},
|
||||
},
|
||||
});
|
50
src/store/user.ts
Normal file
50
src/store/user.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { convertFileToBase64String } from "@/composables/utils";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state: () => ({
|
||||
userData: {
|
||||
email: "",
|
||||
name: "",
|
||||
surname: "",
|
||||
image: "",
|
||||
},
|
||||
laCoins: 123,
|
||||
introWatched: false,
|
||||
}),
|
||||
getters: {
|
||||
isLoggedIn: (state) => state.userData.email !== "",
|
||||
},
|
||||
actions: {
|
||||
setData(data: { name: string; surname: string; email: string; image: string }) {
|
||||
this.userData = data;
|
||||
this.saveData();
|
||||
},
|
||||
|
||||
saveData() {
|
||||
localStorage.setItem("user", JSON.stringify(this.userData));
|
||||
localStorage.setItem("introWatched", JSON.stringify(this.introWatched));
|
||||
},
|
||||
|
||||
loadData() {
|
||||
const userData = localStorage.getItem("user");
|
||||
if (userData) {
|
||||
this.userData = JSON.parse(userData);
|
||||
}
|
||||
|
||||
const introWatched = localStorage.getItem("introWatched");
|
||||
if (introWatched) {
|
||||
this.introWatched = JSON.parse(introWatched);
|
||||
}
|
||||
},
|
||||
|
||||
resetData() {
|
||||
this.setData({ email: "", name: "", surname: "", image: "" });
|
||||
},
|
||||
|
||||
setFieldValue(field: "name" | "surname" | "image", value: string) {
|
||||
this.userData[field] = value;
|
||||
this.saveData();
|
||||
},
|
||||
},
|
||||
});
|
28
src/styles/themes.scss
Normal file
28
src/styles/themes.scss
Normal file
@ -0,0 +1,28 @@
|
||||
@import "@/styles/variables.scss";
|
||||
|
||||
:root {
|
||||
@include define-color("ui-background", hsl(0, 0%, 60%));
|
||||
@include define-color("ui-color", hsl(0, 0%, 30%));
|
||||
@include define-color("ui-color-on-background", hsl(0, 0%, 100%));
|
||||
@include define-color("main-color", hsl(0, 0%, 5%));
|
||||
@include define-color("main-background", hsl(0, 0%, 100%));
|
||||
@include define-color("second-background", hsl(0, 0%, 93%));
|
||||
@include define-color("theme-color", hsl(213, 100%, 50%));
|
||||
@include define-color("success-color", hsl(115, 100%, 40%));
|
||||
@include define-color("warning-color", hsl(0, 100%, 40%));
|
||||
}
|
||||
|
||||
[theme="light"] {
|
||||
}
|
||||
|
||||
[theme="dark"] {
|
||||
@include define-color("ui-background", hsl(0, 0%, 55%));
|
||||
@include define-color("ui-color", hsl(0, 0%, 30%));
|
||||
@include define-color("ui-color-on-background", hsl(0, 0%, 95%));
|
||||
@include define-color("main-color", hsl(0, 0%, 93%));
|
||||
@include define-color("main-background", hsl(213, 0%, 17%));
|
||||
@include define-color("second-background", hsl(213, 0%, 10%));
|
||||
@include define-color("theme-color", hsl(213, 100%, 45%));
|
||||
@include define-color("success-color", hsl(115, 100%, 30%));
|
||||
@include define-color("warning-color", hsl(0, 100%, 30%));
|
||||
}
|
51
src/styles/variables.scss
Normal file
51
src/styles/variables.scss
Normal file
@ -0,0 +1,51 @@
|
||||
$mobileWidth: 500px;
|
||||
|
||||
@mixin main-container {
|
||||
border-radius: r(6);
|
||||
background: color("main-background");
|
||||
box-shadow: 0 r(2) r(2) 0 rgba(0, 0, 0, 0.14), 0 r(3) r(1) r(-2) rgba(0, 0, 0, 0.2), 0 r(1) r(5) 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
src: url("@/assets/fonts/Roboto-Regular.woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
src: url("@/assets/fonts/Roboto-Bold.woff2");
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
src: url("@/assets/fonts/Roboto-Light.woff2");
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto";
|
||||
src: url("@/assets/fonts/Roboto-Italic.woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@mixin define-color($title, $color) {
|
||||
--#{$title}-h: #{hue($color)};
|
||||
--#{$title}-s: #{saturation($color)};
|
||||
--#{$title}-l: #{lightness($color)};
|
||||
--#{$title}-a: #{alpha($color)};
|
||||
}
|
||||
|
||||
@function color($title, $hue: 0deg, $lightness: 0%, $saturation: 0%, $alpha: 0) {
|
||||
@return hsla(
|
||||
calc(var(--#{$title}-h) + #{$hue}),
|
||||
calc(var(--#{$title}-s) + #{$saturation}),
|
||||
calc(var(--#{$title}-l) + #{$lightness}),
|
||||
calc(var(--#{$title}-a) + #{$alpha})
|
||||
);
|
||||
}
|
||||
|
||||
@function r($value) {
|
||||
@return ($value / 10) + rem;
|
||||
}
|
38
src/types/index.ts
Normal file
38
src/types/index.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export interface category {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface menuItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
categoryId: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface heaviness {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface taste {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface notification {
|
||||
field?: string;
|
||||
text: string;
|
||||
type: "warning" | "success";
|
||||
}
|
||||
|
||||
export interface order {
|
||||
id: number;
|
||||
status: "new" | "active" | "completed";
|
||||
table: number;
|
||||
heaviness: heaviness;
|
||||
tastes: taste[];
|
||||
price: number;
|
||||
favourite: boolean;
|
||||
}
|
Reference in New Issue
Block a user