This commit is contained in:
Alexandr SaVBaD Waltz 2026-02-27 21:59:51 +03:00
parent 0429b62ba1
commit 1e1d0ef397
402 changed files with 25377 additions and 22031 deletions

24
storefront/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View file

20
storefront/app.config.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
declare module 'nuxt/schema' {
interface AppConfig {
i18n: {
supportedLocales: Array<{
code: string;
file: string;
default: boolean;
}>;
defaultLocale: string;
};
ui: {
showBreadcrumbs: boolean;
showSearchBar: boolean;
isHeaderFixed: boolean;
isAuthModals: boolean;
toastPosition: string;
};
}
}
export {};

View file

@ -1,154 +0,0 @@
<template>
<div class="main">
<nuxt-loading-indicator color="#7965d1" />
<base-header />
<ui-breadcrumbs v-if="showBreadcrumbs" />
<transition name="opacity" mode="out-in">
<base-auth v-if="activeState">
<forms-login v-if="appStore.isLogin" />
<forms-register v-if="appStore.isRegister" />
<forms-reset-password v-if="appStore.isForgot" />
<forms-new-password v-if="appStore.isReset" />
</base-auth>
</transition>
<nuxt-page />
<base-footer />
</div>
</template>
<script lang="ts" setup>
import {useAppConfig} from "~/composables/config";
import { DEFAULT_LOCALE } from '~/config/constants';
import {useRefresh} from "~/composables/auth";
import {useLanguages, useLocaleRedirect} from "~/composables/languages";
import {useCompanyInfo} from "~/composables/company";
import {useCategories} from "~/composables/categories";
const { locale } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const switchLocalePath = useSwitchLocalePath();
const showBreadcrumbs = computed(() => {
const name = typeof route.name === 'string' ? route.name : '';
return ![
'index',
'brand',
'search',
'profile',
'activate-user',
'reset-password'
].some(prefix => name.startsWith(prefix));
});
const activeState = computed(() => appStore.activeState);
const { COOKIES_LOCALE_KEY } = useAppConfig();
const cookieLocale = useCookie(
COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const { refresh } = useRefresh();
const { getCategories } = await useCategories();
const { isSupportedLocale } = useLocaleRedirect();
let refreshInterval: NodeJS.Timeout;
if (import.meta.server) {
await Promise.all([
refresh(),
useLanguages(),
useCompanyInfo(),
getCategories()
]);
}
watch(
() => appStore.activeState,
(state) => {
appStore.setOverflowHidden(state !== '');
},
{ immediate: true }
);
watch(locale, () => {
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
let stopWatcher: VoidFunction = () => {};
if (!cookieLocale.value) {
cookieLocale.value = DEFAULT_LOCALE;
await router.push({path: switchLocalePath(cookieLocale.value)});
}
if (locale.value !== cookieLocale.value) {
if (isSupportedLocale(cookieLocale.value)) {
await router.push({
path: switchLocalePath(cookieLocale.value),
query: route.query
});
} else {
cookieLocale.value = DEFAULT_LOCALE
await router.push({
path: switchLocalePath(DEFAULT_LOCALE),
query: route.query
});
}
}
onMounted( async () => {
refreshInterval = setInterval(async () => {
await refresh();
}, 600000);
stopWatcher = watch(
() => appStore.isOverflowHidden,
(hidden) => {
const root = document.documentElement;
const body = document.body;
if (hidden) {
root.classList.add('lock-scroll');
body.classList.add('lock-scroll');
} else {
root.classList.remove('lock-scroll');
body.classList.remove('lock-scroll');
}
},
{ immediate: true }
);
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
onBeforeUnmount(() => {
stopWatcher()
document.documentElement.classList.remove('lock-scroll');
document.body.classList.remove('lock-scroll');
});
</script>
<style lang="scss">
.main {
padding-top: 90px;
background-color: $light;
}
.lock-scroll {
overflow: hidden !important;
}
</style>

View file

@ -0,0 +1,15 @@
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@appConstants';
export default defineAppConfig({
i18n: {
supportedLocales: SUPPORTED_LOCALES,
defaultLocale: DEFAULT_LOCALE,
},
ui: {
showBreadcrumbs: true,
showSearchBar: true,
isHeaderFixed: true,
isAuthModals: false,
toastPosition: 'top-right',
},
});

206
storefront/app/app.vue Normal file
View file

@ -0,0 +1,206 @@
<template>
<div
class="main"
:style="{ 'padding-top': uiConfig.isHeaderFixed ? '83px': '0' }"
>
<nuxt-loading-indicator :color="accentColor" />
<base-header />
<ui-breadcrumbs v-if="uiConfig.showBreadcrumbs && showBreadcrumbs" />
<transition
name="opacity"
mode="out-in"
v-if="uiConfig.isAuthModals"
>
<base-auth v-if="activeState">
<forms-login v-if="appStore.isLogin" />
<forms-register v-if="appStore.isRegister" />
<forms-reset-password v-if="appStore.isForgot" />
<forms-new-password v-if="appStore.isReset" />
</base-auth>
</transition>
<nuxt-page />
<ui-button
:type="'button'"
class="demo__button"
v-if="isDemoMode"
@click="appStore.setDemoSettings(!appStore.isDemoSettings)"
>
<icon
name="material-symbols:settings"
size="30"
/>
</ui-button>
<transition name="opacity" mode="out-in">
<demo-settings />
</transition>
<base-footer />
</div>
</template>
<script setup lang="ts">
import {DEFAULT_LOCALE} from '@appConstants';
import {useRefresh} from '@composables/auth';
import {useLanguages, useLocaleRedirect} from '@composables/languages';
import {useCompanyInfo} from '@composables/company';
import {useCategories} from '@composables/categories';
import {useProjectConfig} from '@composables/config';
const { locale } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const { isDemoMode, uiConfig } = useProjectConfig();
const toaster = { position: uiConfig.value.toastPosition };
const switchLocalePath = useSwitchLocalePath();
const { $appHelpers } = useNuxtApp();
const showBreadcrumbs = computed(() => {
const name = typeof route.name === 'string' ? route.name : '';
return ![
'index',
'search',
'profile',
'activate-user',
'reset-password',
'auth-sign-in',
'auth-sign-up',
'auth-reset-password',
'contact',
'blog',
'docs'
].some(prefix => name.startsWith(prefix));
});
const activeState = computed(() => appStore.activeAuthState);
const cookieLocale = useCookie(
$appHelpers.COOKIES_LOCALE_KEY,
{
default: () => DEFAULT_LOCALE,
path: '/'
}
);
const { refresh } = useRefresh();
const { getCategories } = await useCategories();
const { isSupportedLocale } = useLocaleRedirect();
let refreshInterval: NodeJS.Timeout;
if (import.meta.server) {
await Promise.all([
refresh(),
useLanguages(),
useCompanyInfo(),
getCategories()
]);
}
watch(
() => [appStore.activeAuthState, appStore.isDemoSettings],
([authState, isDemo]) => {
appStore.setOverflowHidden(authState !== '' || isDemo);
},
{ immediate: true }
);
watch(locale, () => {
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
let stopWatcher: VoidFunction = () => {};
if (!cookieLocale.value) {
cookieLocale.value = DEFAULT_LOCALE;
await router.push({path: switchLocalePath(cookieLocale.value)});
}
if (locale.value !== cookieLocale.value) {
if (isSupportedLocale(cookieLocale.value)) {
await router.push({
path: switchLocalePath(cookieLocale.value),
query: route.query
});
} else {
cookieLocale.value = DEFAULT_LOCALE
await router.push({
path: switchLocalePath(DEFAULT_LOCALE),
query: route.query
});
}
}
const accentColor = ref('');
const getCssVariable = (name: string): string => {
if (import.meta.client) {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
}
return '';
};
onMounted( async () => {
refreshInterval = setInterval(async () => {
await refresh();
}, 600000);
stopWatcher = watch(
() => appStore.isOverflowHidden,
(hidden) => {
const root = document.documentElement;
const body = document.body;
if (hidden) {
root.classList.add('lock-scroll');
body.classList.add('lock-scroll');
} else {
root.classList.remove('lock-scroll');
body.classList.remove('lock-scroll');
}
},
{ immediate: true }
);
accentColor.value = getCssVariable('--accent');
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
onBeforeUnmount(() => {
stopWatcher()
document.documentElement.classList.remove('lock-scroll');
document.body.classList.remove('lock-scroll');
});
</script>
<style lang="scss">
.main {
background-color: $light;
position: relative;
}
.lock-scroll {
overflow: hidden !important;
}
.demo {
&__button {
position: fixed !important;
width: fit-content !important;
bottom: 20px;
right: 20px;
padding: 10px !important;
}
}
</style>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -0,0 +1,45 @@
$font_default: 'Inter', sans-serif;
$default_border_radius: 4px;
$white: var(--color-neutral-50, #fff);
$light: var(--color-neutral-100, #f8f7fc);
$black: var(--color-neutral-900, #000);
$accent: var(--color-accent, #111827);
$accentDark: var(--color-accent-dark, #242c38);
$accentLight: var(--color-accent-light, #465267);
$accentDisabled: var(--color-accent-disabled, #826fa2);
$accentSmooth: var(--color-accent-smooth, #656bd1);
$accentNeon: var(--color-accent-neon, #F200FF);
$contrast: var(--color-contrast, #FFC107);
$error: var(--color-error, #f13838);
$background: var(--color-background);
$background-secondary: var(--color-background-secondary);
$background-elevated: var(--color-background-elevated);
$text-primary: var(--color-text-primary);
$text-secondary: var(--color-text-secondary);
$text-tertiary: var(--color-text-tertiary);
$text-accent: var(--color-text-accent);
$text-inverse: var(--color-text-inverse);
$border: var(--color-border);
$border-hover: var(--color-border-hover);
$divider: var(--color-divider);
$surface: var(--color-surface);
$surface-hover: var(--color-surface-hover);
$surface-active: var(--color-surface-active);
$success: var(--color-success, #10b981);
$warning: var(--color-warning, #f59e0b);
$info: var(--color-info, #3b82f6);
$success-bg: var(--color-success-bg, #d1fae5);
$warning-bg: var(--color-warning-bg, #fef3c7);
$info-bg: var(--color-info-bg, #dbeafe);
$error-bg: var(--color-error-bg, #fee2e2);
$accent-hover: var(--color-accent-hover);
$accent-active: var(--color-accent-active);

View file

@ -0,0 +1,117 @@
@use "modules/normalize";
@use "modules/transitions";
@use "global/mixins";
@use "global/variables";
// UI
@use "ui/collapse";
@use "ui/notification";
@use "ui/rating";
@use "ui/select";
:root {
--color-accent: #111827;
--color-accent-dark: #242c38;
--color-accent-light: #465267;
--color-accent-smooth: #656bd1;
--color-accent-neon: #f200ff;
--color-contrast: #ffc107;
--color-error: #f13838;
--color-neutral-50: #ffffff;
--color-neutral-100: #f8f7fc;
--color-neutral-200: #f0eefa;
--color-neutral-300: #e2dfed;
--color-neutral-400: #c5c1d9;
--color-neutral-500: #8c8a9e;
--color-neutral-600: #5a5869;
--color-neutral-700: #3d3b47;
--color-neutral-800: #23222a;
--color-neutral-900: #000000;
--color-background: var(--color-neutral-50);
--color-background-secondary: var(--color-neutral-100);
--color-background-elevated: var(--color-neutral-50);
--color-text-primary: var(--color-neutral-900);
--color-text-secondary: var(--color-neutral-600);
--color-text-tertiary: var(--color-neutral-500);
--color-text-accent: var(--color-accent);
--color-text-inverse: var(--color-neutral-50);
--color-border: var(--color-neutral-300);
--color-border-hover: var(--color-accent-light);
--color-divider: var(--color-neutral-200);
--color-surface: var(--color-neutral-50);
--color-surface-hover: var(--color-neutral-100);
--color-surface-active: var(--color-neutral-200);
--color-success: #10b981;
--color-warning: #f59e0b;
--color-info: #3b82f6;
--color-success-bg: #d1fae5;
--color-warning-bg: #fef3c7;
--color-info-bg: #dbeafe;
--color-error-bg: #fee2e2;
--color-accent-hover: var(--color-accent-dark);
--color-accent-active: var(--color-accent-smooth);
--color-accent-disabled: #c7bfe9;
}
.dark {
--color-accent: #8d7bdb;
--color-accent-dark: #6b58c7;
--color-accent-light: #b3a9e5;
--color-accent-smooth: #7a80e1;
--color-accent-neon: #ff1aff;
--color-contrast: #ffd54f;
--color-error: #ff6b6b;
--color-neutral-50: #000000;
--color-neutral-100: #0f0e14;
--color-neutral-200: #1d1b26;
--color-neutral-300: #2d2b3a;
--color-neutral-400: #4a4858;
--color-neutral-500: #6d6b7a;
--color-neutral-600: #a19fae;
--color-neutral-700: #c9c7d6;
--color-neutral-800: #e2e0ef;
--color-neutral-900: #ffffff;
--color-background: var(--color-neutral-100);
--color-background-secondary: var(--color-neutral-200);
--color-background-elevated: var(--color-neutral-300);
--color-text-primary: var(--color-neutral-800);
--color-text-secondary: var(--color-neutral-600);
--color-text-tertiary: var(--color-neutral-500);
--color-text-accent: var(--color-accent-light);
--color-text-inverse: var(--color-neutral-900);
--color-border: var(--color-neutral-400);
--color-border-hover: var(--color-accent);
--color-divider: var(--color-neutral-300);
--color-surface: var(--color-neutral-200);
--color-surface-hover: var(--color-neutral-300);
--color-surface-active: var(--color-neutral-400);
--color-success: #34d399;
--color-warning: #fbbf24;
--color-info: #60a5fa;
--color-success-bg: rgba(16, 185, 129, 0.2);
--color-warning-bg: rgba(245, 158, 11, 0.2);
--color-info-bg: rgba(59, 130, 246, 0.2);
--color-error-bg: rgba(239, 68, 68, 0.2);
--color-accent-hover: var(--color-accent);
--color-accent-active: var(--color-accent-dark);
--color-accent-disabled: #4a4269;
}

View file

@ -10,7 +10,6 @@
html {
overflow-x: hidden;
font-family: $font_default;
word-spacing: -3px;
}
#app {
@ -26,6 +25,7 @@ a {
input, textarea, button {
font-family: $font_default;
outline: none;
background-color: transparent;
}
button:focus-visible {
@ -37,17 +37,6 @@ button:focus-visible {
margin-inline: auto;
}
//::-webkit-scrollbar {
// width: 12px;
//}
//
//::-webkit-scrollbar-thumb {
// background-color: rgba($accent, 0.5);
// border-radius: $default_border_radius;
// -webkit-transition: all .2s ease;
// transition: all .2s ease;
//}
::-webkit-scrollbar-thumb {
background: $accent;
}

View file

@ -8,10 +8,8 @@
padding-block: 20px
}
.el-collapse-item {
border-radius: $default_border_radius;
border: 1px solid $accentDark;
background-color: rgba($accent, 0.2);
box-shadow: 0 0 10px 1px rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.el-collapse-item__header {
background-color: transparent !important;
@ -26,7 +24,7 @@
color: $accentDark !important;
}
.el-collapse-item__wrap {
border-top: 2px solid $accentDark;
border-top: 2px solid #e5e7eb;
border-bottom: none !important;
background-color: transparent !important;
}

View file

@ -0,0 +1,18 @@
@use "../global/variables" as *;
.el-rate {
height: unset !important;
}
.el-rate .el-rate__icon.is-active {
color: #facc15 !important;
}
.el-rate .el-rate__icon {
color: #facc15 !important;
font-size: 16px !important;
margin-right: 0 !important;
}
.white .el-rate__icon.is-active {
color: $white !important;
font-size: 18px !important;
}

View file

@ -0,0 +1,7 @@
@use "../global/variables" as *;
.el-select__wrapper {
height: 36px !important;
min-height: 36px !important;
background-color: transparent !important;
}

View file

@ -7,16 +7,16 @@
</template>
<script setup lang="ts">
import {onClickOutside} from "@vueuse/core";
import {onClickOutside} from '@vueuse/core';
const appStore = useAppStore()
const appStore = useAppStore();
const closeModal = () => {
appStore.unsetActiveState()
}
appStore.unsetActiveAuthState();
};
const modalRef = ref(null)
onClickOutside(modalRef, () => closeModal())
const modalRef = ref(null);
onClickOutside(modalRef, () => closeModal());
</script>
<style lang="scss" scoped>

View file

@ -0,0 +1,135 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer__wrapper">
<div class="footer__main">
<div class="footer__left">
<nuxt-link-locale to="/" class="footer__logo">
SCHON
</nuxt-link-locale>
<p class="footer__text">{{ t('footer.text') }}</p>
</div>
<div class="footer__columns">
<div class="footer__column">
<h6>{{ t('footer.shop') }}</h6>
<nuxt-link-locale to="/shop">{{ t('footer.allProducts') }}</nuxt-link-locale>
<nuxt-link-locale to="/catalog">{{ t('footer.catalog') }}</nuxt-link-locale>
<nuxt-link-locale to="/brands">{{ t('footer.brands') }}</nuxt-link-locale>
</div>
<div class="footer__column">
<h6>{{ t('footer.help') }}</h6>
<nuxt-link-locale to="/contact">{{ t('contact.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/privacy-policy">{{ t('docs.policy.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/terms-and-condition">{{ t('docs.terms.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/shipping-information">{{ t('docs.shipping.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/refund-policy">{{ t('docs.return.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/about-us">{{ t('docs.about.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/faq">{{ t('docs.faq.title') }}</nuxt-link-locale>
</div>
</div>
</div>
<div class="footer__bottom">
<p>© {{ actualYear }} Schon. {{ t('footer.rights') }}</p>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const companyStore = useCompanyStore();
const { t } = useI18n();
const companyInfo = computed(() => companyStore.companyInfo);
const actualYear = computed(() => new Date().getFullYear());
const encodedCompanyAddress = computed(() => {
return companyInfo.value?.companyAddress ? encodeURIComponent(companyInfo.value?.companyAddress) : '';
});
</script>
<style scoped lang="scss">
.footer {
background-color: #1a1a1a;
&__wrapper {
padding-block: 64px;
display: flex;
flex-direction: column;
gap: 50px;
}
&__main {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&__left {
display: flex;
flex-direction: column;
gap: 20px;
}
&__logo {
transition: 0.2s;
color: $white;
font-size: 24px;
font-weight: 600;
letter-spacing: 6.7px;
font-family: 'Playfair Display', sans-serif;
@include hover {
text-shadow: 0 0 5px #1a1a1a;
}
}
&__text {
max-width: 285px;
color: #d1d5db;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__columns {
display: flex;
align-items: flex-start;
gap: 150px;
}
&__column {
display: flex;
flex-direction: column;
gap: 14px;
& h6 {
color: $white;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.5px;
}
& p, a {
transition: 0.2s;
color: #d1d5db;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
@include hover {
color: #acafb4;
}
}
}
&__bottom {
& p {
color: #d1d5db;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
}
</style>

View file

@ -54,10 +54,8 @@
</template>
<script setup lang="ts">
import {ref} from "vue";
import {onClickOutside} from "@vueuse/core";
import type {ICategory} from "~/types";
import {useCategoryStore} from "~/stores/category";
import type {ICategory} from '@types';
import {onClickOutside} from '@vueuse/core';
const { t } = useI18n();
const categoryStore = useCategoryStore();

View file

@ -0,0 +1,355 @@
<template>
<header
class="header"
:class="[{
'header__no-search': !uiConfig.showSearchBar,
'header__fixed': uiConfig.isHeaderFixed
}]"
>
<div class="container">
<div class="header__wrapper">
<div class="header__inner">
<nuxt-link-locale to="/" class="header__logo">
SCHON
</nuxt-link-locale>
</div>
<div class="header__inner">
<nav class="header__nav">
<nuxt-link-locale
to="/shop"
class="header__nav-item"
:class="[{ active: route.name.includes('shop') }]"
>
{{ t('header.nav.shop') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/catalog"
class="header__nav-item"
:class="[{ active: route.name.includes('catalog') }]"
>
{{ t('header.nav.catalog') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/brands"
class="header__nav-item"
:class="[{ active: route.name.includes('brands') }]"
>
{{ t('header.nav.brands') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/blog"
class="header__nav-item"
:class="[{ active: route.name.includes('blog') }]"
>
{{ t('header.nav.blog') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/contact"
class="header__nav-item"
:class="[{ active: route.name.includes('contact') }]"
>
{{ t('header.nav.contact') }}
</nuxt-link-locale>
</nav>
</div>
<div class="header__inner">
<div class="header__block">
<icon
@click="isSearchVisible = true"
class="header__block-search"
name="tabler:search"
size="20"
/>
<ui-language-switcher />
<el-badge :value="productsInWishlistQuantity" color="#111827">
<nuxt-link-locale to="/wishlist">
<icon class="header__block-wishlist" name="material-symbols:favorite-rounded" size="20" />
</nuxt-link-locale>
</el-badge>
<el-badge :value="productsInCartQuantity" color="#111827">
<nuxt-link-locale to="/cart">
<icon class="header__block-cart" name="bx:bxs-shopping-bag" size="20" />
</nuxt-link-locale>
</el-badge>
<nuxt-link-locale
to="/profile/settings"
class="header__block-item"
v-if="isAuthenticated"
>
<nuxt-img
class="header__block-avatar"
v-if="user?.avatar"
:src="user?.avatar"
alt="avatar"
format="webp"
densities="x1"
/>
<div class="header__block-profile" v-else>
<icon name="clarity:avatar-line" size="16" />
</div>
</nuxt-link-locale>
<nuxt-link-locale
to="/auth/sign-in"
class="header__block-auth"
v-else
>
<p>{{ t('buttons.login') }}</p>
</nuxt-link-locale>
</div>
</div>
</div>
</div>
<div class="header__search" :class="[{ active: isSearchVisible }]">
<ui-search
ref="searchRef"
v-if="uiConfig.showSearchBar"
/>
</div>
</header>
</template>
<script setup lang="ts">
import { useProjectConfig } from "@composables/config";
import {onClickOutside} from "@vueuse/core";
const { t } = useI18n();
const localePath = useLocalePath();
const route = useRoute();
const appStore = useAppStore();
const userStore = useUserStore();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const { uiConfig } = useProjectConfig();
const isAuthenticated = computed(() => userStore.isAuthenticated);
const user = computed(() => userStore.user);
const productsInCartQuantity = computed(() => {
let count = 0;
cartStore.currentOrder?.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
return count;
});
const productsInWishlistQuantity = computed(() => {
return wishlistStore.wishlist ? wishlistStore.wishlist.products.edges.length : 0;
});
const isSearchVisible = ref<boolean>(false);
const searchRef = ref(null);
onClickOutside(searchRef, () => isSearchVisible.value = false);
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/ + ${to}`));
}
};
</script>
<style lang="scss" scoped>
.header {
position: relative;
z-index: 3;
top: 0;
left: 0;
width: 100vw;
background-color: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid #f3f4f6;
&__no-search {
padding-inline: 75px;
& .header__inner {
gap: 75px;
}
}
&__fixed {
position: fixed;
}
&__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 25px;
padding-block: 25px;
}
&__inner {
width: 33%;
display: flex;
align-items: center;
justify-content: center;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
&__logo {
transition: 0.2s;
font-size: 24px;
font-weight: 600;
letter-spacing: 6.7px;
font-family: 'Playfair Display', sans-serif;
color: #1a1a1a;
@include hover {
text-shadow: 0 0 5px #1a1a1a;
}
}
&__nav {
display: flex;
align-items: center;
gap: 40px;
&-item {
position: relative;
transition: 0.2s;
color: #1f2937;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
&::after {
content: "";
position: absolute;
bottom: -3px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: #1f2937;
}
&.active::after {
width: 100%;
}
@include hover {
&::after {
width: 100%;
}
}
}
}
&__block {
display: flex;
align-items: center;
gap: 16px;
&-block {
display: flex;
align-items: center;
gap: 15px;
}
&-search {
cursor: pointer;
display: block;
transition: 0.2s;
color: #374151;
@include hover {
color: #29323f;
}
}
&-wishlist {
display: block;
cursor: pointer;
transition: 0.2s;
color: #374151;
@include hover {
color: #29323f;
}
}
&-cart {
display: block;
cursor: pointer;
transition: 0.2s;
color: #374151;
@include hover {
color: #29323f;
}
}
&-auth {
transition: 0.2s;
border-bottom: 1px solid #111827;
padding-bottom: 2px;
color: #111827;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
@include hover {
color: #424c62;
}
}
&-avatar {
width: 32px;
border-radius: 50%;
border: 1px solid #374151;
transition: 0.2s;
& span {
display: block;
}
@include hover {
opacity: 0.7;
}
}
&-profile {
width: 32px;
border-radius: 50%;
padding: 5px;
border: 1px solid #374151;
transition: 0.2s;
& span {
display: block;
}
@include hover {
opacity: 0.7;
}
}
}
&__search {
position: absolute;
width: 100%;
top: 100%;
left: 0;
overflow: hidden;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
}
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<nuxt-link-locale
class="card"
:to="`/catalog/${brand.uuid}`"
:to="`/brand/${brand.slug}`"
>
<nuxt-img
v-if="brand.smallLogo"
@ -18,7 +18,7 @@
</template>
<script setup lang="ts">
import type {IBrand} from "~/types";
import type {IBrand} from '@types';
const props = defineProps<{
brand: IBrand;
@ -34,14 +34,15 @@ const props = defineProps<{
align-items: center;
gap: 20px;
background-color: $white;
border-radius: $default_border_radius;
border: 2px solid $accentDark;
border-radius: 8px;
border: 1px solid #e5e7eb;
height: 100%;
padding: 20px;
padding: 23px;
transition: 0.2s;
@include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4);
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.3);
border-color: #505052;
}
&__image {
@ -59,9 +60,11 @@ const props = defineProps<{
}
& p {
color: #1a1a1a;
text-align: center;
font-size: 18px;
font-weight: 600;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -18,7 +18,7 @@
</template>
<script setup lang="ts">
import type {ICategory} from "~/types/index.js";
import type {ICategory} from '@types';
const props = defineProps<{
category: ICategory;
@ -34,14 +34,15 @@ const props = defineProps<{
align-items: center;
gap: 20px;
background-color: $white;
border-radius: $default_border_radius;
border: 2px solid $accentDark;
border-radius: 8px;
border: 1px solid #e5e7eb;
height: 100%;
padding: 20px;
padding: 23px;
transition: 0.2s;
@include hover {
box-shadow: 0 0 30px 3px rgba($accentDark, 0.4);
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.3);
border-color: #505052;
}
&__image {
@ -59,9 +60,11 @@ const props = defineProps<{
}
& p {
color: #1a1a1a;
text-align: center;
font-size: 18px;
font-weight: 600;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -59,20 +59,20 @@
</div>
<div class="order__product-right">
<h6>{{ t('profile.orders.price') }}: {{ product.node.product.price * product.node.quantity }}{{ CURRENCY }}</h6>
<p>{{ product.node.quantity }} X {{ product.node.product.price }}{{ CURRENCY }}</p>
<p>{{ product.node.quantity }} X {{ product.node.product.price }}$</p>
</div>
</div>
<div class="order__total">
<p>{{ t('profile.orders.total') }}: {{ order.totalPrice }}{{ CURRENCY }}</p>
<p>{{ t('profile.orders.total') }}: {{ order.totalPrice }}$</p>
</div>
</div>
</el-collapse-item>
</template>
<script setup lang="ts">
import {useDate} from "~/composables/date";
import {CURRENCY, orderStatuses} from "~/config/constants";
import type {IOrder} from "~/types";
import {useDate} from '@composables/date';
import {orderStatuses, CURRENCY} from '@appConstants';
import type {IOrder} from '@types';
const props = defineProps<{
order: IOrder;
@ -125,6 +125,10 @@ const statusColor = (status: string) => {
align-items: center;
gap: 5px;
color: #4b5563;
font-size: 14px;
font-weight: 400;
&.status {
border-radius: $default_border_radius;
padding: 3px 7px;
@ -134,6 +138,7 @@ const statusColor = (status: string) => {
&-icon {
transition: 0.2s;
color: #4b5563;
&.active {
transform: rotate(-180deg);
@ -157,7 +162,7 @@ const statusColor = (status: string) => {
& div {
& div {
padding-top: 10px;
border-top: 1px solid $accent;
border-top: 1px solid #e5e7eb;
display: flex;
align-items: flex-start;
justify-content: space-between;
@ -177,8 +182,9 @@ const statusColor = (status: string) => {
}
& p {
color: #4b5563;
font-size: 18px;
font-weight: 600;
font-size: 20px;
}
}
}
@ -196,7 +202,7 @@ const statusColor = (status: string) => {
align-items: flex-start;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 2px solid $accent;
border-bottom: 2px solid #e5e7eb;
&-left {
display: flex;
@ -209,8 +215,9 @@ const statusColor = (status: string) => {
}
& p {
color: #4b5563;
font-size: 18px;
font-weight: 500;
font-weight: 600;
}
}
@ -220,11 +227,15 @@ const statusColor = (status: string) => {
align-items: flex-end;
& h6 {
font-size: 20px;
color: #4b5563;
font-size: 18px;
font-weight: 600;
}
& p {
color: #4b5563;
font-size: 14px;
font-weight: 400;
}
}
}

View file

@ -0,0 +1,63 @@
<template>
<div class="card">
<p class="card__title">{{ post.title }}</p>
<nuxt-link-locale :to="`/blog/${post.slug}`" class="card__button">{{ t('buttons.readMore') }}</nuxt-link-locale>
</div>
</template>
<script setup lang="ts">
import type {IPost} from '@types';
const props = defineProps<{
post: { node: IPost }[];
}>();
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.card {
display: flex;
flex-direction: column;
gap: 10px;
&__title {
color: #1a1a1a;
font-family: "Playfair Display", sans-serif;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__button {
width: fit-content;
position: relative;
color: #1a1a1a;
transition: 0.2s;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
&::after {
content: "";
position: absolute;
bottom: -3px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: #1f2937;
}
&.active::after {
width: 100%;
}
@include hover {
&::after {
width: 100%;
}
}
}
}
</style>

View file

@ -3,6 +3,17 @@
class="card"
:class="{ 'card__list': isList }"
>
<div
class="card__wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name
})"
>
<icon name="mdi:cards-heart" size="16" v-if="isProductInWishlist" />
<icon name="mdi:cards-heart-outline" size="16" v-else />
</div>
<div class="card__wrapper">
<nuxt-link-locale
:to="`/product/${product.slug}`"
@ -46,58 +57,22 @@
</client-only>
</div>
</nuxt-link-locale>
</div>
<div class="card__content">
<div class="card__price">{{ product.price }} {{ CURRENCY }}</div>
<div class="card__content-inner">
<div class="card__brand">{{ product.brand.name }}</div>
<p class="card__name">{{ product.name }}</p>
<el-rate
class="card__rating"
v-model="rating"
size="large"
disabled
/>
<div class="card__quantity">{{ t('cards.product.stock') }} {{ product.quantity }}</div>
</div>
<div class="card__price">{{ product.price }} $</div>
</div>
<div class="card__bottom">
<div class="card__bottom-inner">
<ui-button
class="card__bottom-button"
v-if="isProductInCart"
@click="overwriteOrder({
type: 'remove',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="removeLoading"
>
{{ t('buttons.removeFromCart') }}
</ui-button>
<ui-button
v-else
class="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
<div
class="card__bottom-wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name
})"
>
<icon name="mdi:cards-heart" size="28" v-if="isProductInWishlist" />
<icon name="mdi:cards-heart-outline" size="28" v-else />
</div>
</div>
<div class="tools" v-if="isToolsVisible && isProductInCart">
<div class="tools" v-if="isProductInCart">
<button
class="tools__item tools__item-button"
@click="overwriteOrder({
@ -120,25 +95,35 @@
+
</button>
</div>
<ui-button
v-else
class="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IProduct} from "~/types/app/products";
import type {IProduct} from '@types';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-fade';
import 'swiper/css/pagination'
import {useWishlistOverwrite} from "~/composables/wishlist";
import {useOrderOverwrite} from "~/composables/orders";
import {CURRENCY} from "~/config/constants";
import {useWishlistOverwrite} from '@composables/wishlist';
import {useOrderOverwrite} from '@composables/orders';
const props = defineProps<{
product: IProduct;
isList?: boolean;
isToolsVisible?: boolean;
}>();
const {t} = useI18n();
@ -192,8 +177,8 @@ function goTo(index: number) {
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 2px solid $accentDark;
border-radius: 8px;
border: 1px solid #e5e7eb;
width: 100%;
background-color: $white;
transition: 0.2s;
@ -201,13 +186,12 @@ function goTo(index: number) {
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&__list {
flex-direction: row;
align-items: stretch;
justify-content: space-between;
padding: 10px;
gap: 50px;
padding: 15px;
& .card__link {
width: fit-content;
@ -219,6 +203,22 @@ function goTo(index: number) {
height: 150px;
}
& .card__content {
width: 100%;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
padding: 0;
&-inner {
align-self: flex-start;
}
}
& .tools {
width: 136px;
}
& .card__bottom {
margin-top: 0;
width: fit-content;
@ -247,7 +247,8 @@ function goTo(index: number) {
}
@include hover {
box-shadow: 0 0 20px 2px rgba($accentDark, 0.4);
border-color: #b7b8bb;
box-shadow: 0 0 10px 1px #e5e7eb;
}
&__wrapper {
@ -259,7 +260,6 @@ function goTo(index: number) {
&__link {
display: block;
width: 100%;
padding: 20px 15px;
}
&__block {
@ -303,24 +303,44 @@ function goTo(index: number) {
}
&__content {
padding-inline: 20px;
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 15px;
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__brand {
font-size: 12px;
font-weight: 400;
letter-spacing: -0.2px;
color: #6b7280;
}
&__price {
width: fit-content;
background-color: rgba($accent, 0.2);
border-radius: $default_border_radius;
padding: 5px 10px;
margin-bottom: 10px;
font-weight: 700;
font-weight: 600;
font-size: 16px;
letter-spacing: -0.5px;
color: #1a1a1a;
}
&__rating {
margin-block: 4px 10px;
}
&__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $black;
font-weight: 500;
font-size: 14px;
letter-spacing: -0.5px;
}
&__quantity {
@ -331,11 +351,30 @@ function goTo(index: number) {
font-size: 14px;
}
&__bottom {
margin-top: auto;
padding: 0 20px 20px 20px;
max-width: 100%;
&__wishlist {
cursor: pointer;
width: fit-content;
background-color: $white;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
border-radius: 50%;
padding: 12px;
transition: 0.2s;
@include hover {
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.3);
}
& span {
color: #4b5563;
display: block;
}
}
&__bottom {
&-inner {
display: flex;
align-items: center;
@ -344,7 +383,8 @@ function goTo(index: number) {
}
&-button {
width: 84%;
padding-block: 10px !important;
border-radius: 4px;
}
&-wishlist {
@ -371,10 +411,10 @@ function goTo(index: number) {
.tools {
width: 100%;
border-radius: 4px;
background-color: rgba($accent, 0.2);
background-color: #111827;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
height: 30px;
height: 40px;
&__item {
display: flex;
@ -387,23 +427,23 @@ function goTo(index: number) {
border-left: 1px solid $accent;
border-right: 1px solid $accent;
color: $accent;
font-size: 18px;
color: $white;
font-size: 16px;
font-weight: 600;
}
&-button {
cursor: pointer;
background-color: rgba($accent, 0.2);
background-color: #111827;
border-radius: 4px 0 0 4px;
transition: 0.2s;
color: $accent;
font-size: 20px;
color: $white;
font-size: 18px;
font-weight: 500;
@include hover {
background-color: $accent;
background-color: #222c41;
color: $white;
}

View file

@ -0,0 +1,317 @@
<template>
<div v-if="isDemoMode && isOpen" class="modal">
<div
class="modal__wrapper"
>
<demo-ui-button
@click="appStore.setDemoSettings(false)"
class="modal__close"
>
<Icon name="material-symbols:close" size="30" />
</demo-ui-button>
<h2 class="modal__title">{{ toConstantCase(t('demo.settings.title')) }}</h2>
<div class="modal__inner">
<div class="modal__block">
<h3 class="modal__block-title">{{ toConstantCase(t('demo.settings.ui')) }}</h3>
<div
v-for="(flag, idx) in availableFlags"
:key="flag.key"
class="modal__block-item"
>
<demo-ui-checkbox
:label="toConstantCase(flag.label)"
v-model="localFlags[flag.key]"
:id="idx"
/>
<p>{{ flag.description }}</p>
</div>
</div>
<demo-ui-button @click="showCodePreview = !showCodePreview">{{ toConstantCase(t('demo.buttons.generateCode')) }}</demo-ui-button>
<div v-if="showCodePreview" class="modal__preview">
<div class="modal__preview-wrapper">
<pre class="modal__preview-code"><code class="language-typescript">{{ codePreview }}</code></pre>
<demo-ui-button
@click="copyConfig"
class="modal__preview-button"
>
<Icon name="material-symbols:content-copy" size="16" />
</demo-ui-button>
</div>
<p class="modal__preview-text">{{ t('demo.preview.text') }}</p>
</div>
</div>
<div class="modal__buttons">
<demo-ui-button @click="resetToDefault">
<Icon name="material-symbols:refresh" size="20" />
{{ toConstantCase(t('demo.buttons.reset')) }}
</demo-ui-button>
<demo-ui-button @click="saveChanges">
<Icon name="material-symbols:save" size="20" />
{{ toConstantCase(t('demo.buttons.save')) }}
</demo-ui-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useProjectConfig } from '@composables/config';
const appStore = useAppStore();
const {t} = useI18n();
const isOpen = computed(() => appStore.isDemoSettings);
const {
isDemoMode,
availableFlags,
setDemoFlag,
resetDemoFlags,
uiConfig,
generateConfigCode,
copyToClipboard
} = useProjectConfig();
const showCodePreview = ref<boolean>(false);
const localFlags = ref<Record<string, boolean>>({});
const previewConfig = computed(() => {
const config: Record<string, boolean> = {};
availableFlags.value.forEach(flag => {
config[flag.key] = localFlags.value[flag.key] !== undefined
? localFlags.value[flag.key]
: flag.value;
});
return config;
});
const formatPreviewConfig = (config: Record<string, boolean>): string => {
const entries = Object.entries(config)
.map(([key, value]) => ` ${key}: ${value}`)
.join(',\n');
return `ui: {\n${entries}\n}`;
};
function toConstantCase(text: string): string {
const placeholders: string[] = [];
let placeholderIndex = 0;
const textWithPlaceholders = text.replace(/(['"])(.*?)\1/g, (match) => {
placeholders.push(match);
return `__QUOTE_PLACEHOLDER_${placeholderIndex++}__`;
});
let result = textWithPlaceholders
.toUpperCase()
.replace(/\s+/g, '_')
.replace(/[^A-Z0-9_]/g, '');
placeholders.forEach((placeholder, index) => {
result = result.replace(`__QUOTE_PLACEHOLDER_${index}__`, placeholder);
});
return result;
}
watch(isOpen, (newVal) => {
if (newVal) {
const flags: Record<string, boolean> = {};
availableFlags.value.forEach(flag => {
flags[flag.key] = flag.value;
});
localFlags.value = flags;
}
}, { immediate: true });
const saveChanges = () => {
availableFlags.value.forEach(flag => {
const newValue = localFlags.value[flag.key];
const oldValue = flag.value;
if (newValue !== oldValue) {
setDemoFlag(flag.key, newValue);
}
});
appStore.setDemoSettings(false);
};
const resetToDefault = () => {
resetDemoFlags();
appStore.setDemoSettings(false);
};
const copyConfig = async () => {
const code = codePreview.value;
await copyToClipboard(code);
};
const codePreview = computed(() => {
return formatPreviewConfig(previewConfig.value);
});
</script>
<style scoped lang="scss">
.modal {
position: fixed;
z-index: 3;
width: 100vw;
height: 100vh;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
&__wrapper {
position: absolute;
z-index: 2;
inset: 100px;
background-color: #0d0d0d;
padding: 50px;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 50px;
}
&__close {
position: absolute;
z-index: 1;
top: 15px;
right: 15px;
padding: 7px !important;
}
&__title {
text-align: center;
font-size: 40px;
font-weight: 500;
text-shadow: 0 0 5px $accentNeon;
text-transform: uppercase;
color: $accentNeon;
}
&__inner {
height: 100%;
width: 100%;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
padding: 10px 20px;
scrollbar-color: $accentNeon transparent;
}
&__block {
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 15px;
&-title {
width: fit-content;
margin-bottom: 20px;
padding-bottom: 5px;
border-bottom: 2px solid $accentNeon;
color: $accentNeon;
font-weight: 500;
text-shadow: 0 0 5px $accentNeon;
text-transform: uppercase;
font-size: 22px;
}
&-item {
display: flex;
flex-direction: column;
gap: 5px;
& p {
padding-left: 50px;
color: #8c8c8c;
font-weight: 500;
font-size: 16px;
}
}
}
&__preview {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&-wrapper {
position: relative;
background-color: #1a1a1a;
border-radius: $default_border_radius;
padding: 20px;
border: 1px solid rgba($accentNeon, 0.3);
}
&-code {
margin: 0;
color: #f8f8f2;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
overflow-x: auto;
.language-typescript {
color: #f8f8f2;
.keyword {
color: lch(69.03% 60.03 345.46);
}
.string {
color: #f1fa8c;
}
.number {
color: #bd93f9;
}
.boolean {
color: #ff79c6;
}
.property {
color: #8be9fd;
}
}
}
&-button {
position: absolute;
top: 10px;
right: 10px;
padding: 6px 12px !important;
font-size: 12px !important;
gap: 6px !important;
}
&-text {
color: #8c8c8c;
font-weight: 500;
font-size: 14px;
text-align: center;
}
}
&__buttons {
border-top: 1px solid $white;
padding-top: 25px;
display: flex;
align-items: center;
justify-content: center;
gap: 25px;
}
}
</style>

View file

@ -1,9 +1,9 @@
<template>
<button
type="button"
class="button"
:disabled="isDisabled"
:class="[{active: isLoading}]"
:type="type"
>
<ui-loader class="button__loader" v-if="isLoading" />
<slot v-else />
@ -12,49 +12,54 @@
<script setup lang="ts">
const props = defineProps<{
type: 'submit' | 'button',
isDisabled?: boolean,
isLoading?: boolean
}>()
isDisabled?: boolean;
isLoading?: boolean;
}>();
</script>
<style lang="scss" scoped>
.button {
position: relative;
width: 100%;
width: fit-content;
cursor: pointer;
flex-shrink: 0;
transition: 0.2s;
border: 1px solid $accent;
background-color: $accent;
border: 1px solid $accentNeon;
box-shadow: 0 0 10px 1px $accentNeon;
background-color: transparent;
border-radius: $default_border_radius;
padding-block: 7px;
display: grid;
place-items: center;
padding: 7px 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
z-index: 1;
color: $white;
color: $accentNeon;
text-shadow: 0 0 10px $accentNeon;
text-align: center;
font-size: 14px;
font-weight: 700;
font-size: 16px;
font-weight: 500;
@include hover {
background-color: $accentLight;
background-color: $accentNeon;
color: $white;
}
&.active {
background-color: $accentLight;
background-color: $accentNeon;
color: $white;
}
&:disabled {
cursor: not-allowed;
background-color: $accentDisabled;
color: $white;
background-color: transparent;
color: #fff;
}
&:disabled:hover, &.active {
background-color: $accentDisabled;
color: $white;
background-color: transparent;
color: #fff;
}
&__loader {

View file

@ -0,0 +1,187 @@
<template>
<label class="checkbox">
<input
:id="id"
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<span class="checkbox__box">
<span class="checkbox__mark"></span>
</span>
<span
class="checkbox__label"
:data-text="label"
>
{{ label }}
</span>
</label>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string;
modelValue: boolean;
id: number;
}>();
defineEmits<{
'update:modelValue': [value: boolean]
}>();
</script>
<style lang="scss" scoped>
.checkbox {
width: fit-content;
display: flex;
align-items: center;
gap: 15px;
cursor: pointer;
user-select: none;
position: relative;
--glitch-anim-duration: 0.3s;
& input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
&__box {
width: 1.5em;
height: 1.5em;
border: 2px solid $accentNeon;
position: relative;
transition: all 0.3s ease;
clip-path: polygon(
15% 0,
85% 0,
100% 15%,
100% 85%,
85% 100%,
15% 100%,
0 85%,
0 15%
);
}
&__mark {
position: absolute;
top: 50%;
left: 50%;
width: 60%;
height: 60%;
background-color: $accentNeon;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
clip-path: inherit;
}
&__label {
color: $white;
font-weight: 500;
font-size: 18px;
text-transform: uppercase;
position: relative;
transition:
color 0.3s ease,
text-shadow 0.3s ease;
}
}
.checkbox input:checked + .checkbox__box .checkbox__mark {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
animation: glitch-anim-checkbox var(--glitch-anim-duration) both;
}
.checkbox input:checked ~ .checkbox__label {
color: $accentNeon;
text-shadow: 0 0 8px $accentNeon;
}
.checkbox:hover .checkbox__box {
box-shadow: 0 0 10px $accentNeon;
}
.checkbox:hover .checkbox__label::before,
.checkbox:hover .checkbox__label::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #050505;
}
.checkbox:hover .checkbox__label::before {
color: #a855f7;
animation: glitch-anim-text var(--glitch-anim-duration)
cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}
.checkbox:hover .checkbox__label::after {
color: $accentNeon;
animation: glitch-anim-text var(--glitch-anim-duration)
cubic-bezier(0.25, 0.46, 0.45, 0.94) reverse both;
}
@keyframes glitch-anim-checkbox {
0% {
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
20% {
transform: translate(calc(-50% - 3px), calc(-50% + 2px));
clip-path: inset(50% 0 20% 0);
}
40% {
transform: translate(calc(-50% + 2px), calc(-50% - 1px));
clip-path: inset(20% 0 60% 0);
}
60% {
transform: translate(calc(-50% - 2px), calc(-50% + 1px));
clip-path: inset(80% 0 5% 0);
}
80% {
transform: translate(calc(-50% + 2px), calc(-50% - 2px));
clip-path: inset(30% 0 45% 0);
}
100% {
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
}
@keyframes glitch-anim-text {
0% {
transform: translate(0);
clip-path: inset(0 0 0 0);
}
20% {
transform: translate(-3px, 2px);
clip-path: inset(50% 0 20% 0);
}
40% {
transform: translate(2px, -1px);
clip-path: inset(20% 0 60% 0);
}
60% {
transform: translate(-2px, 1px);
clip-path: inset(80% 0 5% 0);
}
80% {
transform: translate(2px, -2px);
clip-path: inset(30% 0 45% 0);
}
100% {
transform: translate(0);
clip-path: inset(0 0 0 0);
}
}
</style>

View file

@ -1,14 +1,17 @@
<template>
<form @submit.prevent="handleContactUs()" class="form">
<h2 class="form__title">{{ t('contact.form.title') }}</h2>
<ui-input
:type="'text'"
:placeholder="t('fields.name')"
:label="t('fields.name')"
:rules="[required]"
v-model="name"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[required]"
v-model="email"
:inputMode="'email'"
@ -16,6 +19,7 @@
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
:inputMode="'tel'"
@ -23,11 +27,13 @@
<ui-input
:type="'text'"
:placeholder="t('fields.subject')"
:label="t('fields.subject')"
:rules="[required]"
v-model="subject"
/>
<ui-textarea
:placeholder="t('fields.message')"
:label="t('fields.message')"
:rules="[required]"
v-model="message"
/>
@ -37,14 +43,14 @@
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.send') }}
{{ t('buttons.sendMessage') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from "~/composables/rules";
import {useContactUs} from "~/composables/contact/index.js";
import {useValidators} from '@composables/rules';
import {useContactUs} from '@composables/contact';
const { t } = useI18n();
@ -81,8 +87,20 @@ async function handleContactUs() {
<style lang="scss" scoped>
.form {
width: 585px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 24px;
border-radius: 16px;
border: 1px solid #e5e7eb;
padding: 32px;
&__title {
color: #1a1a1a;
font-family: "Playfair Display", sans-serif;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -29,15 +29,14 @@
</template>
<script setup lang="ts">
import {useDeposit} from "~/composables/user";
import {useDeposit} from '@composables/user';
const { t } = useI18n();
const companyStore = useCompanyStore();
const paymentMin = computed(() => companyStore.companyInfo?.paymentGatewayMinimum || 0);
const paymentMax = computed(() => companyStore.companyInfo?.paymentGatewayMaximum || 500);
const { paymentMin, paymentMax } = usePaymentLimits();
const amount = ref<string>("0");
const amount = ref<number>(5);
const isFormValid = computed(() => {
return (

View file

@ -0,0 +1,148 @@
<template>
<form @submit.prevent="handleLogin()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.login.title') }}</h2>
<p class="form__subtitle">{{ t('forms.login.subtitle') }}</p>
</div>
<div class="form__main">
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
:inputMode="'email'"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.password')"
:label="t('fields.password')"
:rules="[required]"
v-model="password"
/>
<div class="form__main-block">
<ui-checkbox
v-model="isStayLogin"
>
{{ t('checkboxes.remember') }}
</ui-checkbox>
<ui-link
@click="redirectTo('reset-password')"
>
{{ t('forms.login.forgot') }}
</ui-link>
</div>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.login') }}
</ui-button>
<p class="form__or">{{ t('forms.login.or') }}</p>
<ui-button
@click="redirectTo('sign-up')"
:type="'button'"
:style="'secondary'"
class="form__button"
>
{{ t('buttons.createAccount') }}
</ui-button>
</div>
</form>
</template>
<script setup lang="ts">
import {useLogin} from '@composables/auth';
import {useValidators} from '@composables/rules';
import {useProjectConfig} from "@composables/config";
const { t } = useI18n();
const appStore = useAppStore();
const route = useRoute();
const localePath = useLocalePath();
const { uiConfig } = useProjectConfig();
const { required, isEmail } = useValidators();
const email = ref<string>('');
const password = ref<string>('');
const isStayLogin = ref<boolean>(false);
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/${to}`));
}
};
const isFormValid = computed(() => {
return (
isEmail(email.value) === true &&
required(password.value) === true
);
});
const { login, loading } = useLogin();
async function handleLogin() {
await login(email.value, password.value, isStayLogin.value);
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: #111827;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: #4b5563;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
&__or {
text-align: center;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
color: #6b7280;
}
}
</style>

View file

@ -1,15 +1,19 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.newPassword.title') }}</h2>
</div>
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:label="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:label="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
@ -25,8 +29,8 @@
</template>
<script setup lang="ts">
import {useValidators} from "~/composables/rules/index.js";
import {useNewPassword} from "@/composables/auth";
import {useValidators} from '@composables/rules';
import {useNewPassword} from '@composables/auth';
const { t } = useI18n();
@ -59,13 +63,48 @@ async function handleReset() {
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
font-size: 36px;
color: $accent;
color: #111827;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: #4b5563;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
}
</style>

View file

@ -0,0 +1,244 @@
<template>
<form @submit.prevent="handleRegister()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.register.title') }}</h2>
<p class="form__subtitle">{{ t('forms.register.subtitle') }}</p>
</div>
<div class="form__main">
<div class="form__main-box">
<ui-input
:type="'text'"
:placeholder="t('fields.firstName')"
:label="t('fields.firstName')"
:rules="[required]"
v-model="firstName"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.lastName')"
:label="t('fields.lastName')"
:rules="[required]"
v-model="lastName"
/>
</div>
<div class="form__main-box">
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
:inputMode="'tel'"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
:inputMode="'email'"
/>
</div>
<ui-input
:type="'password'"
:placeholder="t('fields.password')"
:label="t('fields.password')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmPassword')"
:label="t('fields.confirmPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-checkbox
v-model="isSubscribed"
>
{{ t('checkboxes.subscribe') }}
</ui-checkbox>
<ui-checkbox
v-model="isAgree"
>
<i18n-t tag="p" scope="global" keypath="checkboxes.agree">
<template #terms>
<nuxt-link-locale
class="form__link"
to="/docs/terms-of-use"
>
{{ t('docs.terms.title') }}
</nuxt-link-locale>
</template>
<template #policy>
<nuxt-link-locale
class="form__link"
to="/docs/privacy-policy"
>
{{ t('docs.policy.title') }}
</nuxt-link-locale>
</template>
</i18n-t>
</ui-checkbox>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.createAccount') }}
</ui-button>
<div class="form__login">
<p>{{ t('forms.register.login') }}</p>
<ui-link
@click="redirectTo('sign-in')"
>
{{ t('buttons.login') }}
</ui-link>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useRegister} from '@composables/auth';
import {useRouteQuery} from '@vueuse/router';
import {useProjectConfig} from "@composables/config";
const { t } = useI18n();
const appStore = useAppStore();
const route = useRoute();
const localePath = useLocalePath();
const { uiConfig } = useProjectConfig();
const { required, isEmail, isPasswordValid } = useValidators();
const isAgree = ref<boolean>(false);
const isSubscribed = ref<boolean>(false);
const firstName = ref<string>('');
const lastName = ref<string>('');
const phoneNumber = ref<string>('');
const email = ref<string>('');
const password = ref<string>('');
const confirmPassword = ref<string>('');
const referrer = useRouteQuery('referrer', '');
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/${to}`));
}
};
const compareStrings = (v: string) => {
if (v === password.value) return true;
return t('errors.compare');
};
const isFormValid = computed(() => {
return (
required(firstName.value) === true &&
required(lastName.value) === true &&
required(phoneNumber.value) === true &&
isEmail(email.value) === true &&
isPasswordValid(password.value) === true &&
compareStrings(confirmPassword.value) === true &&
isAgree.value === true
);
});
const { register, loading } = useRegister();
async function handleRegister() {
await register({
firstName: firstName.value,
lastName: lastName.value,
phoneNumber: phoneNumber.value,
email: email.value,
password: password.value,
confirmPassword: confirmPassword.value,
referrer: referrer.value,
isSubscribed: isSubscribed.value
});
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: #111827;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: #4b5563;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
&-box {
display: flex;
align-items: flex-start;
gap: 15px;
}
}
&__link {
transition: 0.2s;
color: #111827;
@include hover {
color: #2b3752;
}
}
&__login {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
& p {
text-align: center;
color: #4b5563;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
}
</style>

View file

@ -0,0 +1,115 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.reset.title') }}</h2>
<p class="form__subtitle">{{ t('forms.reset.subtitle') }}</p>
</div>
<div class="form__main">
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
:inputMode="'email'"
/>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.sendLink') }}
</ui-button>
<ui-link
@click="redirectTo('sign-in')"
>
<icon name="material-symbols:arrow-left-alt" size="20" />
{{ t('forms.reset.backToLogin') }}
</ui-link>
</div>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {usePasswordReset} from '@composables/auth';
import {useProjectConfig} from "@composables/config";
const { t } = useI18n();
const appStore = useAppStore();
const localePath = useLocalePath();
const { uiConfig } = useProjectConfig();
const { isEmail } = useValidators();
const email = ref<string>('');
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/${to}`));
}
};
const isFormValid = computed(() => {
return (
isEmail(email.value) === true
);
});
const { resetPassword, loading } = usePasswordReset();
async function handleReset() {
await resetPassword(email.value);
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: #111827;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: #4b5563;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
}
</style>

View file

@ -4,12 +4,14 @@
<ui-input
:type="'text'"
:placeholder="t('fields.firstName')"
:label="t('fields.firstName')"
:rules="[required]"
v-model="firstName"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.lastName')"
:label="t('fields.lastName')"
:rules="[required]"
v-model="lastName"
/>
@ -18,12 +20,14 @@
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
/>
@ -32,12 +36,14 @@
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:label="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:label="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
@ -47,14 +53,14 @@
class="form__button"
:isLoading="loading"
>
{{ t('buttons.save') }}
{{ t('buttons.saveChanges') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from "~/composables/rules";
import {useUserUpdating} from "~/composables/user/index.js";
import {useValidators} from '@composables/rules';
import {useUserUpdating} from '@composables/user';
const { t } = useI18n();
const userStore = useUserStore();

View file

@ -0,0 +1,85 @@
<template>
<div class="ad">
<div class="container">
<div class="ad__wrapper">
<div class="ad__block">
<h2 class="ad__title">{{ t('home.ad.title') }}</h2>
<p class="ad__subtext">{{ t('home.ad.text1') }}</p>
<p class="ad__text">{{ t('home.ad.text2') }}</p>
<nuxt-link-locale to="/shop" class="ad__button">{{ t('buttons.shopTheSale') }}</nuxt-link-locale>
</div>
<nuxt-img
format="webp"
densities="x1"
src="/images/saleImage.png"
alt="ad"
loading="lazy"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.ad {
&__wrapper {
background: linear-gradient(to bottom, rgba(26, 26, 26, 1) 0%,rgba(31, 41, 55, 1) 100%);
border-radius: 8px;
padding: 32px 32px 32px 50px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__block {
display: flex;
flex-direction: column;
gap: 10px;
}
&__title {
margin-bottom: 10px;
color: $white;
font-family: "Playfair Display", sans-serif;
font-size: 36px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtext {
color: $white;
font-size: 20px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__text {
color: #d1d5db;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__button {
margin-top: 20px;
width: fit-content;
background-color: $white;
padding: 15px 35px;
transition: 0.2s;
text-transform: uppercase;
color: #1a1a1a;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.1px;
@include hover {
background-color: #cbcbcb;
}
}
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<div class="blog">
<div class="container">
<div class="blog__wrapper">
<h2 class="blog__title">{{ t('home.blog.title') }}</h2>
<div class="blog__posts">
<cards-post
v-for="post in filteredPosts.slice(0, 3)"
:key="post.node.id"
:post="post.node"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IPost} from '@types';
import { docsSlugs } from '@appConstants';
const props = defineProps<{
posts: { node: IPost }[];
}>();
const {t} = useI18n();
const filteredPosts = computed(() => {
const excludedSlugs = Object.values(docsSlugs);
return props.posts.filter(post => !excludedSlugs.includes(post.node.slug));
});
</script>
<style lang="scss" scoped>
.blog {
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
&__title {
color: $black;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__posts {
display: flex;
align-items: stretch;
gap: 32px;
}
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<div class="brands">
<nuxt-marquee
class="brands__marquee"
id="marquee-slider"
:speed="50"
:pauseOnHover="true"
>
<nuxt-link-locale
class="brands__item"
v-for="brand in brands"
:key="brand.node.uuid"
:to="`/brand/${brand.node.slug}`"
>
<p>{{ brand.node.name }}</p>
</nuxt-link-locale>
</nuxt-marquee>
</div>
</template>
<script setup lang="ts">
import type {IBrand} from '@types';
const props = defineProps<{
brands: { node: IBrand }[];
}>();
</script>
<style lang="scss" scoped>
.brands {
background-color: #e5e7eb;
padding-block: 65px;
&__item {
transition: 0.2s;
margin-right: 65px;
color: #4b5563;
font-family: "Playfair Display", sans-serif;
font-size: 24px;
font-weight: 400;
letter-spacing: 1.9px;
@include hover {
text-shadow: 0 0 10px #1a1a1a;
}
}
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div class="categories">
<div class="container">
<div class="categories__wrapper">
<h2 class="categories__title">{{ t('home.categories.title') }}</h2>
<swiper
class="swiper"
:modules="[Pagination]"
:spaceBetween="24"
:breakpoints="{
200: {
slidesPerView: 3
}
}"
>
<swiper-slide
class="swiper__slide"
v-for="category in categories"
:key="category.node.uuid"
>
<nuxt-link-locale :to="`/catalog/${category.node.slug}`">
<nuxt-img
format="webp"
densities="x1"
:src="category.node.image"
:alt="category.node.name"
loading="lazy"
/>
<div>
<p>{{ category.node.name }}</p>
</div>
</nuxt-link-locale>
</swiper-slide>
</swiper>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {Pagination} from "swiper/modules";
import {Swiper, SwiperSlide} from "swiper/vue";
const {t} = useI18n();
const categoryStore = useCategoryStore();
const categories = computed(() => categoryStore.categories);
</script>
<style lang="scss" scoped>
.categories {
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
&__title {
color: $black;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
}
.swiper {
width: 100%;
&__slide {
& a {
position: relative;
& img {
width: 100%;
}
& div {
background-color: rgba(0, 0, 0, 0.7);
padding: 25px;
& p {
color: $white;
font-size: 20px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
}
}
}
</style>

View file

@ -0,0 +1,81 @@
<template>
<div class="hero">
<div class="container">
<div class="hero__wrapper">
<h2 class="hero__title">{{ t('home.hero.title') }}</h2>
<p class="hero__text">{{ t('home.hero.text') }}</p>
<nuxt-link-locale to="/shop" class="hero__button">{{ t('buttons.shopNow') }}</nuxt-link-locale>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.hero {
position: relative;
background-image: url(/images/heroImage.png);
background-repeat: no-repeat;
-webkit-background-size: cover;
background-size: cover;
background-position: top;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4)
}
&__wrapper {
position: relative;
z-index: 1;
padding-block: 185px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
width: 675px;
margin-inline: auto;
}
&__title {
color: $white;
font-family: "Playfair Display", sans-serif;
font-size: 60px;
font-weight: 700;
letter-spacing: 1px;
}
&__text {
text-align: center;
color: $white;
font-size: 20px;
font-weight: 300;
letter-spacing: -0.5px;
}
&__button {
margin-top: 10px;
background-color: $white;
padding: 10px 35px;
transition: 0.2s;
color: #1a1a1a;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.1px;
@include hover {
background-color: #cbcbcb;
}
}
}
</style>

View file

@ -17,30 +17,14 @@
<icon name="material-symbols:order-approve-rounded" size="20" />
{{ t('profile.orders.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('wishlist') }]"
to="/profile/wishlist"
>
<icon name="mdi:cards-heart-outline" size="20" />
{{ t('profile.wishlist.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('cart') }]"
to="/profile/cart"
>
<icon name="ph:shopping-cart-light" size="20" />
{{ t('profile.cart.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('balance') }]"
to="/profile/balance"
>
<icon name="ic:outline-attach-money" size="20" />
{{ t('profile.balance.title') }}
</nuxt-link-locale>
<!-- <nuxt-link-locale-->
<!-- class="nav__item"-->
<!-- :class="[{ active: route.path.includes('balance') }]"-->
<!-- to="/profile/balance"-->
<!-- >-->
<!-- <icon name="ic:outline-attach-money" size="20" />-->
<!-- {{ t('profile.balance.title') }}-->
<!-- </nuxt-link-locale>-->
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('promocodes') }]"
@ -58,7 +42,7 @@
</template>
<script setup lang="ts">
import {useLogout} from "~/composables/auth";
import {useLogout} from '@composables/auth';
const {t} = useI18n();
const route = useRoute();
@ -69,43 +53,43 @@ const { logout } = useLogout();
<style lang="scss" scoped>
.nav {
position: sticky;
top: 141px;
width: max-content;
top: 116px;
width: 256px;
flex-shrink: 0;
height: fit-content;
&__inner {
background-color: $white;
border-radius: $default_border_radius;
padding-block: 7px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 5px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
border: 1px solid #e5e7eb;
overflow: hidden;
}
&__item {
cursor: pointer;
padding: 7px 30px 7px 10px;
border-left: 2px solid $white;
padding: 16px 24px;
border-right: 2px solid transparent;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 10px;
transition: 0.2s;
color: $accent;
font-size: 18px;
font-weight: 600;
color: #374151;
font-size: 16px;
font-weight: 500;
@include hover {
color: $accentDark;
background-color: #f5f5f5;
border-color: #f5f5f5;
background-color: #f9fafb;
}
&.active {
border-color: $accent;
color: $accentDark;
background-color: rgba($accent, 0.2);
border-right-color: #111827;
color: #111827;
background-color: #f9fafb;
}
}
@ -118,6 +102,7 @@ const { logout } = useLogout();
padding: 7px 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: 0.2s;

View file

@ -0,0 +1,48 @@
<template>
<el-skeleton
class="sk"
animated
>
<template #template>
<el-skeleton-item
variant="image"
class="sk__image"
/>
<el-skeleton-item
variant="p"
class="sk__name"
/>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
const props = defineProps<{
isList?: boolean;
}>();
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
background-color: rgba(255, 255, 255, 0.61);
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
border: 1px solid #e5e7eb;
padding: 23px;
&__image {
width: 100%;
height: 233px;
border-radius: $default_border_radius;
}
&__name {
width: 100%;
height: 20px;
}
}
</style>

View file

@ -11,9 +11,10 @@
class="sk__image"
/>
<div class="sk__content-wrapper">
<div class="sk__content-inner">
<el-skeleton-item
variant="p"
class="sk__price"
class="sk__brand"
/>
<el-skeleton-item
variant="p"
@ -25,19 +26,16 @@
/>
<el-skeleton-item
variant="p"
class="sk__quantity"
class="sk__price"
/>
</div>
</div>
<div class="sk__buttons">
<el-skeleton-item
variant="p"
class="sk__button"
/>
<el-skeleton-item
variant="p"
class="sk__button"
/>
</div>
</div>
</div>
</template>
</el-skeleton>
@ -45,36 +43,41 @@
<script setup lang="ts">
const props = defineProps<{
isList?: boolean
}>()
isList?: boolean;
}>();
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $default_border_radius;
border-radius: 8px;
border: 1px solid #e5e7eb;
background-color: rgba(255, 255, 255, 0.61);
border: 2px solid $accent;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: flex-start;
padding: 10px;
padding: 15px;
& .sk__content {
width: 100%;
display: flex;
flex-direction: row;
gap: 50px;
&-wrapper {
width: 100%;
padding-top: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
&-inner {
align-self: flex-start;
}
}
@ -107,53 +110,53 @@ const props = defineProps<{
&__image {
width: 100%;
height: 220px;
height: 200px;
border-radius: $default_border_radius;
}
&__content {
&-wrapper {
padding: 24px 20px 20px 20px;
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__price {
width: 35%;
height: 25px;
width: 50px;
height: 20px;
}
&__name {
width: 100%;
height: 75px;
height: 17px;
}
&__rating {
margin-block: 4px 10px;
width: 120px;
height: 40px;
height: 16px;
}
&__quantity {
width: 50%;
height: 18px;
&__brand {
width: 75px;
height: 15px;
}
&__buttons {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
margin-top: auto;
padding: 0 20px 20px 20px;
}
&__button {
width: 100%;
height: 34px;
&:last-child {
width: 34px;
flex-shrink: 0;
}
height: 40px;
}
}
</style>

View file

@ -0,0 +1,45 @@
<template>
<el-skeleton class="sk" animated>
<template #template>
<div class="sk__block" v-for="idx in 3" :key="idx">
<el-skeleton-item variant="p" class="sk__title" />
<el-skeleton-item variant="p" class="sk__text" />
<el-skeleton-item variant="p" class="sk__text" />
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&__block {
display: flex;
flex-direction: column;
gap: 10px;
}
&__title {
width: 200px;
height: 30px;
}
&__text {
width: 100%;
height: 21px;
}
}
:deep(.el-skeleton__item) {
--el-skeleton-color: #c9ccd0 !important;
--el-skeleton-to-color: #c3c3c7 !important;
}
</style>

View file

@ -0,0 +1,272 @@
<template>
<div class="filters">
<div class="filters__top">
<h2>{{ t('store.filters.title') }}</h2>
<p @click="resetFilters">{{ t('buttons.clearAll') }}</p>
</div>
<el-collapse v-model="collapse">
<el-collapse-item
name="0"
>
<template #title>
<div class="filters__head">
<h3 class="filters__name" v-text="t('market.filter.price')" />
</div>
</template>
<div class="filters__price">
<div class="filters__price-inputs">
<ui-input
:model-value="priceMinInput"
type="text"
placeholder="Min"
input-mode="decimal"
@update:model-value="(val) => priceMinInput = val"
@blur="handlePriceBlur(priceMinInput, 'min')"
/>
<span class="filters__separator"></span>
<ui-input
:model-value="priceMaxInput"
type="text"
placeholder="Max"
input-mode="decimal"
@update:model-value="(val) => priceMaxInput = val"
@blur="handlePriceBlur(priceMaxInput, 'max')"
/>
</div>
<el-slider
v-model="priceRange"
:min="categoryMin"
:max="categoryMax"
range
:step="0.01"
:format-tooltip="formatPriceTooltip"
/>
</div>
</el-collapse-item>
<el-collapse-item
v-if="filterableAttributes"
v-for="(attribute, idx) in filterableAttributes"
:key="idx"
:name="`${idx + 2}`"
>
<template #title>
<div class="filters__head">
<h3 class="filters__name" v-text="attribute.attributeName" />
</div>
</template>
<ul class="filters__list">
<li
v-for="(value, idx) of attribute.possibleValues"
:key="idx"
class="filters__item"
>
<ui-checkbox
:id="attribute.attributeName + idx"
v-model="selectedMap[attribute.attributeName][value]"
>
{{ value }}
</ui-checkbox>
</li>
</ul>
</el-collapse-item>
</el-collapse>
<!-- <skeletons-market-filters v-else />-->
</div>
</template>
<script setup lang="ts">
import type {IStoreFilters} from '@types';
import {useFilters} from '@composables/store';
import {useRouteQuery} from '@vueuse/router';
const appStore = useAppStore();
const { t } = useI18n();
const props = defineProps<{
filterableAttributes?: IStoreFilters[];
initialMinPrice?: number;
initialMaxPrice?: number;
categoryMinPrice?: number;
categoryMaxPrice?: number;
}>();
const emit = defineEmits(["filterMinPrice", "filterMaxPrice", "update:selected"]);
const attributesQuery = useRouteQuery<string>('attributes', '');
const {
selectedMap,
selectedAllMap,
priceRange,
collapse,
toggleAll,
resetFilters,
applyFilters,
parseAttributesString
} = useFilters(
toRef(props, 'filterableAttributes')
);
const priceMinInput = computed({
get: () => String(priceRange.value[0]),
set: (val: string | number) => {
handlePriceInput(val, 'min');
}
});
const priceMaxInput = computed({
get: () => String(priceRange.value[1]),
set: (val: string | number) => {
handlePriceInput(val, 'max');
}
});
const categoryMin = computed(() => props.categoryMinPrice ?? 0);
const categoryMax = computed(() => props.categoryMaxPrice ?? 50000);
onMounted(() => {
initializeInputs();
});
const initializeInputs = () => {
const min = props.initialMinPrice ?? categoryMin.value;
const max = props.initialMaxPrice ?? categoryMax.value;
priceRange.value = [min, max];
const { min: floatMin, max: floatMax } = getFloatMinMax();
floatRange.value = [floatMin, floatMax];
};
const handlePriceInput = useDebounceFn((value: string | number, type: 'min' | 'max') => {
const strValue = String(value).replace(',', '.');
const numValue = parseFloat(strValue);
if (isNaN(numValue)) return;
if (type === 'min') {
const clamped = Math.max(categoryMin.value, Math.min(numValue, priceRange.value[1]));
priceRange.value = [clamped, priceRange.value[1]];
} else {
const clamped = Math.max(priceRange.value[0], Math.min(numValue, categoryMax.value));
priceRange.value = [priceRange.value[0], clamped];
}
}, 300);
const handlePriceBlur = (value: string | number, type: 'min' | 'max') => {
const strValue = String(value).trim();
if (strValue === '') {
if (type === 'min') {
priceRange.value = [categoryMin.value, priceRange.value[1]];
} else {
priceRange.value = [priceRange.value[0], categoryMax.value];
}
}
};
const debouncedPriceUpdate = useDebounceFn(() => {
emit("filterMinPrice", priceRange.value[0]);
emit("filterMaxPrice", priceRange.value[1]);
}, 300);
const debouncedFilterApply = useDebounceFn(() => {
const picked = applyFilters();
emit('update:selected', picked);
}, 300);
watch(priceRange, () => {
debouncedPriceUpdate();
}, { deep: true });
watch(floatRange, () => {
debouncedFilterApply();
}, { deep: true });
watch(selectedMap, () => {
debouncedFilterApply();
}, { deep: true });
watch(
() => [props.categoryMinPrice, props.categoryMaxPrice, props.initialMinPrice, props.initialMaxPrice],
([catMin, catMax, initMin, initMax]) => {
const min = initMin ?? catMin ?? 0;
const max = initMax ?? catMax ?? 50000;
priceRange.value = [min, max];
},
{ immediate: true }
);
watch(
() => attributesQuery.value,
(attrStr) => {
const initial = parseAttributesString(attrStr);
const hasFloatInQuery = initial['float'] && initial['float'].length === 2;
if (!hasFloatInQuery) {
resetFilters();
}
if (!attrStr) return;
Object.entries(initial).forEach(([key, vals]) => {
if (key === 'float' && vals.length === 2) {
const min = parseFloat(vals[0]);
const max = parseFloat(vals[1]);
floatRange.value = [min, max];
} else {
vals.forEach(val => {
if (selectedMap[key] && selectedMap[key][val] !== undefined) {
selectedMap[key][val] = true;
}
});
if (selectedMap[key]) {
selectedAllMap[key] = Object.values(selectedMap[key]).every(v => v);
}
}
});
},
{ immediate: true }
);
const formatPriceTooltip = (value: number) => `${value.toFixed(2)}`;
</script>
<style scoped lang="scss">
.filter {
width: 290px;
border: 1px solid #e5e7ebFF;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 24px;
padding: 25px;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
& h2 {
color: $black;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.5px;
}
& span {
cursor: pointer;
transition: 0.2s;
color: #4b5563;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
@include hover {
color: #313944;
}
}
}
}
</style>

View file

@ -0,0 +1,218 @@
<template>
<div class="store">
<store-filter
v-if="filters.length"
:filterableAttributes="filters"
:isOpen="showFilter"
@update:selected="onFiltersChange"
@close="showFilter = false"
/>
<div class="store__inner">
<store-top
v-model="orderBy"
@toggle-filter="onFilterToggle"
:isFilters="filters.length > 0"
/>
<client-only>
<div
class="store__list"
:class="[
{ 'store__list-grid': productView === 'grid' },
{ 'store__list-list': productView === 'list' }
]"
>
<cards-product
v-if="products.length && !pending"
v-for="product in products"
:key="product.node.uuid"
:product="product.node"
:isList="productView === 'list'"
/>
<skeletons-cards-product
v-for="idx in 12"
:key="idx"
:isList="productView === 'list'"
/>
</div>
</client-only>
<div class="store__list-observer" ref="observer"></div>
</div>
</div>
</template>
<script setup lang="ts">
import {useFilters, useStore} from '@composables/store';
import {useCategoryBySlug} from '@composables/categories';
import {useBrandBySlug} from '@composables/brands';
import {useDefaultSeo} from '@composables/seo';
const { locale } = useI18n();
const { $appHelpers } = useNuxtApp();
const productView = useCookie<string>(
$appHelpers.COOKIES_PRODUCT_VIEW_KEY as string,
{
default: () => 'grid',
path: '/',
}
);
const categorySlug = useRouteParams<string>('categorySlug');
const brandSlug = useRouteParams<number>('brandSlug');
const attributes = useRouteQuery<string>('attributes', '');
const orderBy = useRouteQuery<string>('orderBy', 'created');
const minPrice = useRouteQuery<number>('minPrice', 0);
const maxPrice = useRouteQuery<number>('maxPrice', 50000);
const observer = ref(null);
const categoryData = categorySlug.value
? await useCategoryBySlug(categorySlug.value)
: { category: ref(null), seoMeta: ref(null), filters: ref([]) };
const brandData = brandSlug.value
? await useBrandBySlug(brandSlug.value)
: { brand: ref(null), seoMeta: ref(null) };
const { category, seoMeta: categorySeoMeta, filters } = categoryData;
const { brand, seoMeta: brandSeoMeta } = brandData;
const seoMeta = computed(() => categorySeoMeta.value || brandSeoMeta.value);
const meta = useDefaultSeo(seoMeta.value || null);
if (meta) {
useSeoMeta({
title: meta.title || $appHelpers.APP_NAME,
description: meta.description || meta.title || $appHelpers.APP_NAME,
ogTitle: meta.og.title || undefined,
ogDescription: meta.og.description || meta.title || $appHelpers.APP_NAME,
ogType: meta.og.type || undefined,
ogUrl: meta.og.url || undefined,
ogImage: meta.og.image || undefined,
twitterCard: meta.twitter.card || undefined,
twitterTitle: meta.twitter.title || undefined,
twitterDescription: meta.twitter.description || undefined,
robots: meta.robots,
});
useHead({
link: [
meta.canonical ? { rel: 'canonical', href: meta.canonical } : {},
].filter(Boolean) as any,
meta: [{ property: 'og:locale', content: locale.value }],
script: meta.jsonLd.map((obj: any) => ({
type: 'application/ld+json',
innerHTML: JSON.stringify(obj),
})),
__dangerouslyDisableSanitizersByTagID: Object.fromEntries(
meta.jsonLd.map((_, i: number) => [`ldjson-${i}`, ['innerHTML']])
),
});
}
watch(
() => category.value,
(cat) => {
if (cat && !useRoute().query.maxPrice) {
maxPrice.value = cat.minMaxPrices.maxPrice;
}
},
{ immediate: true }
);
const { pending, products, pageInfo, variables, getProducts } = useStore({
orderBy: orderBy.value,
categoriesSlugs: categorySlug.value,
productAfter: '',
minPrice: minPrice.value,
maxPrice: maxPrice.value,
brand: brand.value?.name,
attributes: attributes.value
});
await getProducts();
const { buildAttributesString } = useFilters(filters);
const showFilter = ref<boolean>(false);
function onFilterToggle() {
showFilter.value = true;
}
async function onFiltersChange(newFilters: Record<string, string[]>) {
const attrString = buildAttributesString(newFilters);
attributes.value = attrString;
variables.attributes = attrString;
await getProducts();
}
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage && !pending.value) {
variables.productAfter = pageInfo.value.endCursor;
await getProducts();
}
},
);
watch(orderBy, async (newVal) => {
variables.orderBy = newVal || '';
variables.productAfter = '';
products.value = [];
await getProducts();
});
watch(attributes, async (newVal) => {
variables.attributes = newVal;
variables.productAfter = '';
products.value = [];
await getProducts();
});
watch(minPrice, async (newVal) => {
variables.minPrice = newVal || 0;
variables.productAfter = '';
products.value = [];
await getProducts();
});
watch(maxPrice, async (newVal) => {
variables.maxPrice = newVal || 500000;
variables.productAfter = '';
products.value = [];
await getProducts();
});
</script>
<style scoped lang="scss">
.store {
position: relative;
display: flex;
align-items: flex-start;
gap: 32px;
&__inner {
width: 100%;
}
&__list {
margin-top: 50px;
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 25px;
}
&-list {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 20px;
}
&-observer {
background-color: transparent;
width: 100%;
height: 10px;
}
}
}
</style>

View file

@ -0,0 +1,138 @@
<template>
<div class="top" :class="[{ filters: isFilters }]">
<div class="top__sorting">
<p>{{ t('store.sorting') }}</p>
<client-only>
<el-select
v-model="select"
size="large"
style="width: 240px"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</client-only>
</div>
<div class="top__view">
<button
class="top__view-button"
:class="{ active: productView === 'list' }"
@click="setView('list')"
>
<icon name="material-symbols:view-list-sharp" size="16" />
</button>
<button
class="top__view-button"
:class="{ active: productView === 'grid' }"
@click="setView('grid')"
>
<icon name="material-symbols:grid-view" size="16" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n();
const { $appHelpers } = useNuxtApp();
const props = defineProps<{
modelValue: string;
isFilters: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'toggle-filter'): void;
}>();
const productView = useCookie($appHelpers.COOKIES_PRODUCT_VIEW_KEY as string);
function setView(view: 'list' | 'grid') {
productView.value = view;
}
const select = ref(props.modelValue || 'created');
const options = [
{
value: 'created',
label: 'New',
},
{
value: 'rating',
label: 'Rating',
},
{
value: 'price',
label: 'Сheap first',
},
{
value: '-price',
label: 'Expensive first',
}
];
watch(select, value => {
emit('update:modelValue', value);
});
</script>
<style scoped lang="scss">
.top {
margin-bottom: 20px;
width: 100%;
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 40px;
border-radius: 8px;
border: 1px solid #e5e7eb;
padding: 16px;
&.filters {
justify-content: flex-end;
}
&__sorting {
display: flex;
align-items: center;
gap: 20px;
& p {
font-weight: 400;
font-size: 14px;
letter-spacing: -0.5px;
color: #4b5563;
}
}
&__view {
display: flex;
align-items: center;
border-radius: 4px;
border: 1px solid #e5e7eb;
&-button {
cursor: pointer;
background-color: transparent;
display: grid;
place-items: center;
padding: 10px;
transition: 0.2s;
color: #000;
@include hover {
background-color: rgba(0, 0, 0, 0.1);
}
&.active {
background-color: rgba(0, 0, 0, 0.1);
}
}
}
}
</style>

View file

@ -21,15 +21,17 @@
</template>
<script setup lang="ts">
import {useBreadcrumbs} from "~/composables/breadcrumbs";
import {useBreadcrumbs} from '@composables/breadcrumbs';
const { breadcrumbs } = useBreadcrumbs()
const { breadcrumbs } = useBreadcrumbs();
</script>
<style scoped lang="scss">
.breadcrumbs {
background-color: #f8f8f8;
padding: 15px 250px 15px 50px;
line-height: 140%;
border-bottom: 1px solid #e5e7eb;
&__link {
cursor: pointer !important;

View file

@ -0,0 +1,90 @@
<template>
<button
class="button"
:disabled="isDisabled"
:class="[
{ active: isLoading },
{ secondary: style === 'secondary' }
]"
:type="type"
>
<ui-loader class="button__loader" v-if="isLoading" />
<slot v-else />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
type: 'submit' | 'button';
isDisabled?: boolean;
isLoading?: boolean;
style?: string;
}>();
</script>
<style lang="scss" scoped>
.button {
position: relative;
width: 100%;
cursor: pointer;
flex-shrink: 0;
transition: 0.2s;
background-color: #111827;
border-radius: 8px;
padding-block: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
z-index: 1;
color: $white;
text-align: center;
font-size: 16px;
font-weight: 500;
&.secondary {
background-color: $white;
border: 1px solid #d1d5db;
color: #374151;
@include hover {
background-color: #d1d5db;
}
&.active {
background-color: #d1d5db;
}
&:disabled {
cursor: not-allowed;
background-color: #9a9a9a;
}
&:disabled:hover, &.active {
background-color: #9a9a9a;
}
}
@include hover {
background-color: #222c41;
}
&.active {
background-color: #222c41;
}
&:disabled {
cursor: not-allowed;
background-color: #0a0f1a;
}
&:disabled:hover, &.active {
background-color: #0a0f1a;
}
&__loader {
margin-block: 4px;
}
}
</style>

View file

@ -16,12 +16,12 @@
<script setup lang="ts">
const props = defineProps<{
id?: string,
modelValue: boolean,
isAccent?: boolean
id?: string;
modelValue: boolean;
isAccent?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'update:modelValue', v: boolean): void;
}>();
function onChange(e: Event) {
@ -57,10 +57,10 @@ function onChange(e: Event) {
&__block {
cursor: pointer;
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid $black;
border-radius: $default_border_radius;
width: 16px;
height: 16px;
border: 0.5px solid $black;
border-radius: 1px;
position: relative;
&::after {
@ -71,7 +71,7 @@ function onChange(e: Event) {
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background-color: $accent;
background-color: $black;
border-radius: 2px;
opacity: 0;
}
@ -79,10 +79,10 @@ function onChange(e: Event) {
&__label {
cursor: pointer;
color: #2B2B2B;
font-size: 12px;
font-weight: 500;
line-height: 16px;
color: #374151;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}

View file

@ -1,6 +1,8 @@
<template>
<div class="block">
<div class="block__inner">
<div class="block__wrapper">
<label v-if="label" class="block__label">{{ label }}</label>
<div class="block__wrapper-inner">
<input
:placeholder="placeholder"
:type="isPasswordVisible"
@ -9,7 +11,6 @@
@keydown="numberOnly ? onlyNumbersKeydown($event) : null"
class="block__input"
:inputmode="inputMode || 'text'"
:autocapitalize="type === 'input' ? 'off' : 'on'"
>
<button
@click.prevent="togglePasswordVisible"
@ -20,6 +21,7 @@
<icon v-else name="mdi:eye-outline" />
</button>
</div>
</div>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
@ -31,17 +33,18 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
type: string,
placeholder: string,
modelValue?: string | number,
rules?: Rule[],
numberOnly?: boolean,
inputMode?: "text" | "email" | "search" | "tel" | "url" | "none" | "numeric" | "decimal"
type: string;
placeholder: string;
modelValue?: string | number;
rules?: Rule[];
label?: string;
numberOnly?: boolean;
inputMode?: "text" | "email" | "search" | "tel" | "url" | "none" | "numeric" | "decimal";
}>();
const isPasswordVisible = ref(props.type);
const isValid = ref(true);
const errorMessage = ref('');
const isPasswordVisible = ref<string>(props.type);
const isValid = ref<boolean>(true);
const errorMessage = ref<string>('');
function togglePasswordVisible() {
isPasswordVisible.value =
@ -93,25 +96,41 @@ function onInput(e: Event) {
gap: 10px;
position: relative;
&__inner {
&__wrapper {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
&-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
}
&__label {
color: #374151;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
}
&__input {
width: 100%;
padding: 6px 12px;
border: 1px solid #e0e0e0;
border-radius: $default_border_radius;
padding: 14px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: $white;
color: #1f1f1f;
font-size: 12px;
font-size: 16px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.5px;
&::placeholder {
color: #575757;
color: #868686;
}
}

View file

@ -6,6 +6,7 @@
:class="[{ active: isSwitcherVisible }]"
>
<client-only>
<!-- <icon name="fluent:globe-20-filled" size="20" />-->
<nuxt-img
format="webp"
densities="x1"
@ -42,8 +43,8 @@
</template>
<script setup lang="ts">
import {onClickOutside} from "@vueuse/core";
import {useLanguageSwitch} from "@/composables/languages/index.js";
import {onClickOutside} from '@vueuse/core';
import {useLanguageSwitch} from '@composables/languages';
const languageStore = useLanguageStore();
@ -70,7 +71,7 @@ const uiSwitchLanguage = (localeCode: string) => {
.switcher {
position: relative;
z-index: 1;
width: 52px;
width: 44px;
flex-shrink: 0;
&__button {
@ -78,18 +79,16 @@ const uiSwitchLanguage = (localeCode: string) => {
cursor: pointer;
display: flex;
gap: 5px;
border: 1px solid $accent;
background-color: #ddd9ef;
padding: 5px;
border-radius: $default_border_radius;
transition: 0.2s;
@include hover {
background-color: $accent;
background-color: rgba(0, 0, 0, 0.2);
}
&.active {
background-color: $accent;
background-color: rgba(0, 0, 0, 0.2);
}
& img {
@ -126,19 +125,19 @@ const uiSwitchLanguage = (localeCode: string) => {
&-button {
width: 100%;
cursor: pointer;
padding: 5px 8px;
background-color: #ddd9ef;
padding: 3px 5px;
background-color: #cccccc;
transition: 0.1s;
&:first-child {
padding-top: 10px;
padding-top: 5px;
}
&:last-child {
padding-bottom: 10px;
padding-bottom: 5px;
}
&:hover {
background-color: $accent;
@include hover {
background-color: #b2b2b2;
}
}
}

View file

@ -0,0 +1,56 @@
<template>
<span
class="link"
:class="{ 'link--clickable': isClickable }"
@click="handleClick"
>
<slot></slot>
</span>
</template>
<script setup lang="ts">
interface Props {
routePath?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
click: [];
}>();
const router = useRouter();
const isClickable = computed(() => !!props.routePath);
const handleClick = () => {
if (props.routePath) {
if (import.meta.client) {
router.push(props.routePath);
}
} else {
emit('click');
}
};
</script>
<style lang="scss" scoped>
.link {
width: fit-content;
display: flex;
align-items: center;
gap: 5px;
transition: 0.2s;
cursor: pointer;
color: #111827;
font-size: 14px;
font-weight: 400;
&--clickable {
cursor: pointer;
}
@include hover {
color: #364565;
}
}
</style>

View file

@ -1,5 +1,7 @@
<template>
<div class="search">
<div class="container">
<div class="search__inner">
<div
@click="toggleSearch(true)"
class="search__wrapper"
@ -62,10 +64,12 @@
/>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSearchUI } from "@/composables/search";
import { useSearchUI } from '@composables/search';
const {t} = useI18n();
const router = useRouter();
@ -106,19 +110,23 @@ function goTo(category: string, item: any) {
let path = "/";
switch (category) {
case "products":
case "products": {
path = `/product/${item.slug}`;
break;
case "categories":
}
case "categories": {
path = `/catalog/${item.slug}`;
break;
case "brands":
path = `/brand/${item.uuid}`;
}
case "brands": {
path = `/brand/${item.slug}`;
break;
case "posts":
}
case "posts": {
path = "/";
break;
}
}
toggleSearch(false);
router.push(path);
@ -127,10 +135,16 @@ function goTo(category: string, item: any) {
<style lang="scss" scoped>
.search {
width: 100%;
background-color: $white;
&__inner {
padding-block: 10px;
width: 100%;
position: relative;
z-index: 1;
height: 45px;
height: 100%;
}
&__bg {
background-color: rgba(0, 0, 0, 0.2);
@ -164,7 +178,7 @@ function goTo(category: string, item: any) {
&__form {
width: 100%;
height: 45px;
height: 40px;
position: relative;
& input {

View file

@ -1,11 +1,14 @@
<template>
<div class="block">
<div class="block__inner">
<label v-if="label" class="block__label">{{ label }}</label>
<textarea
:placeholder="placeholder"
:value="modelValue"
@input="onInput"
class="block__textarea"
/>
</div>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
@ -17,9 +20,10 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
placeholder: string,
modelValue?: string,
rules?: Rule[]
placeholder: string;
modelValue?: string;
rules?: Rule[];
label?: string;
}>();
const isValid = ref(true);
@ -53,6 +57,21 @@ const onInput = (e: Event) => {
gap: 10px;
position: relative;
&__inner {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
&__label {
color: #374151;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
}
&__textarea {
width: 100%;
height: 150px;

View file

@ -0,0 +1,33 @@
<template>
<button
@click="toggleTheme"
class="theme-toggle"
:aria-label="`Переключить на ${theme === 'light' ? 'тёмную' : 'светлую'} тему`"
>
<Icon v-if="theme === 'light'" name="line-md:moon-alt-loop" size="24" />
<Icon v-else name="line-md:sunny-outline-loop" size="24" />
</button>
</template>
<script setup lang="ts">
import { useThemes } from '@composables/themes';
const { theme, toggleTheme } = useThemes();
</script>
<style scoped lang="scss">
.theme-toggle {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: $default_border_radius;
padding: 8px;
cursor: pointer;
color: var(--color-text-primary);
transition: all 0.3s ease;
&:hover {
background: var(--color-surface-hover);
border-color: var(--color-accent);
}
}
</style>

View file

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

View file

@ -0,0 +1,80 @@
import { DEFAULT_LOCALE } from '@appConstants';
import { useLocaleRedirect } from '@composables/languages';
import { useNotification } from '@composables/notification';
import { useUserBaseData } from '@composables/user';
import { LOGIN } from '@graphql/mutations/auth';
import type { ILoginResponse } from '@types';
export function useLogin() {
const { t } = useI18n();
const userStore = useUserStore();
const localePath = useLocalePath();
const { $appHelpers } = useNuxtApp();
const { checkAndRedirect } = useLocaleRedirect();
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieLocale = useCookie($appHelpers.COOKIES_LOCALE_KEY, {
default: () => DEFAULT_LOCALE,
path: '/',
});
const { mutate, loading, error } = useMutation<ILoginResponse>(LOGIN);
async function login(email: string, password: string, isStayLogin: boolean) {
const result = await mutate({
email,
password,
});
const authData = result?.data?.obtainJwtToken;
if (!authData) return;
if (isStayLogin && authData.refreshToken) {
cookieRefresh.value = authData.refreshToken;
}
userStore.setUser(authData.user);
cookieAccess.value = authData.accessToken;
navigateTo(localePath('/'));
useNotification({
message: t('popup.success.login'),
type: 'success',
});
if (authData.user.language !== cookieLocale.value) {
await checkAndRedirect(authData.user.language);
}
await useUserBaseData(authData.user.email);
}
watch(error, (err) => {
if (!err) return;
console.error('useLogin error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
loading,
login,
};
}

View file

@ -0,0 +1,33 @@
export function useLogout() {
const userStore = useUserStore();
const cartStore = useCartStore();
const wishlistStore = useWishlistStore();
const router = useRouter();
const { $appHelpers } = useNuxtApp();
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
default: () => '',
path: '/',
});
async function logout() {
userStore.setUser(null);
cartStore.setCurrentOrders(null);
wishlistStore.setWishlist(null);
cookieRefresh.value = '';
cookieAccess.value = '';
await router.push({
path: '/',
});
}
return {
logout,
};
}

View file

@ -0,0 +1,57 @@
import { useNotification } from '@composables/notification';
import { NEW_PASSWORD } from '@graphql/mutations/auth.js';
import type { INewPasswordResponse } from '@types';
export function useNewPassword() {
const { t } = useI18n();
const router = useRouter();
const localePath = useLocalePath();
const token = useRouteQuery('token', '');
const uid = useRouteQuery('uid', '');
const { mutate, loading, error } = useMutation<INewPasswordResponse>(NEW_PASSWORD);
async function newPassword(password: string, confirmPassword: string) {
const result = await mutate({
password,
confirmPassword,
token: token.value,
uid: uid.value,
});
if (result?.data?.confirmResetPassword.success) {
useNotification({
message: t('popup.success.newPassword'),
type: 'success',
});
await router.push({
path: '/',
});
navigateTo(localePath('/'));
}
}
watch(error, (err) => {
if (!err) return;
console.error('useNewPassword error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
newPassword,
loading,
};
}

View file

@ -0,0 +1,46 @@
import { useNotification } from '@composables/notification';
import { RESET_PASSWORD } from '@graphql/mutations/auth.js';
import type { IPasswordResetResponse } from '@types';
export function usePasswordReset() {
const { t } = useI18n();
const appStore = useAppStore();
const { mutate, loading, error } = useMutation<IPasswordResetResponse>(RESET_PASSWORD);
async function resetPassword(email: string) {
const result = await mutate({
email,
});
if (result?.data?.resetPassword.success) {
useNotification({
message: t('popup.success.reset'),
type: 'success',
});
appStore.unsetActiveAuthState();
}
}
watch(error, (err) => {
if (!err) return;
console.error('usePasswordReset error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
resetPassword,
loading,
};
}

View file

@ -0,0 +1,122 @@
import { DEFAULT_LOCALE } from '@appConstants';
import { useLogout } from '@composables/auth';
import { useLocaleRedirect } from '@composables/languages';
import { useNotification } from '@composables/notification';
import { useUserBaseData } from '@composables/user';
import { REFRESH } from '@graphql/mutations/auth';
export function useRefresh() {
const { t } = useI18n();
const router = useRouter();
const localePath = useLocalePath();
const userStore = useUserStore();
const { $appHelpers } = useNuxtApp();
const { checkAndRedirect } = useLocaleRedirect();
const { logout } = useLogout();
const { mutate, loading, error } = useMutation(REFRESH);
function isTokenInvalidError(error: unknown): boolean {
if (isGraphQLError(error)) {
const message = error.graphQLErrors?.[0]?.message?.toLowerCase() || '';
return (
message.includes('invalid refresh token') ||
message.includes('blacklist') ||
message.includes('expired') ||
message.includes('revoked')
);
}
return false;
}
async function refresh() {
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieLocale = useCookie($appHelpers.COOKIES_LOCALE_KEY, {
default: () => DEFAULT_LOCALE,
path: '/',
});
if (!cookieRefresh.value) {
return;
}
try {
const result = await mutate({
refreshToken: cookieRefresh.value,
});
const data = result?.data?.refreshJwtToken;
if (!data) {
return;
}
userStore.setUser(data.user);
cookieRefresh.value = data.refreshToken;
cookieAccess.value = data.accessToken;
if (data.user.language !== cookieLocale.value) {
await checkAndRedirect(data.user.language);
}
await useUserBaseData(data.user.email);
} catch (err) {
if (isTokenInvalidError(err)) {
await logout();
await router.push(localePath('/'));
return;
}
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else if (err instanceof Error) {
message = err.message;
} else if (typeof err === 'string') {
message = err;
}
useNotification({
message,
type: 'error',
title: t('popup.errors.main'),
});
}
}
watch(error, async (err) => {
if (!err) return;
if (isTokenInvalidError(err)) {
await logout();
await router.push(localePath('/'));
return;
}
console.error('useRefresh error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
refresh,
loading,
};
}

View file

@ -0,0 +1,83 @@
import { useNotification } from '@composables/notification';
import { useMailClient } from '@composables/utils';
import { REGISTER } from '@graphql/mutations/auth.js';
import type { IRegisterResponse } from '@types';
interface IRegisterArguments {
firstName: string;
lastName: string;
phoneNumber: string;
email: string;
password: string;
confirmPassword: string;
referrer: string;
isSubscribed: boolean;
}
export function useRegister() {
const { t } = useI18n();
const appStore = useAppStore();
const { mailClientUrl, detectMailClient, openMailClient } = useMailClient();
const { mutate, loading, error } = useMutation<IRegisterResponse>(REGISTER);
async function register(payload: IRegisterArguments) {
const result = await mutate({
firstName: payload.firstName,
lastName: payload.lastName,
phoneNumber: payload.phoneNumber,
email: payload.email,
password: payload.password,
confirmPassword: payload.confirmPassword,
referrer: payload.referrer,
isSubscribed: payload.isSubscribed
});
if (result?.data?.createUser?.success) {
detectMailClient(payload.email);
useNotification({
message: h('div', [
h('p', t('popup.success.register')),
mailClientUrl.value
? h(
'button',
{
class: 'el-notification__button',
onClick: () => {
openMailClient();
},
},
t('buttons.goEmail'),
)
: '',
]),
type: 'success',
});
appStore.unsetActiveAuthState();
}
}
watch(error, (err) => {
if (!err) return;
console.error('useRegister error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
useNotification({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
register,
loading,
};
}

View file

@ -0,0 +1,2 @@
export * from './useBrandBySlug';
export * from './useBrands';

View file

@ -0,0 +1,21 @@
import { GET_BRAND_BY_SLUG } from '@graphql/queries/standalone/brands';
import type { IBrandsResponse } from '@types';
export async function useBrandBySlug(slug: string) {
const brand = computed(() => data.value?.brands.edges[0]?.node ?? null);
const { data, error } = await useAsyncQuery<IBrandsResponse>(GET_BRAND_BY_SLUG, {
slug,
});
watch(error, (err) => {
if (err) {
console.error('useBrandsBySlug error:', err);
}
});
return {
brand,
seoMeta: computed(() => brand.value?.seoMeta),
};
}

View file

@ -0,0 +1,83 @@
import { GET_BRANDS } from '@graphql/queries/standalone/brands';
import type { IBrand, IBrandsResponse } from '@types';
interface IBrandArgs {
brandAfter?: string;
brandOrderBy?: string;
brandName?: string;
brandSearch?: string;
}
interface IBrandVars {
brandFirst: number;
brandAfter?: string;
brandOrderBy?: string;
brandName?: string;
brandSearch?: string;
}
export function useBrands(args: IBrandArgs = {}) {
const variables = reactive<IBrandVars>({
brandFirst: 45,
brandAfter: args.brandAfter,
brandOrderBy: args.orderBy,
brandName: args.brandName,
brandSearch: args.brandSearch,
});
const pending = ref(false);
const brands = ref<IBrand[]>([]);
const pageInfo = ref<{
hasNextPage: boolean;
endCursor: string;
}>({
hasNextPage: false,
endCursor: ''
});
const error = ref<string | null>(null);
const getBrands = async (): Promise<void> => {
pending.value = true;
const queryVariables = {
brandFirst: variables.first,
brandAfter: variables.brandAfter || undefined,
brandOrderBy: variables.orderBy || undefined,
brandName: variables.brandName || undefined,
brandSearch: variables.brandSearch || undefined,
};
const { data, error: mistake } = await useAsyncQuery<IBrandsResponse>(GET_BRANDS, queryVariables);
if (data.value?.brands?.edges) {
pageInfo.value = data.value?.brands.pageInfo;
if (variables.brandAfter) {
brands.value = [
...brands.value,
...data.value.brands.edges,
];
} else {
brands.value = data.value?.brands.edges;
}
}
if (mistake.value) {
error.value = mistake.value;
}
pending.value = false;
};
watch(error, (e) => {
if (e) console.error('useBrands error:', e);
});
return {
pending,
brands,
pageInfo,
variables,
getBrands,
};
}

View file

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

View file

@ -0,0 +1,90 @@
import type { ICategory, IProduct } from '@types';
interface Crumb {
text: string;
link?: string;
}
function findCategoryPath(
nodes: ICategory[],
targetSlug: string,
path: ICategory[] = [],
): ICategory[] | null {
for (const node of nodes) {
const newPath = [
...path,
node,
];
if (node.slug === targetSlug) {
return newPath;
}
if (node.children?.length) {
const found = findCategoryPath(node.children, targetSlug, newPath);
if (found) {
return found;
}
}
}
return null;
}
export function useBreadcrumbs() {
const { t } = useI18n();
const route = useRoute();
const pageTitle = useState<string>('pageTitle');
const categoryStore = useCategoryStore();
const product = useState<IProduct | null>('currentProduct');
const breadcrumbs = computed<Crumb[]>(() => {
const crumbs: Crumb[] = [
{
text: t('breadcrumbs.home'),
link: '/',
},
];
if (route.path.includes('/catalog') || route.path.includes('/product')) {
crumbs.push({
text: t('breadcrumbs.catalog'),
link: '/catalog',
});
let categorySlug: string | undefined;
if (route.path.includes('/catalog')) {
categorySlug = route.params.categorySlug as string;
} else if (route.path.includes('/product')) {
categorySlug = product.value?.category?.slug;
}
if (categorySlug) {
const roots = categoryStore.categories.map((e) => e.node);
const path = findCategoryPath(roots, categorySlug);
path?.forEach((node) => {
crumbs.push({
text: node.name,
link: `/catalog/${node.slug}`,
});
});
}
if (route.path.includes('/product') && product.value) {
crumbs.push({
text: product.value.name,
});
}
} else {
const routeNameWithoutLocale = String(route.name).split('___')[0];
crumbs.push({
text: pageTitle.value || t(`breadcrumbs.${routeNameWithoutLocale}`),
});
}
return crumbs;
});
console.log(breadcrumbs.value)
return {
breadcrumbs,
};
}

View file

@ -0,0 +1,4 @@
export * from './useCategories';
export * from './useCategoryBySlug';
export * from './useCategoryBySlugSeo';
export * from './useCategoryTags';

View file

@ -0,0 +1,39 @@
import { GET_CATEGORIES } from '@graphql/queries/standalone/categories';
import type { ICategoriesResponse } from '@types';
export async function useCategories() {
const categoryStore = useCategoryStore();
const { locale } = useI18n();
const getCategories = async (cursor?: string): Promise<void> => {
const { data, error } = await useAsyncQuery<ICategoriesResponse>(GET_CATEGORIES, {
level: 0,
whole: true,
categoryAfter: cursor,
});
if (!error.value && data.value?.categories.edges) {
if (!cursor) {
categoryStore.setCategories(data.value.categories.edges);
} else {
categoryStore.addCategories(data.value.categories.edges);
}
const pageInfo = data.value.categories.pageInfo;
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
await getCategories(pageInfo.endCursor);
}
}
if (error.value) console.error('useCategories error:', error.value);
};
watch(locale, async () => {
categoryStore.setCategories([]);
await getCategories();
});
return {
getCategories,
};
}

View file

@ -0,0 +1,26 @@
import { GET_CATEGORY_BY_SLUG } from '@graphql/queries/standalone/categories';
import type { ICategoryBySlugResponse } from '@types';
export async function useCategoryBySlug(slug: string) {
const { data, error } = await useAsyncQuery<ICategoryBySlugResponse>(GET_CATEGORY_BY_SLUG, {
categorySlug: slug,
});
const category = computed(() => data.value?.categories.edges[0]?.node ?? null);
const filters = computed(() => {
if (!category.value) return [];
return category.value.filterableAttributes.filter((attr) => attr.possibleValues.length > 0);
});
watch(error, (err) => {
if (err) {
console.error('useCategoryBySlug error:', err);
}
});
return {
category,
seoMeta: computed(() => category.value?.seoMeta),
filters,
};
}

View file

@ -0,0 +1,20 @@
import { GET_CATEGORY_BY_SLUG_SEO } from '@graphql/queries/standalone/categories';
import type { ICategoryBySlugSeoResponse } from '@types';
export async function useCategoryBySlugSeo(slug: string) {
const category = computed(() => data.value?.categories.edges[0]?.node ?? null);
const { data, error } = await useAsyncQuery<ICategoryBySlugSeoResponse>(GET_CATEGORY_BY_SLUG_SEO, {
categorySlug: slug,
});
watch(error, (err) => {
if (err) {
console.error('useCategoryBySlugSeo error:', err);
}
});
return {
seoMeta: computed(() => category.value?.seoMeta),
};
}

View file

@ -0,0 +1,18 @@
import { GET_CATEGORY_TAGS } from '@graphql/queries/standalone/categories';
import type { ICategoryTagsResponse } from '@types';
export async function useCategoryTags() {
const tags = computed(() => data.value?.categoryTags?.edges ?? []);
const { data, error } = await useAsyncQuery<ICategoryTagsResponse>(GET_CATEGORY_TAGS);
watch(error, (err) => {
if (err) {
console.error('useCategoryTags error:', err);
}
});
return {
tags,
};
}

View file

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

View file

@ -0,0 +1,20 @@
import { GET_COMPANY_INFO } from '@graphql/queries/standalone/company';
import type { ICompanyResponse } from '@types';
export async function useCompanyInfo() {
const companyStore = useCompanyStore();
const { data, error } = await useAsyncQuery<ICompanyResponse>(GET_COMPANY_INFO);
if (data.value?.parameters) {
companyStore.setCompanyInfo(data.value.parameters);
}
watch(error, (err) => {
if (err) {
console.error('useCompanyInfo error:', err);
}
});
return {};
}

View file

@ -0,0 +1,18 @@
import { GET_PAYMENTS_LIMITS } from '@graphql/queries/standalone/company';
import type { IPaymentsLimitsResponse } from '@types';
export function usePaymentLimits() {
const { data, error } = useAsyncQuery<IPaymentsLimitsResponse>(GET_PAYMENTS_LIMITS);
const paymentMin = computed(() => data.value?.paymentsLimits?.minAmount ?? 0);
const paymentMax = computed(() => data.value?.paymentsLimits?.maxAmount ?? 500);
watch(error, (e) => {
if (e) console.error('usePaymentLimits error:', e);
});
return {
paymentMin,
paymentMax,
};
}

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