2026.1
This commit is contained in:
parent
0429b62ba1
commit
1e1d0ef397
402 changed files with 25377 additions and 22031 deletions
24
storefront/.gitignore
vendored
Normal file
24
storefront/.gitignore
vendored
Normal 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
|
||||
20
storefront/app.config.d.ts
vendored
Normal file
20
storefront/app.config.d.ts
vendored
Normal 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 {};
|
||||
|
|
@ -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>
|
||||
15
storefront/app/app.config.ts
Normal file
15
storefront/app/app.config.ts
Normal 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
206
storefront/app/app.vue
Normal 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>
|
||||
BIN
storefront/app/assets/fonts/Inter/Inter-Black.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Black.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-Bold.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-ExtraBold.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-Medium.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-Regular.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-SemiBold.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
108
storefront/app/assets/styles/global/fonts.scss
Normal file
108
storefront/app/assets/styles/global/fonts.scss
Normal 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;
|
||||
}
|
||||
45
storefront/app/assets/styles/global/variables.scss
Normal file
45
storefront/app/assets/styles/global/variables.scss
Normal 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);
|
||||
117
storefront/app/assets/styles/main.scss
Normal file
117
storefront/app/assets/styles/main.scss
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
18
storefront/app/assets/styles/ui/rating.scss
Normal file
18
storefront/app/assets/styles/ui/rating.scss
Normal 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;
|
||||
}
|
||||
7
storefront/app/assets/styles/ui/select.scss
Normal file
7
storefront/app/assets/styles/ui/select.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@use "../global/variables" as *;
|
||||
|
||||
.el-select__wrapper {
|
||||
height: 36px !important;
|
||||
min-height: 36px !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
|
@ -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>
|
||||
135
storefront/app/components/base/footer/index.vue
Normal file
135
storefront/app/components/base/footer/index.vue
Normal 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>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
v-for="category in categories"
|
||||
:key="category.node.uuid"
|
||||
:class="[{ active: category.node.uuid === activeCategory.uuid }]"
|
||||
@click="setActiveCategory( category.node)"
|
||||
@click="setActiveCategory(category.node)"
|
||||
>
|
||||
{{ category.node.name }}
|
||||
</p>
|
||||
|
|
@ -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();
|
||||
355
storefront/app/components/base/header/index.vue
Normal file
355
storefront/app/components/base/header/index.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
storefront/app/components/cards/post.vue
Normal file
63
storefront/app/components/cards/post.vue
Normal 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>
|
||||
|
|
@ -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,99 +57,73 @@
|
|||
</client-only>
|
||||
</div>
|
||||
</nuxt-link-locale>
|
||||
<div class="card__content">
|
||||
<div class="card__price">{{ product.price }} {{ CURRENCY }}</div>
|
||||
</div>
|
||||
<div class="card__content">
|
||||
<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 class="card__price">{{ product.price }} $</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__bottom">
|
||||
<div class="card__bottom-inner">
|
||||
<ui-button
|
||||
class="card__bottom-button"
|
||||
v-if="isProductInCart"
|
||||
@click="overwriteOrder({
|
||||
<div class="card__bottom">
|
||||
<div class="card__bottom-inner">
|
||||
<div class="tools" v-if="isProductInCart">
|
||||
<button
|
||||
class="tools__item tools__item-button"
|
||||
@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({
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span class="tools__item tools__item-count" v-text="'X' + productInCartQuantity" />
|
||||
<button
|
||||
class="tools__item tools__item-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'),
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ui-button
|
||||
v-else
|
||||
class="card__bottom-button"
|
||||
@click="overwriteOrder({
|
||||
type: '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 />
|
||||
:type="'button'"
|
||||
:isLoading="addLoading"
|
||||
>
|
||||
{{ t('buttons.addToCart') }}
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tools" v-if="isToolsVisible && isProductInCart">
|
||||
<button
|
||||
class="tools__item tools__item-button"
|
||||
@click="overwriteOrder({
|
||||
type: 'remove',
|
||||
productUuid: product.uuid,
|
||||
productName: product.name
|
||||
})"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span class="tools__item tools__item-count" v-text="'X' + productInCartQuantity" />
|
||||
<button
|
||||
class="tools__item tools__item-button"
|
||||
@click="overwriteOrder({
|
||||
type: 'add',
|
||||
productUuid: product.uuid,
|
||||
productName: product.name
|
||||
})"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</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;
|
||||
}
|
||||
|
||||
317
storefront/app/components/demo/settings.vue
Normal file
317
storefront/app/components/demo/settings.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
187
storefront/app/components/demo/ui/checkbox.vue
Normal file
187
storefront/app/components/demo/ui/checkbox.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 (
|
||||
148
storefront/app/components/forms/login.vue
Normal file
148
storefront/app/components/forms/login.vue
Normal 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>
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
<template>
|
||||
<form @submit.prevent="handleReset()" class="form">
|
||||
<h2 class="form__title">{{ t('forms.newPassword.title') }}</h2>
|
||||
<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>
|
||||
244
storefront/app/components/forms/register.vue
Normal file
244
storefront/app/components/forms/register.vue
Normal 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>
|
||||
115
storefront/app/components/forms/reset-password.vue
Normal file
115
storefront/app/components/forms/reset-password.vue
Normal 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>
|
||||
|
|
@ -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();
|
||||
85
storefront/app/components/home/ad.vue
Normal file
85
storefront/app/components/home/ad.vue
Normal 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>
|
||||
57
storefront/app/components/home/blog.vue
Normal file
57
storefront/app/components/home/blog.vue
Normal 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>
|
||||
48
storefront/app/components/home/brands.vue
Normal file
48
storefront/app/components/home/brands.vue
Normal 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>
|
||||
93
storefront/app/components/home/categories.vue
Normal file
93
storefront/app/components/home/categories.vue
Normal 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>
|
||||
81
storefront/app/components/home/hero.vue
Normal file
81
storefront/app/components/home/hero.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
||||
48
storefront/app/components/skeletons/cards/brand.vue
Normal file
48
storefront/app/components/skeletons/cards/brand.vue
Normal 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>
|
||||
|
|
@ -11,70 +11,73 @@
|
|||
class="sk__image"
|
||||
/>
|
||||
<div class="sk__content-wrapper">
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__price"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__name"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__rating"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__quantity"
|
||||
/>
|
||||
<div class="sk__content-inner">
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__brand"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__name"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__rating"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__price"
|
||||
/>
|
||||
</div>
|
||||
<div class="sk__buttons">
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sk__buttons">
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__button"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
variant="p"
|
||||
class="sk__button"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isList?: boolean
|
||||
}>()
|
||||
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>
|
||||
45
storefront/app/components/skeletons/docs.vue
Normal file
45
storefront/app/components/skeletons/docs.vue
Normal 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>
|
||||
272
storefront/app/components/store/filter.vue
Normal file
272
storefront/app/components/store/filter.vue
Normal 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>
|
||||
218
storefront/app/components/store/index.vue
Normal file
218
storefront/app/components/store/index.vue
Normal 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>
|
||||
138
storefront/app/components/store/top.vue
Normal file
138
storefront/app/components/store/top.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
90
storefront/app/components/ui/button.vue
Normal file
90
storefront/app/components/ui/button.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,24 +1,26 @@
|
|||
<template>
|
||||
<div class="block">
|
||||
<div class="block__inner">
|
||||
<input
|
||||
:placeholder="placeholder"
|
||||
:type="isPasswordVisible"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
@keydown="numberOnly ? onlyNumbersKeydown($event) : null"
|
||||
class="block__input"
|
||||
:inputmode="inputMode || 'text'"
|
||||
:autocapitalize="type === 'input' ? 'off' : 'on'"
|
||||
>
|
||||
<button
|
||||
@click.prevent="togglePasswordVisible"
|
||||
class="block__eyes"
|
||||
v-if="type === 'password' && String(modelValue).length > 0"
|
||||
>
|
||||
<icon v-if="isPasswordVisible === 'password'" name="mdi:eye-off-outline" />
|
||||
<icon v-else name="mdi:eye-outline" />
|
||||
</button>
|
||||
<div class="block__wrapper">
|
||||
<label v-if="label" class="block__label">{{ label }}</label>
|
||||
<div class="block__wrapper-inner">
|
||||
<input
|
||||
:placeholder="placeholder"
|
||||
:type="isPasswordVisible"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
@keydown="numberOnly ? onlyNumbersKeydown($event) : null"
|
||||
class="block__input"
|
||||
:inputmode="inputMode || 'text'"
|
||||
>
|
||||
<button
|
||||
@click.prevent="togglePasswordVisible"
|
||||
class="block__eyes"
|
||||
v-if="type === 'password' && String(modelValue).length > 0"
|
||||
>
|
||||
<icon v-if="isPasswordVisible === 'password'" name="mdi:eye-off-outline" />
|
||||
<icon v-else name="mdi:eye-outline" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
|
@ -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%;
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
storefront/app/components/ui/link.vue
Normal file
56
storefront/app/components/ui/link.vue
Normal 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>
|
||||
|
|
@ -1,71 +1,75 @@
|
|||
<template>
|
||||
<div class="search">
|
||||
<div
|
||||
@click="toggleSearch(true)"
|
||||
class="search__wrapper"
|
||||
:class="[{ active: isSearchActive }]"
|
||||
>
|
||||
<form class="search__form" @submit.prevent="submitSearch">
|
||||
<input
|
||||
type="text"
|
||||
v-model="query"
|
||||
:placeholder="t('fields.search')"
|
||||
inputmode="search"
|
||||
/>
|
||||
<div class="search__tools">
|
||||
<button
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
v-if="query"
|
||||
>
|
||||
<icon name="gridicons:cross" size="16" />
|
||||
</button>
|
||||
<div class="search__tools-line" v-if="query"></div>
|
||||
<button type="submit">
|
||||
<icon name="tabler:search" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="search__results" :class="[{ active: (searchResults && isSearchActive) || loading }]">
|
||||
<skeletons-header-search v-if="loading" />
|
||||
<div class="container">
|
||||
<div class="search__inner">
|
||||
<div
|
||||
class="search__results-inner"
|
||||
v-for="(blocks, category) in filteredSearchResults"
|
||||
:key="category"
|
||||
@click="toggleSearch(true)"
|
||||
class="search__wrapper"
|
||||
:class="[{ active: isSearchActive }]"
|
||||
>
|
||||
<div class="search__results-title">
|
||||
<p>{{ getBlockTitle(category) }}:</p>
|
||||
</div>
|
||||
<div
|
||||
class="search__item"
|
||||
v-for="item in blocks"
|
||||
:key="item.uuid"
|
||||
@click.stop="goTo(category, item)"
|
||||
>
|
||||
<div class="search__item-left">
|
||||
<icon name="ic:twotone-search" size="18" />
|
||||
<p>{{ item.name }}</p>
|
||||
<form class="search__form" @submit.prevent="submitSearch">
|
||||
<input
|
||||
type="text"
|
||||
v-model="query"
|
||||
:placeholder="t('fields.search')"
|
||||
inputmode="search"
|
||||
/>
|
||||
<div class="search__tools">
|
||||
<button
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
v-if="query"
|
||||
>
|
||||
<icon name="gridicons:cross" size="16" />
|
||||
</button>
|
||||
<div class="search__tools-line" v-if="query"></div>
|
||||
<button type="submit">
|
||||
<icon name="tabler:search" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="search__results" :class="[{ active: (searchResults && isSearchActive) || loading }]">
|
||||
<skeletons-header-search v-if="loading" />
|
||||
<div
|
||||
class="search__results-inner"
|
||||
v-for="(blocks, category) in filteredSearchResults"
|
||||
:key="category"
|
||||
>
|
||||
<div class="search__results-title">
|
||||
<p>{{ getBlockTitle(category) }}:</p>
|
||||
</div>
|
||||
<div
|
||||
class="search__item"
|
||||
v-for="item in blocks"
|
||||
:key="item.uuid"
|
||||
@click.stop="goTo(category, item)"
|
||||
>
|
||||
<div class="search__item-left">
|
||||
<icon name="ic:twotone-search" size="18" />
|
||||
<p>{{ item.name }}</p>
|
||||
</div>
|
||||
<icon name="line-md:external-link" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="search__results-empty" v-if="!hasResults && query && !loading">
|
||||
<p>{{ t('header.search.empty') }}</p>
|
||||
</div>
|
||||
<icon name="line-md:external-link" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="search__results-empty" v-if="!hasResults && query && !loading">
|
||||
<p>{{ t('header.search.empty') }}</p>
|
||||
</div>
|
||||
<transition name="opacity" mode="out-in">
|
||||
<div
|
||||
class="search__bg"
|
||||
@click="toggleSearch(false)"
|
||||
v-if="isSearchActive"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="opacity" mode="out-in">
|
||||
<div
|
||||
class="search__bg"
|
||||
@click="toggleSearch(false)"
|
||||
v-if="isSearchActive"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSearchUI } from "@/composables/search";
|
||||
import { useSearchUI } from '@composables/search';
|
||||
|
||||
const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
|
@ -106,18 +110,22 @@ 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);
|
||||
|
|
@ -128,9 +136,15 @@ function goTo(category: string, item: any) {
|
|||
<style lang="scss" scoped>
|
||||
.search {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 45px;
|
||||
background-color: $white;
|
||||
|
||||
&__inner {
|
||||
padding-block: 10px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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 {
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
<template>
|
||||
<div class="block">
|
||||
<textarea
|
||||
:placeholder="placeholder"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
class="block__textarea"
|
||||
/>
|
||||
<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;
|
||||
33
storefront/app/components/ui/theme-toggle.vue
Normal file
33
storefront/app/components/ui/theme-toggle.vue
Normal 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>
|
||||
|
|
@ -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';
|
||||
80
storefront/app/composables/auth/useLogin.ts
Normal file
80
storefront/app/composables/auth/useLogin.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
storefront/app/composables/auth/useLogout.ts
Normal file
33
storefront/app/composables/auth/useLogout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
storefront/app/composables/auth/useNewPassword.ts
Normal file
57
storefront/app/composables/auth/useNewPassword.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
storefront/app/composables/auth/usePasswordReset.ts
Normal file
46
storefront/app/composables/auth/usePasswordReset.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
122
storefront/app/composables/auth/useRefresh.ts
Normal file
122
storefront/app/composables/auth/useRefresh.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
83
storefront/app/composables/auth/useRegister.ts
Normal file
83
storefront/app/composables/auth/useRegister.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
storefront/app/composables/brands/index.ts
Normal file
2
storefront/app/composables/brands/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './useBrandBySlug';
|
||||
export * from './useBrands';
|
||||
21
storefront/app/composables/brands/useBrandBySlug.ts
Normal file
21
storefront/app/composables/brands/useBrandBySlug.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
83
storefront/app/composables/brands/useBrands.ts
Normal file
83
storefront/app/composables/brands/useBrands.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1
storefront/app/composables/breadcrumbs/index.ts
Normal file
1
storefront/app/composables/breadcrumbs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './useBreadcrumbs';
|
||||
90
storefront/app/composables/breadcrumbs/useBreadcrumbs.ts
Normal file
90
storefront/app/composables/breadcrumbs/useBreadcrumbs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
4
storefront/app/composables/categories/index.ts
Normal file
4
storefront/app/composables/categories/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './useCategories';
|
||||
export * from './useCategoryBySlug';
|
||||
export * from './useCategoryBySlugSeo';
|
||||
export * from './useCategoryTags';
|
||||
39
storefront/app/composables/categories/useCategories.ts
Normal file
39
storefront/app/composables/categories/useCategories.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
26
storefront/app/composables/categories/useCategoryBySlug.ts
Normal file
26
storefront/app/composables/categories/useCategoryBySlug.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
18
storefront/app/composables/categories/useCategoryTags.ts
Normal file
18
storefront/app/composables/categories/useCategoryTags.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
storefront/app/composables/company/index.ts
Normal file
2
storefront/app/composables/company/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './useCompanyInfo';
|
||||
export * from './usePaymentLimits';
|
||||
20
storefront/app/composables/company/useCompanyInfo.ts
Normal file
20
storefront/app/composables/company/useCompanyInfo.ts
Normal 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 {};
|
||||
}
|
||||
18
storefront/app/composables/company/usePaymentLimits.ts
Normal file
18
storefront/app/composables/company/usePaymentLimits.ts
Normal 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
Loading…
Reference in a new issue