Compare commits

..

No commits in common. "storefront-nuxt" and "master" have entirely different histories.

309 changed files with 14 additions and 31326 deletions

View file

@ -12,7 +12,6 @@ coverage.*
*.py,cover *.py,cover
nosetests.xml nosetests.xml
desktop.ini desktop.ini
tmp/
# Cache directories # Cache directories
__pycache__/ __pycache__/
@ -52,8 +51,6 @@ wheels/
share/python-wheels/ share/python-wheels/
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
storefront/node_modules/
storefront/.nuxt
# Git # Git
.git/ .git/

View file

@ -1,40 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
ARG SCHON_BASE_DOMAIN
ARG SCHON_PROJECT_NAME
ARG SCHON_LANGUAGE_CODE
ENV SCHON_BASE_DOMAIN=$SCHON_BASE_DOMAIN
ENV SCHON_PROJECT_NAME=$SCHON_PROJECT_NAME
ENV SCHON_LANGUAGE_CODE=$SCHON_LANGUAGE_CODE
COPY ./storefront/package.json ./storefront/package-lock.json ./
RUN npm ci --include=optional
COPY ./storefront ./
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV HOST=0.0.0.0
ENV PORT=3000
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system --gid 1001 nodeapp \
&& adduser --system --uid 1001 --ingroup nodeapp --home /home/nodeapp nodeapp
USER nodeapp
COPY --from=build /app/.output/ ./
RUN install -d -m 0755 -o nodeapp -g nodeapp /home/nodeapp \
&& printf '#!/bin/sh\nif [ \"$DEBUG\" = \"1\" ]; then export NODE_ENV=development; else export NODE_ENV=production; fi\nexec node /app/server/index.mjs\n' > /home/nodeapp/start.sh \
&& chown nodeapp:nodeapp /home/nodeapp/start.sh \
&& chmod +x /home/nodeapp/start.sh
USER nodeapp
CMD ["sh", "/home/nodeapp/start.sh"]

View file

@ -126,7 +126,7 @@ services:
container_name: worker container_name: worker
build: build:
context: . context: .
dockerfile: ./Dockerfiles/worker.Dockerfile dockerfile: Dockerfiles/worker.Dockerfile
restart: always restart: always
volumes: volumes:
- .:/app - .:/app
@ -154,7 +154,7 @@ services:
container_name: stock_updater container_name: stock_updater
build: build:
context: . context: .
dockerfile: ./Dockerfiles/stock_updater.Dockerfile dockerfile: Dockerfiles/stock_updater.Dockerfile
restart: always restart: always
volumes: volumes:
- .:/app - .:/app
@ -182,7 +182,7 @@ services:
container_name: beat container_name: beat
build: build:
context: . context: .
dockerfile: ./Dockerfiles/beat.Dockerfile dockerfile: Dockerfiles/beat.Dockerfile
restart: always restart: always
volumes: volumes:
- .:/app - .:/app
@ -214,30 +214,6 @@ services:
ports: ports:
- "9090:9090" - "9090:9090"
storefront:
container_name: storefront
build:
context: .
dockerfile: ./Dockerfiles/storefront.Dockerfile
args:
- DEBUG=${DEBUG}
- SCHON_BASE_DOMAIN=${SCHON_BASE_DOMAIN}
- SCHON_PROJECT_NAME=${SCHON_PROJECT_NAME}
- SCHON_LANGUAGE_CODE=${SCHON_LANGUAGE_CODE}
restart: always
env_file:
- .env
environment:
- NUXT_HOST=0.0.0.0
- NUXT_PORT=3000
- NUXT_DEVTOOLS_ENABLED=${DEBUG}
ports:
- "3000:3000"
depends_on:
app:
condition: service_started
logging: *default-logging
volumes: volumes:
postgres-data: postgres-data:

View file

@ -619,8 +619,6 @@ class CreateAddress(Mutation):
raw_data = String( raw_data = String(
required=True, description=_("original address string provided by the user") required=True, description=_("original address string provided by the user")
) )
address_line_1 = String(required=True)
address_line_2 = String(required=False)
address = Field(AddressType) address = Field(AddressType)

View file

@ -0,0 +1,11 @@
User-agent: *
Disallow: /*/public/wishlist/
Disallow: /*/public/cart/
Disallow: /*/public/checkout/
Disallow: /*/auth/sign-in/
Disallow: /*/auth/sign-up/
Allow: /
Sitemap: https://schon.wiseless.xyz/sitemap.xml
Host: schon.wiseless.xyz

24
storefront/.gitignore vendored
View file

@ -1,24 +0,0 @@
# 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

0
storefront/.gitkeep Normal file
View file

View file

@ -1,19 +0,0 @@
declare module 'nuxt/schema' {
interface AppConfig {
i18n: {
supportedLocales: Array<{
code: string;
file: string;
default: boolean;
}>;
};
ui: {
showBreadcrumbs: boolean;
showSearchBar: boolean;
isHeaderFixed: boolean;
isAuthModals: boolean;
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
};
}
}
export {};

View file

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

View file

@ -1,204 +0,0 @@
<template>
<div
class="main"
:style="{ 'padding-top': uiConfig.isHeaderFixed ? '84px': '0' }"
>
<nuxt-loading-indicator :color="primaryColor" />
<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 {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 { $appHelpers } = useNuxtApp();
const { isDemoMode, uiConfig } = useProjectConfig();
const switchLocalePath = useSwitchLocalePath();
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: () => $appHelpers.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 = $appHelpers.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 = $appHelpers.DEFAULT_LOCALE;
await router.push({
path: switchLocalePath($appHelpers.DEFAULT_LOCALE),
query: route.query
});
}
}
const primaryColor = 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 }
);
primaryColor.value = getCssVariable('--primary');
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: $main_hover;
position: relative;
}
.lock-scroll {
overflow: hidden !important;
}
.demo {
&__button {
position: fixed !important;
width: fit-content !important;
bottom: 20px;
right: 20px;
padding: 10px !important;
}
}
</style>

View file

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

View file

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

View file

@ -1,105 +0,0 @@
$font_default: 'Inter', sans-serif;
$default_border_radius: 8px;
$less_border_radius: 4px;
$main: var(--main);
$main_hover: var(--main_hover);
$title_bg: var(--title_bg);
$primary: var(--primary);
$primary_hover: var(--primary_hover);
$primary_dark: var(--primary_dark);
$primary_gradient: var(--primary_gradient);
$primary_shadow: var(--primary_shadow);
$primary_shadow_hover: var(--primary_shadow_hover);
$blackout: var(--blackout);
$secondary: var(--secondary);
$secondary_hover: var(--secondary_hover);
$disabled: var(--disabled);
$disabled_secondary: var(--disabled_secondary);
$border: var(--border);
$border_hover: var(--border_hover);
$text: var(--text);
$link_primary: var(--link_primary);
$link_primary_hover: var(--link_primary_hover);
$link_secondary: var(--link_secondary);
$link_secondary_hover: var(--link_secondary_hover);
$skeleton: var(--skeleton);
$rating: var(--rating);
$success: var(--success);
$error: var(--error);
$info: var(--info);
$warning: var(--warning);
:root {
--main: #fff;
--main_hover: #f9fafb;
--title_bg: #f8f8f8;
--primary: #111827;
--primary_hover: #242c38;
--primary_dark: #1a1a1a;
--primary_gradient: linear-gradient(to bottom, rgba(26, 26, 26, 1) 0%,rgba(31, 41, 55, 1) 100%);
--primary_shadow: rgba(0, 0, 0, 0.1);
--primary_shadow_hover: rgba(0, 0, 0, 0.3);
--blackout: rgba(0, 0, 0, 0.4);
--secondary: #374151;
--secondary_hover: #29323f;
--disabled: #0a0f1a;
--disabled_secondary: #9a9a9a;
--border: #e5e7eb;
--border_hover: #505052;
--text: #4b5563;
--link_primary: #1f2937;
--link_primary_hover: #29323f;
--link_secondary: #c2c7ce;
--link_secondary_hover: #a4a8ad;
--skeleton: rgba(255, 255, 255, 0.61);
--rating: #facc15;
--success: #67c23a;
--error: #f56c6c;
--info: #909399;
--warning: #e6a23c;
}
[data-theme='dark'] {
--main: #1a1a1a;
--main_hover: #242424;
--title_bg: #111111;
--primary: #f3f4f6;
--primary_hover: #ffffff;
--primary_dark: #e5e7eb;
--primary_gradient: linear-gradient(to bottom, rgba(243, 244, 246, 1) 0%, rgba(229, 231, 235, 1) 100%);
--primary_shadow: rgba(0, 0, 0, 0.5);
--primary_shadow_hover: rgba(0, 0, 0, 0.7);
--blackout: rgba(0, 0, 0, 0.5);
--secondary: #9ca3af;
--secondary_hover: #d1d5db;
--disabled: #4b5563;
--disabled_secondary: #6b7280;
--border: #374151;
--border_hover: #4b5563;
--text: #e5e7eb;
--link_primary: #aba7a7;
--link_primary_hover: #858282;
--link_secondary: #9ca3af;
--link_secondary_hover: #cbd5e1;
--skeleton: rgba(255, 255, 255, 0.1);
--rating: #facc15;
--success: #67c23a;
--error: #f56c6c;
--info: #909399;
--warning: #e6a23c;
}

View file

@ -1,14 +0,0 @@
@use "modules/normalize";
@use "modules/transitions";
@use "global/mixins";
@use "global/variables";
// UI
@use "ui/badge";
@use "ui/collapse";
@use "ui/notification";
@use "ui/rating";
@use "ui/select";
@use "ui/slider";

View file

@ -1,76 +0,0 @@
@use "../global/variables" as *;
* {
margin: 0;
padding: 0;
border: none;
box-sizing: border-box;
transition: all 0.2s;
}
html {
overflow-x: hidden;
font-family: $font_default;
}
#app {
overflow-x: hidden;
position: relative;
}
a {
text-decoration: none;
color: inherit;
}
input, textarea, button {
font-family: $font_default;
outline: none;
background-color: transparent;
}
button:focus-visible {
outline: none;
}
.container {
max-width: 1500px;
margin-inline: auto;
}
::-webkit-scrollbar-thumb {
background: $primary;
}
::-webkit-scrollbar-track {
background: $main_hover;
}
* {
scrollbar-color: $primary $main_hover;
}
:root {
--el-color-primary: #{$primary} !important;
}
.el-skeleton__item {
--el-skeleton-color: #c9ccd0 !important;
--el-skeleton-to-color: #c3c3c7 !important;
}
.el-badge__content {
border: none !important;
}
@media (max-width: 1680px) {
.container {
max-width: 1200px;
}
}
@media (max-width: 1300px) {
.container {
width: 90%;
}
}

View file

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

View file

@ -1,5 +0,0 @@
@use "../global/variables" as *;
.el-badge__content {
background-color: $disabled !important;
}

View file

@ -1,39 +0,0 @@
@use "../global/variables" as *;
.el-collapse {
border: none !important;
display: flex;
flex-direction: column;
gap: 20px;
padding-block: 20px
}
.el-collapse-item {
border-radius: $default_border_radius;
border: 1px solid $border;
}
.el-collapse-item__header {
background-color: transparent !important;
border-bottom: none !important;
line-height: 100% !important;
font-size: 14px !important;
font-weight: 600 !important;
padding-inline: 8px !important;
color: $primary_dark !important;
}
.el-collapse-item__header.focusing:focus:not(:hover) {
color: $primary_dark !important;
}
.el-collapse-item__wrap {
border-top: 2px solid $border;
border-bottom: none !important;
background-color: transparent !important;
}
.el-collapse-item__content {
padding: 10px !important;
display: flex;
flex-direction: column;
gap: 5px;
}
.el-collapse .el-icon {
display: none !important;
}

View file

@ -1,52 +0,0 @@
@use "../global/variables" as *;
.el-notification {
border: 2px solid $primary !important;
transition: all 0.3s ease !important;
&__progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background-color: $primary_dark;
animation: progress-animation linear forwards;
&.success {
background-color: $success;
}
&.error {
background-color: $error;
}
&.info {
background-color: $info;
}
&.warning {
background-color: $warning;
}
}
&__button {
margin-top: 10px;
padding: 6px 12px;
background-color: $primary;
border-radius: $less_border_radius;
color: $main;
border: none;
cursor: pointer;
}
.el-notification__closeBtn {
color: $primary !important;
}
}
@keyframes progress-animation {
0% {
width: 100%;
}
100% {
width: 0;
}
}

View file

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

View file

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

View file

@ -1,17 +0,0 @@
.el-slider {
width: 90% !important;
margin-inline: auto;
height: 30px !important;
}
.el-slider__bar, .el-slider__runway {
height: 4px !important;
}
.el-slider__button-wrapper {
width: 34px !important;
height: 34px !important;
}
.el-slider__button {
width: 16px !important;
height: 16px !important;
}

View file

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

View file

@ -1,133 +0,0 @@
<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-conditions">{{ 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/return-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: #000;
&__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 {
color: #fff;
font-size: 24px;
font-weight: 600;
letter-spacing: 6.7px;
font-family: 'Playfair Display', sans-serif;
@include hover {
text-shadow: 0 0 5px #fff;
}
}
&__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: #d2d0d0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.5px;
}
& p, a {
color: $link_secondary;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
@include hover {
color: $link_secondary_hover;
}
}
}
&__bottom {
& p {
color: $link_secondary;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
}
</style>

View file

@ -1,264 +0,0 @@
<template>
<div class="catalog" ref="blockRef">
<button
@click="setBlock(!isBlockOpen)"
class="catalog__button"
:class="[{ active: isBlockOpen }]"
>
{{ t('header.catalog.title') }}
<span></span>
</button>
<div class="container">
<div class="categories" :class="[{active: isBlockOpen}]">
<div class="categories__block" v-if="categories.length > 0">
<div class="categories__left">
<p
v-for="category in categories"
:key="category.node.uuid"
:class="[{ active: category.node.uuid === activeCategory.uuid }]"
@click="setActiveCategory(category.node)"
>
{{ category.node.name }}
</p>
</div>
<div class="categories__main">
<div
class="categories__main-block"
v-for="mainChildren in activeCategory.children"
:key="mainChildren.uuid"
>
<nuxt-link-locale
:to="`/catalog/${mainChildren.slug}`"
class="categories__main-link"
@click="setBlock(false)"
>
{{ mainChildren.name }}
</nuxt-link-locale>
<div class="categories__main-list">
<nuxt-link-locale
v-for="children in mainChildren.children"
:key="children.uuid"
:to="`/catalog/${children.slug}`"
@click="setBlock(false)"
>
{{ children.name }}
</nuxt-link-locale >
</div>
</div>
</div>
</div>
<div class="categories__empty" v-else><p>{{ t('header.catalog.empty') }}</p></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ICategory} from '@types';
import {onClickOutside} from '@vueuse/core';
const { t } = useI18n();
const categoryStore = useCategoryStore();
const categories = computed(() => categoryStore.categories);
const isBlockOpen = ref<boolean>(false);
const setBlock = (state: boolean) => {
isBlockOpen.value = state;
};
// TODO: add loading state
const blockRef = ref(null);
onClickOutside(blockRef, () => setBlock(false));
const activeCategory = ref<ICategory>(categories.value[0]?.node);
const setActiveCategory = (category: ICategory) => {
activeCategory.value = category;
};
</script>
<style lang="scss" scoped>
.catalog {
&__button {
cursor: pointer;
border-radius: $less_border_radius;
background-color: rgba($primary, 0.2);
border: 1px solid $primary;
padding: 5px 20px;
display: flex;
align-items: center;
gap: 10px;
color: $primary;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $primary;
color: $main;
}
& span {
font-size: 26px;
}
&.active {
background-color: $primary;
color: $main;
& span {
transform: rotate(-180deg);
}
}
}
}
.container {
position: absolute;
left: 50%;
top: 110%;
transform: translateX(-50%);
width: 100%;
}
.categories {
border-radius: $less_border_radius;
width: 100%;
background-color: $main;
box-shadow: 0 0 15px 1px $secondary;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
&__block {
display: grid;
grid-template-columns: 20% 80%;
max-height: 50vh;
}
&__columns {
& div {
padding: 20px 50px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-column-gap: 30px;
grid-row-gap: 5px;
& p {
cursor: pointer;
padding: 5px 20px;
font-size: 16px;
font-weight: 600;
@include hover {
color: $primary;
}
}
}
}
&__left {
flex-shrink: 0;
padding-block: 10px;
overflow: auto;
& p {
cursor: pointer;
flex-shrink: 0;
padding: 10px;
border-left: 3px solid $main;
font-weight: 700;
@include hover {
color: $primary;
}
&.active {
border-color: $primary;
color: $primary;
}
}
}
&__main {
padding: 20px;
border-left: 2px solid $primary_dark;
overflow: auto;
&-block {
padding-bottom: 15px;
border-bottom: 1px solid #eeeeee;
margin-bottom: 15px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
}
&-link {
position: relative;
width: fit-content;
cursor: pointer;
font-weight: 600;
font-size: 16px;
&::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: $primary;
}
@include hover {
&::after {
width: 100%;
}
}
}
&-list {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3,1fr);
grid-column-gap: 30px;
grid-row-gap: 5px;
& a {
cursor: pointer;
font-size: 14px;
@include hover {
color: $primary;
}
}
}
}
&__empty {
& p {
padding: 20px;
text-align: center;
font-size: 16px;
font-weight: 600;
}
}
}
</style>

View file

@ -1,362 +0,0 @@
<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
v-if="uiConfig.showSearchBar"
@click="isSearchVisible = true"
class="header__block-search"
name="tabler:search"
size="20"
/>
<ui-language-switcher />
<ui-theme-toggle />
<el-badge :value="productsInWishlistQuantity">
<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">
<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 && uiConfig.showSearchBar }]">
<ui-search
ref="searchRef"
/>
</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 { $appHelpers } = useNuxtApp();
const { uiConfig } = useProjectConfig();
const isAuthenticated = computed(() => userStore.isAuthenticated);
const user = computed(() => userStore.user);
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const productsInCartQuantity = computed(() => {
if (isAuthenticated.value) {
let count = 0;
cartStore.currentOrder?.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
return count;
} else {
return cookieCart.value.reduce((acc, item) => acc + item.quantity, 0);
}
});
const productsInWishlistQuantity = computed(() => {
if (isAuthenticated.value) {
return wishlistStore.wishlist ? wishlistStore.wishlist.products.edges.length : 0;
} else {
return cookieWishlist.value.length
}
});
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: 5;
top: 0;
left: 0;
width: 100vw;
background-color: $main;
border-bottom: 1px solid $border;
&__fixed {
position: fixed;
}
&__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 25px;
padding-block: 25px;
background-color: $main;
}
&__inner {
width: 33%;
display: flex;
align-items: center;
justify-content: center;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
&__logo {
font-size: 24px;
font-weight: 600;
letter-spacing: 6.7px;
font-family: 'Playfair Display', sans-serif;
color: $primary_dark;
@include hover {
text-shadow: 0 0 5px $primary_dark;
}
}
&__nav {
display: flex;
align-items: center;
gap: 40px;
&-item {
position: relative;
color: $link_primary;
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: $link_primary;
}
&.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;
color: $secondary;
@include hover {
color: $secondary_hover;
}
}
&-wishlist {
display: block;
cursor: pointer;
color: $secondary;
@include hover {
color: $secondary_hover;
}
}
&-cart {
display: block;
cursor: pointer;
color: $secondary;
@include hover {
color: $secondary_hover;
}
}
&-auth {
border-bottom: 1px solid $secondary;
padding-bottom: 2px;
color: $secondary;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
@include hover {
color: $secondary_hover;
}
}
&-avatar {
width: 28px;
border-radius: 50%;
border: 1px solid $secondary;
& span {
display: block;
}
@include hover {
opacity: 0.7;
}
}
&-profile {
width: 28px;
border-radius: 50%;
padding: 5px;
border: 1px solid $secondary;
& span {
color: $primary;
display: block;
}
@include hover {
opacity: 0.7;
}
}
}
&__search {
position: relative;
width: 100%;
top: 100%;
left: 0;
display: grid;
grid-template-rows: 0fr;
//transition: grid-template-rows 0.2s ease;
opacity: 0;
visibility: hidden;
&.active {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
}
& > * {
min-height: 0;
}
}
}
</style>

View file

@ -1,69 +0,0 @@
<template>
<nuxt-link-locale
class="card"
:to="`/brand/${brand.slug}`"
>
<nuxt-img
v-if="brand.smallLogo"
format="webp"
densities="x1"
:src="brand.smallLogo"
:alt="brand.name"
class="card__image"
loading="lazy"
/>
<div class="card__image-placeholder" v-else />
<p>{{ brand.name }}</p>
</nuxt-link-locale>
</template>
<script setup lang="ts">
import type {IBrand} from '@types';
const props = defineProps<{
brand: IBrand;
}>();
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
background-color: $main;
border-radius: $default_border_radius;
border: 1px solid $border;
height: 100%;
padding: 23px;
@include hover {
box-shadow: 0 0 10px 2px $primary_shadow_hover;
border-color: $border_hover;
}
&__image {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
object-position: center;
&-placeholder {
width: 100%;
aspect-ratio: 1;
background-color: $primary;
border-radius: $less_border_radius;
}
}
& p {
color: $primary_dark;
text-align: center;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -1,69 +0,0 @@
<template>
<nuxt-link-locale
class="card"
:to="`/catalog/${category.slug}`"
>
<nuxt-img
v-if="category.image"
format="webp"
densities="x1"
:src="category.image"
:alt="category.name"
class="card__image"
loading="lazy"
/>
<div class="card__image-placeholder" v-else />
<p>{{ category.name }}</p>
</nuxt-link-locale>
</template>
<script setup lang="ts">
import type {ICategory} from '@types';
const props = defineProps<{
category: ICategory;
}>();
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
background-color: $main;
border-radius: $default_border_radius;
border: 1px solid $border;
height: 100%;
padding: 23px;
@include hover {
box-shadow: 0 0 10px 2px $primary_shadow_hover;
border-color: $border_hover;
}
&__image {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
object-position: center;
&-placeholder {
width: 100%;
aspect-ratio: 1;
background-color: $primary;
border-radius: $less_border_radius;
}
}
& p {
color: $primary_dark;
text-align: center;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -1,258 +0,0 @@
<template>
<el-collapse-item
class="order"
>
<template #title="{ isActive }">
<div :class="['order__top', { 'is-active': isActive }]">
<div>
<p>{{ t('profile.orders.id') }}: {{ order.humanReadableId }}</p>
<p v-if="order.buyTime">{{ useDate(order.buyTime, locale) }}</p>
<el-tooltip
:content="order.status"
placement="top"
>
<p class="status" :style="[{ backgroundColor: statusColor(order.status) }]">
{{ order.status }}
<icon name="material-symbols:info-outline-rounded" size="14" />
</p>
</el-tooltip>
</div>
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="order__top-icon"
:class="[{ active: isActive }]"
/>
</div>
<div class="order__top-bottom" :class="{ active: !isActive }">
<div>
<div>
<div>
<nuxt-img
v-for="product in order.orderProducts.edges"
:key="product.node.uuid"
:src="product.node.product.images.edges[0].node.image"
:alt="product.node.product.name"
format="webp"
densities="x1"
/>
</div>
<p>{{ order.totalPrice }}{{ CURRENCY }}</p>
</div>
</div>
</div>
</template>
<div class="order__main">
<div
class="order__product"
v-for="product in order.orderProducts.edges"
:key="product.node.uuid"
>
<div class="order__product-left">
<nuxt-img
:src="product.node.product.images.edges[0].node.image"
:alt="product.node.product.name"
format="webp"
densities="x1"
/>
<p>{{ product.node.product.name }}</p>
</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 }}$</p>
</div>
</div>
<div class="order__total">
<p>{{ t('profile.orders.total') }}: {{ order.totalPrice }}$</p>
</div>
</div>
</el-collapse-item>
</template>
<script setup lang="ts">
import {useDate} from '@composables/date';
import {orderStatuses, CURRENCY} from '@appConstants';
import type {IOrder} from '@types';
const props = defineProps<{
order: IOrder;
}>();
const {t, locale} = useI18n();
const statusColor = (status: string) => {
switch (status) {
case orderStatuses.FAILED:
return '#FF0000';
case orderStatuses.PAYMENT:
return '#FFC107';
case orderStatuses.CREATED:
return '#007BFF';
case orderStatuses.DELIVERING:
return '#00C853';
case orderStatuses.FINISHED:
return '#00C853';
case orderStatuses.MOMENTAL:
return '#00C853';
default:
return '#000';
}
};
</script>
<style scoped lang="scss">
.order {
&__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 10px 5px 10px;
& div {
display: flex;
align-items: center;
gap: 25px;
& p {
display: flex;
align-items: center;
gap: 5px;
color: $secondary;
font-size: 14px;
font-weight: 400;
&.status {
border-radius: $less_border_radius;
padding: 3px 7px;
}
}
}
&-icon {
color: $secondary;
&.active {
transform: rotate(-180deg);
}
}
&-bottom {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
& div {
& div {
padding-top: 10px;
border-top: 1px solid $border;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 25px;
& div {
padding-top: 0;
border: none;
display: flex;
flex-wrap: wrap;
gap: 10px;
& img {
height: 65px;
border-radius: $less_border_radius;
}
}
& p {
color: $secondary;
font-size: 18px;
font-weight: 600;
}
}
}
}
}
&__main {
display: flex;
flex-direction: column;
gap: 20px;
}
&__product {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 2px solid $border;
&-left {
display: flex;
align-items: flex-start;
gap: 10px;
& img {
height: 150px;
border-radius: $less_border_radius;
}
& p {
color: $secondary;
font-size: 18px;
font-weight: 600;
}
}
&-right {
display: flex;
flex-direction: column;
align-items: flex-end;
& h6 {
color: $secondary;
font-size: 18px;
font-weight: 600;
}
& p {
color: $secondary;
font-size: 14px;
font-weight: 400;
}
}
}
&__total {
display: flex;
align-items: flex-end;
justify-content: flex-end;
& p {
font-size: 20px;
font-weight: 600;
}
}
}
:deep(.el-collapse-item__header) {
height: fit-content;
padding-block: 10px;
}
</style>

View file

@ -1,67 +0,0 @@
<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: IPost;
}>();
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.card {
display: flex;
flex-direction: column;
gap: 10px;
&__title {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__button {
width: fit-content;
position: relative;
color: $primary_dark;
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: $primary_dark;
}
&.active::after {
width: 100%;
}
@include hover {
&::after {
width: 100%;
}
}
}
}
</style>

View file

@ -1,518 +0,0 @@
<template>
<div
class="card"
:class="{ 'card__list': isList }"
>
<div
class="card__wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name,
})"
>
<icon style="color: #dc2626;" 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}`"
class="card__link"
>
<div class="card__block">
<client-only>
<swiper
v-if="images.length"
@swiper="onSwiper"
:modules="[EffectFade, Pagination]"
effect="fade"
:slides-per-view="1"
:pagination="paginationOptions"
class="card__swiper"
>
<swiper-slide
v-for="(img, i) in images"
:key="i"
class="card__swiper-slide"
>
<nuxt-img
:src="img"
:alt="product.name"
loading="lazy"
class="card__swiper-image"
format="webp"
densities="x1"
/>
</swiper-slide>
</swiper>
<div class="card__image-placeholder" />
<div
v-for="(image, idx) in images"
:key="idx"
class="card__block-hover"
:style="{ left: `${(100/ images.length) * idx}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(idx)"
@mouseleave="goTo(0)"
/>
</client-only>
</div>
</nuxt-link-locale>
</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__price">{{ product.price }} $</div>
</div>
<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
})"
>
-
</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>
<ui-button
v-else
class="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
<ui-button
:type="'button'"
class="card__bottom-button"
:style="'secondary'"
@click="buyProduct(product.uuid)"
:isLoading="buyLoading"
>
{{ t('buttons.buyNow') }}
</ui-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IProduct} from '@types';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import {useWishlistOverwrite} from '@composables/wishlist';
import {useOrderOverwrite} from '@composables/orders';
import {useProductBuy} from "@composables/products";
const props = defineProps<{
product: IProduct;
isList?: boolean;
}>();
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const userStore = useUserStore();
const { $appHelpers } = useNuxtApp();
const { overwriteWishlist } = useWishlistOverwrite();
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
const { buyProduct, loading: buyLoading } = useProductBuy();
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const isAuthenticated = computed(() => userStore.isAuthenticated);
const isProductInWishlist = computed(() => {
if (isAuthenticated.value) {
return !!wishlistStore.wishlist?.products?.edges.find(
(el) => el?.node?.uuid === props.product.uuid
);
} else {
return (cookieWishlist.value ?? []).includes(props.product.uuid);
}
});
const isProductInCart = computed(() => {
if (isAuthenticated.value) {
return !!cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product?.uuid
);
} else {
return (cookieCart.value ?? []).some(
(item) => item.productUuid === props.product?.uuid
);
}
});
const productInCartQuantity = computed(() => {
if (isAuthenticated.value) {
const productEdge = cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product.uuid
);
return productEdge?.node.quantity ?? 0;
} else {
const cartItem = (cookieCart.value ?? []).find(
(item) => item.productUuid === props.product.uuid
);
return cartItem?.quantity ?? 0;
}
});
const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
});
const images = computed(() =>
props.product.images.edges.map(e => e.node.image)
);
const paginationOptions = computed(() =>
images.value.length > 1
? {
clickable: true,
bulletClass: 'swiper-pagination-line',
bulletActiveClass: 'swiper-pagination-line--active'
}
: false
);
const swiperRef = ref<any>(null);
function onSwiper(swiper: any) {
swiperRef.value = swiper;
}
function goTo(index: number) {
swiperRef.value?.slideTo(index);
}
</script>
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 1px solid $border;
width: 100%;
background-color: $main;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: stretch;
gap: 50px;
padding: 15px;
& .card__link {
width: fit-content;
padding: 0;
}
& .card__block {
width: 150px;
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;
flex-shrink: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
&-inner {
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
&-button {
width: fit-content;
padding-inline: 25px;
}
}
& .card__wrapper {
flex-direction: row;
}
}
@include hover {
border-color: $border_hover;
box-shadow: 0 0 10px 1px $border;
}
&__wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__link {
display: block;
width: 100%;
}
&__block {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
&-hover {
position: absolute;
top: 0;
left: 0;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
background: transparent;
}
}
&__swiper {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
padding-bottom: 10px;
&-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__image {
&-placeholder {
width: 100%;
height: 200px;
background-color: $primary;
}
}
&__content {
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: $text;
}
&__price {
font-weight: 600;
font-size: 16px;
letter-spacing: -0.5px;
color: $primary_dark;
}
&__rating {
margin-block: 4px 10px;
}
&__name {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
color: $primary_dark;
font-weight: 500;
font-size: 14px;
letter-spacing: -0.5px;
}
&__quantity {
width: fit-content;
background-color: rgba($secondary, 0.5);
border-radius: $less_border_radius;
padding: 5px 10px;
font-size: 14px;
}
&__wishlist {
cursor: pointer;
width: fit-content;
background-color: $main;
box-shadow: 0 2px 4px 0 $primary_shadow;
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
border-radius: 50%;
padding: 12px;
@include hover {
box-shadow: 0 2px 4px 0 $primary_shadow_hover;
}
& span {
color: $secondary;
display: block;
}
}
&__bottom {
&-inner {
display: flex;
flex-direction: column;
gap: 10px;
}
&-button {
padding-block: 10px !important;
border-radius: $less_border_radius;
}
&-wishlist {
cursor: pointer;
width: 34px;
height: 34px;
flex-shrink: 0;
background-color: $primary;
border-radius: $less_border_radius;
display: grid;
place-items: center;
font-size: 22px;
color: $main;
@include hover {
background-color: $primary_hover;
}
}
}
}
.tools {
width: 100%;
border-radius: $less_border_radius;
background-color: $primary;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
height: 40px;
&__item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
&-count {
border-left: 1px solid $primary_hover;
border-right: 1px solid $primary_hover;
color: $main;
font-size: 16px;
font-weight: 600;
}
&-button {
cursor: pointer;
background-color: $primary;
border-radius: $less_border_radius 0 0 $less_border_radius;
color: $main;
font-size: 18px;
font-weight: 500;
@include hover {
background-color: $primary_hover;
color: $main;
}
&:last-child {
border-radius: 0 $less_border_radius $less_border_radius 0;
}
}
}
}
:deep(.swiper-pagination) {
bottom: 0;
display: flex;
justify-content: center;
gap: 6px;
}
:deep(.swiper-pagination-line) {
display: inline-block;
width: 24px;
height: 2px;
background-color: rgba($primary_dark, 0.3);
border-radius: 0;
opacity: 1;
}
:deep(.swiper-pagination-line--active) {
background-color: $primary_dark;
}
</style>

View file

@ -1,317 +0,0 @@
<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: $primary_dark;
padding: 50px;
border-radius: $less_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 $primary_shadow;
text-transform: uppercase;
color: $text;
}
&__inner {
height: 100%;
width: 100%;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
padding: 10px 20px;
scrollbar-color: $text 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 $text;
color: $text;
font-weight: 500;
text-shadow: 0 0 5px $text;
text-transform: uppercase;
font-size: 22px;
}
&-item {
display: flex;
flex-direction: column;
gap: 5px;
& p {
padding-left: 50px;
color: $text;
font-weight: 500;
font-size: 16px;
}
}
}
&__preview {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&-wrapper {
position: relative;
background-color: $primary_dark;
border-radius: $less_border_radius;
padding: 20px;
border: 1px solid rgba($text, 0.3);
}
&-code {
margin: 0;
color: $main_hover;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
main-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: $text;
font-weight: 500;
font-size: 14px;
text-align: center;
}
}
&__buttons {
border-top: 1px solid $main;
padding-top: 25px;
display: flex;
align-items: center;
justify-content: center;
gap: 25px;
}
}
</style>

View file

@ -1,68 +0,0 @@
<template>
<button
type="button"
class="button"
:disabled="isDisabled"
:class="[{active: isLoading}]"
>
<ui-loader class="button__loader" v-if="isLoading" />
<slot v-else />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
isDisabled?: boolean;
isLoading?: boolean;
}>();
</script>
<style lang="scss" scoped>
.button {
position: relative;
width: fit-content;
cursor: pointer;
flex-shrink: 0;
border: 1px solid $text;
box-shadow: 0 0 10px 1px $text;
background-color: transparent;
border-radius: $less_border_radius;
padding: 7px 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
z-index: 1;
color: $text;
text-shadow: 0 0 10px $text;
text-align: center;
font-size: 16px;
font-weight: 500;
@include hover {
background-color: $text;
color: $main;
}
&.active {
background-color: $text;
color: $main;
}
&:disabled {
cursor: not-allowed;
background-color: transparent;
color: $main;
}
&:disabled:hover, &.active {
background-color: transparent;
color: $main;
}
&__loader {
margin-block: 4px;
}
}
</style>

View file

@ -1,187 +0,0 @@
<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 $text;
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: $text;
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: $main;
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: $text;
text-shadow: 0 0 8px $text;
}
.checkbox:hover .checkbox__box {
box-shadow: 0 0 10px $text;
}
.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: $primary_dark;
}
.checkbox:hover .checkbox__label::before {
color: $text;
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: $text;
animation: glitch-anim-text var(--glitch-anim-duration)
cubic-bezier(0.25, 0.46, 0.45, 0.94) reverse both;
}
@keyframes glitch-anim-checkbox {
0% {
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
20% {
transform: translate(calc(-50% - 3px), calc(-50% + 2px));
clip-path: inset(50% 0 20% 0);
}
40% {
transform: translate(calc(-50% + 2px), calc(-50% - 1px));
clip-path: inset(20% 0 60% 0);
}
60% {
transform: translate(calc(-50% - 2px), calc(-50% + 1px));
clip-path: inset(80% 0 5% 0);
}
80% {
transform: translate(calc(-50% + 2px), calc(-50% - 2px));
clip-path: inset(30% 0 45% 0);
}
100% {
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
}
@keyframes glitch-anim-text {
0% {
transform: translate(0);
clip-path: inset(0 0 0 0);
}
20% {
transform: translate(-3px, 2px);
clip-path: inset(50% 0 20% 0);
}
40% {
transform: translate(2px, -1px);
clip-path: inset(20% 0 60% 0);
}
60% {
transform: translate(-2px, 1px);
clip-path: inset(80% 0 5% 0);
}
80% {
transform: translate(2px, -2px);
clip-path: inset(30% 0 45% 0);
}
100% {
transform: translate(0);
clip-path: inset(0 0 0 0);
}
}
</style>

View file

@ -1,106 +0,0 @@
<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'"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
:inputMode="'tel'"
/>
<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"
/>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.sendMessage') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useContactUs} from '@composables/contact';
const { t } = useI18n();
const { required } = useValidators();
const name = ref<string>('');
const email = ref<string>('');
const phoneNumber = ref<string>('');
const subject = ref<string>('');
const message = ref<string>('');
const isFormValid = computed(() => {
return (
required(name.value) === true &&
required(email.value) === true &&
required(phoneNumber.value) === true &&
required(subject.value) === true &&
required(message.value) === true
);
});
const { contactUs, loading } = useContactUs();
async function handleContactUs() {
await contactUs(
name.value,
email.value,
phoneNumber.value,
subject.value,
message.value,
);
}
</script>
<style lang="scss" scoped>
.form {
width: 585px;
display: flex;
flex-direction: column;
gap: 24px;
border-radius: 16px;
border: 1px solid $border;
padding: 32px;
&__title {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -1,77 +0,0 @@
<template>
<form @submit.prevent="handleDeposit()" class="form">
<div class="form__box">
<ui-input
:type="'text'"
:placeholder="''"
v-model="amount"
:numberOnly="true"
:inputMode="'decimal'"
/>
<icon name="ic:baseline-compare-arrows" size="30" />
<ui-input
:type="'text'"
:placeholder="''"
v-model="amount"
:numberOnly="true"
:inputMode="'decimal'"
/>
</div>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.topUp') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useDeposit} from '@composables/user';
const { t } = useI18n();
const companyStore = useCompanyStore();
const { paymentMin, paymentMax } = usePaymentLimits();
const amount = ref<number>(5);
const isFormValid = computed(() => {
return (
amount.value >= paymentMin.value &&
amount.value <= paymentMax.value
);
});
const { deposit, loading } = useDeposit();
async function handleDeposit() {
await deposit(amount.value);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__box {
display: flex;
align-items: flex-start;
gap: 20px;
& span {
flex-shrink: 0;
align-self: center;
}
}
&__button {
width: fit-content;
padding-inline: 20px;
}
}
</style>

View file

@ -1,148 +0,0 @@
<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: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
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: $text;
}
}
</style>

View file

@ -1,110 +0,0 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.newPassword.title') }}</h2>
</div>
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:label="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:label="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.save') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useNewPassword} from '@composables/auth';
const { t } = useI18n();
const { isPasswordValid } = useValidators();
const password = ref<string>('');
const confirmPassword = ref<string>('');
const compareStrings = (v: string) => {
if (v === password.value) return true;
return t('errors.compare');
};
const isFormValid = computed(() => {
return (
isPasswordValid(password.value) === true &&
compareStrings(confirmPassword.value) === true
);
});
const { newPassword, loading } = useNewPassword();
async function handleReset() {
await newPassword(
password.value,
confirmPassword.value,
);
}
</script>
<style lang="scss" scoped>
.form {
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: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
}
</style>

View file

@ -1,243 +0,0 @@
<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: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
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 {
color: $primary;
@include hover {
color: $primary_hover;
}
}
&__login {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
& p {
text-align: center;
color: $text;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
}
</style>

View file

@ -1,115 +0,0 @@
<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: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
}
</style>

View file

@ -1,122 +0,0 @@
<template>
<form class="form" @submit.prevent="handleUpdate">
<div class="form__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__box">
<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"
/>
</div>
<div class="form__box">
<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"
/>
</div>
<ui-button
:type="'submit'"
class="form__button"
:isLoading="loading"
>
{{ t('buttons.saveChanges') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useUserUpdating} from '@composables/user';
const { t } = useI18n();
const userStore = useUserStore();
const { required, isEmail, isPasswordValid } = useValidators();
const user = computed(() => userStore.user);
const firstName = ref<string>('');
const lastName = ref<string>('');
const email = ref<string>('');
const phoneNumber = ref<string>('');
const password = ref<string>('');
const confirmPassword = ref<string>('');
const compareStrings = (v: string) => {
if (v === password.value) return true;
return t('errors.compare');
};
const { updateUser, loading } = useUserUpdating();
watchEffect(() => {
firstName.value = user.value?.firstName || '';
lastName.value = user.value?.lastName || '';
email.value = user.value?.email || '';
phoneNumber.value = user.value?.phoneNumber || '';
});
async function handleUpdate() {
await updateUser(
firstName.value,
lastName.value,
email.value,
phoneNumber.value,
password.value,
confirmPassword.value,
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__box {
display: flex;
align-items: flex-start;
gap: 20px;
}
&__button {
width: fit-content;
padding-inline: 20px;
}
}
</style>

View file

@ -1,85 +0,0 @@
<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: $primary_gradient;
border-radius: $default_border_radius;
padding: 32px 32px 32px 50px;
display: flex !important;
align-items: center;
justify-content: space-between;
}
&__block {
display: flex;
flex-direction: column;
gap: 10px;
}
&__title {
margin-bottom: 10px;
color: $main;
font-family: "Playfair Display", sans-serif;
font-size: 36px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtext {
color: $main;
font-size: 20px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__text {
color: $border;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__button {
margin-top: 20px;
width: fit-content;
background-color: $main;
padding: 15px 35px;
text-transform: uppercase;
color: $primary_dark;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.1px;
@include hover {
background-color: $primary_dark;
color: $main;
}
}
}
</style>

View file

@ -1,57 +0,0 @@
<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: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__posts {
display: flex;
align-items: stretch;
gap: 32px;
}
}
</style>

View file

@ -1,47 +0,0 @@
<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: $border;
padding-block: 65px;
&__item {
margin-right: 65px;
color: $text;
font-family: "Playfair Display", sans-serif;
font-size: 24px;
font-weight: 400;
letter-spacing: 1.9px;
@include hover {
text-shadow: 0 0 10px $primary_dark;
}
}
}
</style>

View file

@ -1,93 +0,0 @@
<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: $primary_dark;
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: $secondary;
font-size: 20px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
}
}
}
</style>

View file

@ -1,82 +0,0 @@
<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: $blackout;
}
&__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: $main;
text-align: center;
font-family: "Playfair Display", sans-serif;
font-size: 60px;
font-weight: 700;
letter-spacing: 1px;
}
&__text {
text-align: center;
color: $main;
font-size: 20px;
font-weight: 300;
letter-spacing: -0.5px;
}
&__button {
margin-top: 10px;
background-color: $main;
padding: 10px 35px;
color: $primary_dark;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.1px;
@include hover {
background-color: $primary_dark;
color: $main;
}
}
}
</style>

View file

@ -1,117 +0,0 @@
<template>
<nav class="nav">
<div class="nav__inner">
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('settings') }]"
to="/profile/settings"
>
<icon name="ic:baseline-settings" size="20" />
{{ t('profile.settings.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('orders') }]"
to="/profile/orders"
>
<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('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') }]"
to="/profile/promocodes"
>
<icon name="fluent:ticket-20-filled" size="20" />
{{ t('profile.promocodes.title') }}
</nuxt-link-locale>
</div>
<div class="nav__logout" @click="logout">
<icon name="material-symbols:power-settings-new-outline" size="20" />
{{ t('profile.logout') }}
</div>
</nav>
</template>
<script setup lang="ts">
import {useLogout} from '@composables/auth';
const {t} = useI18n();
const route = useRoute();
const { logout } = useLogout();
</script>
<style lang="scss" scoped>
.nav {
position: sticky;
top: 116px;
width: 256px;
flex-shrink: 0;
height: fit-content;
&__inner {
background-color: $main;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
border: 1px solid $border;
overflow: hidden;
}
&__item {
cursor: pointer;
padding: 16px 24px;
border-right: 2px solid transparent;
border-bottom: 1px solid $border;
display: flex;
align-items: center;
gap: 10px;
color: $text;
font-size: 16px;
font-weight: 500;
@include hover {
color: $primary;
background-color: $main_hover;
}
&.active {
border-right-color: $primary;
color: $primary;
background-color: $main_hover;
}
}
&__logout {
cursor: pointer;
margin-top: 25px;
border-radius: $less_border_radius;
background-color: rgba($primary, 0.2);
border: 1px solid $primary;
padding: 7px 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: $primary;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $primary;
color: $main;
}
}
}
</style>

View file

@ -1,48 +0,0 @@
<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: $skeleton;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
border: 1px solid $border;
padding: 23px;
&__image {
width: 100%;
height: 233px;
border-radius: $less_border_radius;
}
&__name {
width: 100%;
height: 20px;
}
}
</style>

View file

@ -1,88 +0,0 @@
<template>
<el-skeleton
class="sk"
animated
>
<template #template>
<div class="sk__main">
<el-skeleton-item
variant="p"
class="sk__text"
/>
<el-skeleton-item
variant="p"
class="sk__text"
/>
</div>
<div class="sk__bottom">
<div class="sk__bottom-images">
<el-skeleton-item
variant="image"
class="sk__image"
v-for="idx in 3"
:key="idx"
/>
</div>
<el-skeleton-item
variant="p"
class="sk__price"
/>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $less_border_radius;
background-color: $skeleton;
border: 1px solid $primary;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 10px 8px;
&__main {
width: 100%;
display: flex;
align-items: center;
gap: 25px;
padding-bottom: 5px;
}
&__text {
width: 100px;
height: 20px;
border-radius: $less_border_radius;
}
&__bottom {
border-top: 1px solid $primary;
padding-top: 10px;
display: flex;
justify-content: space-between;
&-images {
display: flex;
align-items: center;
gap: 10px;
}
}
&__image {
width: 52px;
height: 65px;
border-radius: $less_border_radius;
}
&__price {
width: 60px;
height: 20px;
}
}
</style>

View file

@ -1,169 +0,0 @@
<template>
<el-skeleton
class="sk"
:class="[{'sk__list': isList }]"
animated
>
<template #template>
<div class="sk__content">
<el-skeleton-item
variant="image"
class="sk__image"
/>
<div class="sk__content-wrapper">
<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"
/>
<el-skeleton-item
variant="p"
class="sk__button"
/>
</div>
</div>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
const props = defineProps<{
isList?: boolean;
}>();
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $default_border_radius;
border: 1px solid $border;
background-color: $skeleton;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: flex-start;
padding: 15px;
& .sk__content {
width: 100%;
display: flex;
flex-direction: row;
gap: 50px;
&-wrapper {
width: 100%;
padding: 0;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
&-inner {
align-self: flex-start;
}
}
& .sk__image {
width: 150px;
height: 150px;
}
& .sk__price {
width: 100px;
}
& .sk__quantity {
width: 100px;
}
& .sk__buttons {
width: fit-content;
margin-top: 0;
flex-direction: column;
align-items: flex-end;
}
& .sk__button {
&:first-child {
width: 140px;
}
}
}
&__image {
width: 100%;
height: 200px;
border-radius: $less_border_radius;
}
&__content {
&-wrapper {
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__price {
width: 50px;
height: 20px;
}
&__name {
width: 100%;
height: 17px;
}
&__rating {
margin-block: 4px 10px;
width: 120px;
height: 16px;
}
&__brand {
width: 75px;
height: 15px;
}
&__buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
&__button {
width: 100%;
height: 40px;
}
}
</style>

View file

@ -1,46 +0,0 @@
<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;
background-color: $skeleton;
&__block {
display: flex;
flex-direction: column;
gap: 10px;
}
&__title {
width: 200px;
height: 30px;
}
&__text {
width: 100%;
height: 21px;
}
}
//:deep(.el-skeleton__item) {
// --el-skeleton-color: #c9ccd0 !important;
// --el-skeleton-to-color: #c3c3c7 !important;
//}
</style>

View file

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

View file

@ -1,316 +0,0 @@
<template>
<div class="filters">
<div class="filters__top">
<h2>{{ t('store.filters.title') }}</h2>
<p @click="resetFilters">{{ t('buttons.clearAll') }}</p>
</div>
<client-only>
<el-collapse v-model="collapse">
<el-collapse-item
name="0"
>
<template #title="{ isActive }">
<div class="filters__head">
<h3 class="filters__name" v-text="t('store.filters.price')" />
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filters__head-icon"
:class="[{ active: isActive }]"
/>
</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="{ isActive }">
<div class="filters__head">
<h3 class="filters__name" v-text="attribute.attributeName" />
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filters__head-icon"
:class="[{ active: isActive }]"
/>
</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>
</client-only>
</div>
</template>
<script setup lang="ts">
import type {IStoreFilters} from '@types';
import {useFilters} from '@composables/store';
import {useRouteQuery} from '@vueuse/router';
import {CURRENCY} from "~/constants";
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 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(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) => `${CURRENCY}${value.toFixed(2)}`;
</script>
<style scoped lang="scss">
.filters {
width: 290px;
border: 1px solid $border;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
& h2 {
color: $primary_dark;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.5px;
}
& p {
cursor: pointer;
color: $link_primary;
font-size: 12px;
font-weight: 400;
letter-spacing: -0.5px;
@include hover {
color: $link_primary_hover;
}
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
&-icon {
color: $secondary;
&.active {
transform: rotate(-180deg);
}
}
}
&__list {
list-style: none;
}
&__name {
font-size: 16px;
font-weight: 500;
color: $primary_dark;
letter-spacing: -0.5px;
}
&__price {
&-inputs {
display: flex;
align-items: center;
gap: 10px;
}
}
}
:deep(.block__input) {
font-size: 12px;
padding: 5px 10px;
}
</style>

View file

@ -1,226 +0,0 @@
<template>
<div class="store">
<store-filter
v-if="filters.length"
:filterableAttributes="filters"
@update:selected="onFiltersChange"
:initialMinPrice="Number(minPrice)"
:initialMaxPrice="Number(maxPrice)"
:categoryMinPrice="minMaxPrices.minPrice"
:categoryMaxPrice="minMaxPrices.maxPrice"
@filterMinPrice="updateMinPrice"
@filterMaxPrice="updateMaxPrice"
/>
<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-if="pending"
v-for="idx in 12"
:key="idx"
:isList="productView === 'list'"
/>
</div>
</client-only>
<div class="store__list-observer" ref="observer"></div>
<p class="store__empty" v-if="!products.length && !pending">{{ t('store.empty') }}</p>
</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 { t, 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, minMaxPrices } = 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();
}
const updateMinPrice = useDebounceFn((filteredPrice) => {
minPrice.value = filteredPrice;
}, 500);
const updateMaxPrice = useDebounceFn((filteredPrice) => {
maxPrice.value = filteredPrice;
}, 500);
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage && !pending.value) {
variables.productAfter = pageInfo.value.endCursor;
await getProducts();
}
},
);
watch(
[orderBy, attributes, minPrice, maxPrice],
async ([newOrder, newAttr, newMin, newMax]) => {
variables.orderBy = newOrder || '';
variables.attributes = newAttr;
variables.minPrice = Number(newMin) || 0;
variables.maxPrice = Number(newMax) || 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: 32px;
&-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;
}
}
&__empty {
color: $primary;
font-weight: 500;
font-size: 18px;
}
}
</style>

View file

@ -1,136 +0,0 @@
<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 {
width: 100%;
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 40px;
border-radius: $default_border_radius;
border: 1px solid $border;
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: $text;
}
}
&__view {
display: flex;
align-items: center;
border-radius: $less_border_radius;
border: 1px solid $border;
&-button {
cursor: pointer;
background-color: $main;
display: grid;
place-items: center;
padding: 10px;
color: $primary_dark;
@include hover {
background-color: $main_hover;
}
&.active {
background-color: $link_secondary;
}
}
}
}
</style>

View file

@ -1,51 +0,0 @@
<template>
<client-only>
<el-breadcrumb separator="/" class="breadcrumbs">
<el-breadcrumb-item
v-for="(crumb, idx) in breadcrumbs"
:key="idx"
>
<nuxt-link-locale
v-if="idx !== breadcrumbs.length - 1"
:to="crumb.link"
class="breadcrumbs__link"
>
{{ crumb.text }}
</nuxt-link-locale>
<span v-else class="breadcrumbs__current">
{{ crumb.text }}
</span>
</el-breadcrumb-item>
</el-breadcrumb>
</client-only>
</template>
<script setup lang="ts">
import {useBreadcrumbs} from '@composables/breadcrumbs';
const { breadcrumbs } = useBreadcrumbs();
</script>
<style scoped lang="scss">
.breadcrumbs {
background-color: $title_bg;
padding: 15px 250px 15px 50px;
line-height: 140%;
border-bottom: 1px solid $border;
&__link {
cursor: pointer !important;
color: $primary !important;
font-weight: 600 !important;
@include hover {
color: $primary_dark !important;
}
}
&__current {
font-weight: 600;
color: $primary_dark;
}
}
</style>

View file

@ -1,89 +0,0 @@
<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;
background-color: $primary;
border-radius: $default_border_radius;
padding-block: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
z-index: 1;
color: $main;
text-align: center;
font-size: 16px;
font-weight: 500;
&.secondary {
background-color: $main;
border: 1px solid $border;
color: $secondary;
@include hover {
background-color: $border;
}
&.active {
background-color: $border;
}
&:disabled {
cursor: not-allowed;
background-color: $disabled_secondary;
}
&:disabled:hover, &.active {
background-color: $disabled_secondary;
}
}
@include hover {
background-color: $primary_hover;
}
&.active {
background-color: $primary_hover;
}
&:disabled {
cursor: not-allowed;
background-color: $disabled;
}
&:disabled:hover, &.active {
background-color: $disabled;
}
&__loader {
margin-block: 4px;
}
}
</style>

View file

@ -1,92 +0,0 @@
<template>
<label class="checkbox" :class="{ isPrimary }">
<input
:id="id"
class="checkbox__input"
type="checkbox"
:checked="modelValue"
@change="onChange"
/>
<span class="checkbox__block"></span>
<span class="checkbox__label">
<slot/>
</span>
</label>
</template>
<script setup lang="ts">
const props = defineProps<{
id?: string;
modelValue: boolean;
isPrimary?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
}>();
function onChange(e: Event) {
const checked = (e.target as HTMLInputElement).checked;
emit('update:modelValue', checked);
}
</script>
<style lang="scss" scoped>
.checkbox {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
&.isPrimary {
& .checkbox__block {
border: 2px solid $primary_dark;
border-radius: $less_border_radius;
}
& .checkbox__label {
color: $primary_dark;
font-weight: 600;
}
}
&__input {
display: none;
opacity: 0;
}
&__block {
cursor: pointer;
display: inline-block;
width: 16px;
height: 16px;
border: 0.5px solid $primary_dark;
border-radius: 1px;
position: relative;
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background-color: $primary_dark;
border-radius: 2px;
opacity: 0;
}
}
&__label {
cursor: pointer;
color: $secondary;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
.checkbox__input:checked + .checkbox__block::after {
opacity: 1;
}
</style>

View file

@ -1,168 +0,0 @@
<template>
<div class="block">
<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>
</template>
<script setup lang="ts">
type Rule = (value: string) => boolean | string;
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
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<string>(props.type);
const isValid = ref<boolean>(true);
const errorMessage = ref<string>('');
function togglePasswordVisible() {
isPasswordVisible.value =
isPasswordVisible.value === 'password' ? 'text' : 'password';
}
const onlyNumbersKeydown = (event: KeyboardEvent) => {
if (!/^\d$/.test(event.key) &&
!['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
}
};
function onInput(e: Event) {
const target = e.target as HTMLInputElement;
let value = target.value;
if (props.numberOnly) {
const digitsOnly = value.replace(/\D/g, '');
if (digitsOnly !== value) {
target.value = digitsOnly;
value = digitsOnly;
}
}
let valid = true;
errorMessage.value = '';
props.rules?.forEach((rule) => {
const result = rule(value);
if (result !== true) {
valid = false;
errorMessage.value = String(result);
}
})
isValid.value = valid;
emit('update:modelValue', props.numberOnly ? Number(value) : value);
}
</script>
<style lang="scss" scoped>
.block {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
position: relative;
&__wrapper {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
&-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
}
&__label {
color: $secondary;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
}
&__input {
width: 100%;
padding: 14px 12px;
border: 1px solid $border;
border-radius: $default_border_radius;
background-color: $main;
color: $primary_dark;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
&::placeholder {
color: $disabled_secondary;
}
}
&__eyes {
cursor: pointer;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
background-color: transparent;
display: grid;
place-items: center;
font-size: 18px;
color: $disabled_secondary;
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

@ -1,143 +0,0 @@
<template>
<div class="switcher" ref="switcherRef">
<div
@click="setSwitcherVisible(!isSwitcherVisible)"
class="switcher__button"
:class="[{ active: isSwitcherVisible }]"
>
<client-only>
<!-- <icon name="fluent:globe-20-filled" size="20" />-->
<nuxt-img
format="webp"
densities="x1"
v-if="currentLocale"
:src="currentLocale.flag"
:alt="currentLocale.code"
/>
<!-- <skeletons-ui-language-switcher v-else />-->
<template #fallback>
<!-- <skeletons-ui-language-switcher />-->
</template>
</client-only>
</div>
<client-only>
<div
class="switcher__menu"
:class="[{active: isSwitcherVisible}]"
>
<div class="switcher__menu-wrapper">
<nuxt-img
class="switcher__menu-button"
v-for="locale of locales"
:key="locale.code"
format="webp"
densities="x1"
@click="uiSwitchLanguage(locale.code)"
:src="locale.flag"
:alt="locale.code"
/>
</div>
</div>
</client-only>
</div>
</template>
<script setup lang="ts">
import {onClickOutside} from '@vueuse/core';
import {useLanguageSwitch} from '@composables/languages';
const languageStore = useLanguageStore();
const locales = computed(() => languageStore.languages);
const currentLocale = computed(() => languageStore.currentLocale);
const isSwitcherVisible = ref<boolean>(false);
const setSwitcherVisible = (state: boolean) => {
isSwitcherVisible.value = state;
};
const switcherRef = ref(null);
onClickOutside(switcherRef, () => isSwitcherVisible.value = false);
const { switchLanguage } = useLanguageSwitch();
const uiSwitchLanguage = (localeCode: string) => {
switchLanguage(localeCode);
setSwitcherVisible(false);
};
</script>
<style lang="scss" scoped>
.switcher {
position: relative;
z-index: 1;
width: 44px;
flex-shrink: 0;
&__button {
width: 100%;
cursor: pointer;
display: flex;
gap: 5px;
padding: 5px;
border-radius: $less_border_radius;
@include hover {
background-color: $primary_shadow;
}
&.active {
background-color: $primary_shadow;
}
& img {
width: 100%;
}
}
&__menu {
position: absolute;
z-index: 3;
top: 110%;
left: 50%;
transform: translateX(-50%);
width: 100%;
overflow: hidden;
border-radius: $less_border_radius;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
&-wrapper {
display: flex;
flex-direction: column;
}
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
&-button {
width: 100%;
cursor: pointer;
padding: 3px 5px;
background-color: $link_secondary;
&:first-child {
padding-top: 5px;
}
&:last-child {
padding-bottom: 5px;
}
@include hover {
background-color: $link_secondary_hover;
}
}
}
}
</style>

View file

@ -1,55 +0,0 @@
<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;
cursor: pointer;
color: $primary;
font-size: 14px;
font-weight: 400;
&--clickable {
cursor: pointer;
}
@include hover {
color: $secondary;
}
}
</style>

View file

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

View file

@ -1,297 +0,0 @@
<template>
<div class="search">
<div class="container">
<div class="search__inner">
<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="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>
</div>
</div>
<transition name="opacity" mode="out-in">
<div
class="search__bg"
@click="toggleSearch(false)"
v-if="isSearchActive"
/>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSearchUI } from '@composables/search';
const {t} = useI18n();
const router = useRouter();
const appStore = useAppStore();
const {
query,
isSearchActive,
loading,
searchResults,
filteredSearchResults,
hasResults,
getBlockTitle,
clearSearch,
toggleSearch
} = useSearchUI();
function submitSearch() {
if (query.value) {
router.push({
path: '/search',
query: { q: query.value }
})
toggleSearch(false);
}
}
watch(
() => isSearchActive.value,
(state) => {
appStore.setOverflowHidden(state);
},
{ immediate: true }
);
function goTo(category: string, item: any) {
let path = "/";
switch (category) {
case "products": {
path = `/product/${item.slug}`;
break;
}
case "categories": {
path = `/catalog/${item.slug}`;
break;
}
case "brands": {
path = `/brand/${item.slug}`;
break;
}
case "posts": {
path = "/";
break;
}
}
toggleSearch(false);
router.push(path);
}
</script>
<style lang="scss" scoped>
.search {
width: 100%;
background-color: $main;
&__inner {
padding-block: 10px;
width: 100%;
position: relative;
z-index: 1;
height: 100%;
}
&__bg {
background-color: rgba(0, 0, 0, 0.2);
height: 100vh;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 1;
}
&__wrapper {
width: 100%;
background-color: $border;
border-radius: $less_border_radius;
position: relative;
z-index: 2;
&.active {
background-color: $main;
box-shadow: 0 0 0 1px #0000000a,0 4px 4px #0000000a,0 20px 40px #00000014;
& .search__wrapper {
border-radius: $less_border_radius $less_border_radius 0 0;
}
& .search__form input {
border-radius: $less_border_radius $less_border_radius 0 0;
}
}
@include hover {
background-color: $main;
box-shadow: 0 0 0 1px #0000000a,0 4px 4px #0000000a,0 20px 40px #00000014;
}
}
&__form {
width: 100%;
height: 40px;
position: relative;
& input {
background-color: transparent;
width: 100%;
height: 100%;
padding-inline: 20px 150px;
border: 1px solid $link_secondary;
border-radius: $less_border_radius;
color: $secondary;
font-size: 16px;
font-weight: 500;
}
}
&__tools {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 10px;
& button {
cursor: pointer;
display: grid;
place-items: center;
border-radius: $less_border_radius;
padding: 5px 12px;
border: 1px solid $primary;
background-color: transparent;
font-size: 12px;
color: $primary;
@include hover {
background-color: $primary;
color: $main;
}
}
&-line {
background-color: $primary;
height: 15px;
width: 1px;
}
}
&__results {
position: absolute;
z-index: 1;
top: 100%;
width: 100%;
max-height: 0;
overflow: auto;
background-color: $main;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.0392156863), 0 4px 4px rgba(0, 0, 0, 0.0392156863), 0 20px 40px rgba(0, 0, 0, 0.0784313725);
&.active {
max-height: 40vh;
}
&-title {
background-color: rgba($primary, 0.2);
padding: 7px 20px;
font-weight: 600;
}
&-empty {
padding: 10px 20px;
font-size: 14px;
}
}
&__item {
cursor: pointer;
padding: 7px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
font-size: 14px;
@include hover {
background-color: $main_hover;
}
&-left {
display: flex;
align-items: center;
gap: 15px;
}
& p {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
& span {
color: $text;
}
}
}
</style>

View file

@ -1,112 +0,0 @@
<template>
<div class="block">
<div class="block__inner">
<label v-if="label" class="block__label">{{ label }}</label>
<textarea
:placeholder="placeholder"
:value="modelValue"
@input="onInput"
class="block__textarea"
/>
</div>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
type Rule = (value: string) => boolean | string;
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
placeholder: string;
modelValue?: string;
rules?: Rule[];
label?: string;
}>();
const isValid = ref(true);
const errorMessage = ref('');
const onInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
const value = target.value;
isValid.value = true;
errorMessage.value = '';
props.rules?.forEach(rule => {
const result = rule(value);
if (result !== true) {
isValid.value = false;
errorMessage.value = String(result);
}
});
emit('update:modelValue', value);
};
</script>
<style lang="scss" scoped>
.block {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
position: relative;
&__inner {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
&__label {
color: $secondary;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
}
&__textarea {
width: 100%;
height: 150px;
resize: none;
padding: 6px 12px;
border: 1px solid $border;
border-radius: $less_border_radius;
background-color: $main;
color: $primary_dark;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
&::placeholder {
color: $disabled_secondary;
}
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

@ -1,34 +0,0 @@
<template>
<button
@click="toggleTheme"
class="theme"
>
<icon v-if="theme === 'light'" name="line-md:moon-alt-loop" size="22" />
<icon v-else name="line-md:sunny-outline-loop" size="22" />
</button>
</template>
<script setup lang="ts">
import { useThemes } from '@composables/themes';
const { theme, toggleTheme } = useThemes();
</script>
<style scoped lang="scss">
.theme {
background-color: transparent;
border-radius: $less_border_radius;
padding: 5px;
cursor: pointer;
color: $primary;
transition: all 0.3s ease;
@include hover {
background-color: $primary_shadow;
}
& span {
display: block;
}
}
</style>

View file

@ -1,70 +0,0 @@
<template>
<div class="title">
<div class="container">
<div class="title__wrapper">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.title {
padding-block: 50px;
background-color: $title_bg;
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
}
:deep(.title__wrapper h1) {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-weight: 700;
font-size: 36px;
letter-spacing: -0.5px;
}
:deep(.title__wrapper p) {
max-width: 600px;
text-align: center;
color: $text;
font-size: 18px;
font-weight: 400;
letter-spacing: -0.5px;
}
:deep(.title__wrapper .search) {
position: relative;
& span {
position: absolute;
top: 50%;
right: 24px;
transform: translateY(-50%);
color: $disabled_secondary;
}
& input {
background-color: $main;
border: 1px solid $border;
border-radius: 50px;
padding: 18px 70px 18px 50px;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
&::placeholder {
color: $text;
}
}
}
</style>

View file

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

View file

@ -1,79 +0,0 @@
import { useLocaleRedirect } from '@composables/languages';
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, $notify } = useNuxtApp();
const { checkAndRedirect } = useLocaleRedirect();
const { loadUserBaseData } = useUserBaseData();
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: () => $appHelpers.DEFAULT_LOCALE,
path: '/',
});
const { mutate, loading } = useMutation<ILoginResponse>(LOGIN);
async function login(email: string, password: string, isStayLogin: boolean) {
try {
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('/'));
$notify({
message: t('popup.success.login'),
type: 'success',
});
if (authData.user.language !== cookieLocale.value) {
await checkAndRedirect(authData.user.language);
}
await loadUserBaseData(authData.user.email);
} catch (err: unknown) {
console.error('useLogin error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
}
}
return {
loading,
login,
};
}

View file

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

View file

@ -1,57 +0,0 @@
import { NEW_PASSWORD } from '@graphql/mutations/auth.js';
import type { INewPasswordResponse } from '@types';
export function useNewPassword() {
const { t } = useI18n();
const router = useRouter();
const { $notify } = useNuxtApp();
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) {
$notify({
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;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
newPassword,
loading,
};
}

View file

@ -1,46 +0,0 @@
import { RESET_PASSWORD } from '@graphql/mutations/auth.js';
import type { IPasswordResetResponse } from '@types';
export function usePasswordReset() {
const { t } = useI18n();
const { $notify } = useNuxtApp();
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) {
$notify({
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;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
resetPassword,
loading,
};
}

View file

@ -1,121 +0,0 @@
import { useLogout } from '@composables/auth';
import { useLocaleRedirect } from '@composables/languages';
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, $notify } = useNuxtApp();
const { checkAndRedirect } = useLocaleRedirect();
const { loadUserBaseData } = useUserBaseData();
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: () => $appHelpers.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 loadUserBaseData(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;
}
$notify({
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;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
refresh,
loading,
};
}

View file

@ -1,83 +0,0 @@
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 { $notify } = useNuxtApp();
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);
$notify({
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;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
register,
loading,
};
}

View file

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

View file

@ -1,29 +0,0 @@
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,
});
if (!data.value?.brands?.edges?.length) {
throw createError({
status: 404,
statusText: 'Brand not found',
fatal: true
});
}
watch(error, (err) => {
if (err) {
console.error('useBrandsBySlug error:', err);
}
});
return {
brand,
seoMeta: computed(() => brand.value?.seoMeta),
};
}

View file

@ -1,83 +0,0 @@
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<boolean>(false);
const brands = ref<IBrand[]>([]);
const pageInfo = ref<{
hasNextPage: boolean;
endCursor: string;
}>({
hasNextPage: false,
endCursor: '',
});
const error = ref<string | null>(null);
const getBrands = async (): Promise<void> => {
pending.value = true;
const queryVariables = {
brandFirst: variables.first,
brandAfter: variables.brandAfter || undefined,
brandOrderBy: variables.orderBy || undefined,
brandName: variables.brandName || undefined,
brandSearch: variables.brandSearch || undefined,
};
const { data, error: mistake } = await useAsyncQuery<IBrandsResponse>(GET_BRANDS, queryVariables);
if (data.value?.brands?.edges) {
pageInfo.value = data.value?.brands.pageInfo;
if (variables.brandAfter) {
brands.value = [
...brands.value,
...data.value.brands.edges,
];
} else {
brands.value = data.value?.brands.edges;
}
}
if (mistake.value) {
error.value = mistake.value;
}
pending.value = false;
};
watch(error, (e) => {
if (e) console.error('useBrands error:', e);
});
return {
pending,
brands,
pageInfo,
variables,
getBrands,
};
}

View file

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

View file

@ -1,88 +0,0 @@
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;
});
return {
breadcrumbs,
};
}

View file

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

View file

@ -1,39 +0,0 @@
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,
};
}

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