Compare commits
147 commits
master
...
storefront
| Author | SHA1 | Date | |
|---|---|---|---|
| b21bbc8e7b | |||
| 0194d152fe | |||
| 114204b105 | |||
| 1f39535f05 | |||
| 18bb585a13 | |||
| 52687beeec | |||
| 01fe4192aa | |||
| 6c1dbe0c5f | |||
| c889c20b61 | |||
| ef8be78d51 | |||
| aba67ec177 | |||
| 69c224722e | |||
| 51e9418e54 | |||
| 574dc43d06 | |||
| f557fa462a | |||
| 9bf600845a | |||
| 398e11d748 | |||
| 03dbafaf44 | |||
| 7e31a80290 | |||
| 783fca1f28 | |||
| e60839b896 | |||
| 56688c9c09 | |||
| 2c4e66832f | |||
| d4b2839502 | |||
| e8e0675d7d | |||
| 556354a44d | |||
| 8d7685ef67 | |||
| 2ea18eb8a6 | |||
| a14be696e7 | |||
| e65e7b7d73 | |||
| c36135d78d | |||
| 8c4ec23f92 | |||
| af477362d2 | |||
| d5944436ba | |||
| faea55c257 | |||
| 1e1d0ef397 | |||
| 0429b62ba1 | |||
| c2052d62fd | |||
| cc11da01f9 | |||
| 9ae3b4433f | |||
| e065075c5a | |||
| 6b0d1ad1dc | |||
| e861b2344b | |||
| a560f258fa | |||
| c7b09f77f1 | |||
| 951dc10107 | |||
| f6e1e43665 | |||
| 1c615a987a | |||
| 7d0b6301ea | |||
| 3191220ee9 | |||
| 23fb126574 | |||
| 1885937244 | |||
| f6b1d6d9fc | |||
| 5464f31ab8 | |||
| 88572108d5 | |||
| 5491873585 | |||
| 3137e9945c | |||
| d6dd9fafe7 | |||
| 90bb8c3326 | |||
| d482438cef | |||
| 18c2f9c154 | |||
| 90bd5066c8 | |||
| bea3efadc9 | |||
| 375287bbb7 | |||
| ae1f291a51 | |||
| 7be460af67 | |||
| a63f02673e | |||
| 2ceef3cc15 | |||
| 8f12f9c085 | |||
| 4750bed093 | |||
| 3e58d47b08 | |||
| e34de24b59 | |||
| 0f53ac3710 | |||
| f571310ec8 | |||
| 3e20e70bd1 | |||
| 7907613fdb | |||
| b876946a78 | |||
| 292b26acce | |||
| e3d98f2361 | |||
| b489405783 | |||
| 57e5e49059 | |||
| 244d94831e | |||
| e868ec93d5 | |||
| 30e9bc444a | |||
| 5dd055b677 | |||
| aee8bd2770 | |||
| a77d18d33c | |||
| 9dcd867a19 | |||
| 137da76bb1 | |||
| ddb12b75c4 | |||
| 252b86636a | |||
| d791cbb83a | |||
| 945767fe01 | |||
| 6dd7b3568c | |||
| b4f5919e81 | |||
| 28011e3afc | |||
| d3e016c8d6 | |||
| 0f888baa45 | |||
| 53425b855d | |||
| c88e0d7569 | |||
| dccaa206d6 | |||
| 7a7d26f7d7 | |||
| 2ccb812b71 | |||
| d7f5ed4141 | |||
| 607af80235 | |||
| 48e3380cd0 | |||
| d8386fcd93 | |||
| 0b246bcd3b | |||
| ba8914486e | |||
| ca6554c6d1 | |||
| 04fa776623 | |||
| 1d1213813c | |||
| ea53f398a3 | |||
| 78e3a650ac | |||
| f289ea1e8e | |||
| 6fd21183fe | |||
| 1a6a9f666e | |||
| f370c0872f | |||
| 85ec39255b | |||
| 02c402c6de | |||
| c9807bd6d4 | |||
| 949e077942 | |||
| ab35cb0c85 | |||
| 063123d040 | |||
| 5f50281029 | |||
| 4a99d51077 | |||
| 3d4df235f2 | |||
| 87b62b32e8 | |||
| 492aeb85db | |||
| e639e49e7e | |||
| a70967db73 | |||
| b68911006b | |||
| dc19e1f0a0 | |||
| 282c0ae541 | |||
| e78d2fc652 | |||
| 9877633a2c | |||
| 40ae24a04c | |||
| 408dee727e | |||
| cb8e4fb2ab | |||
| 64730a1d4e | |||
| 4957039fc5 | |||
| 52b32bd608 | |||
| c60ac13e88 | |||
| 761fecf67f | |||
| 53df1f5b88 | |||
| a31ee9c6b1 | |||
| 129ad1a6fa |
309 changed files with 31326 additions and 14 deletions
|
|
@ -12,6 +12,7 @@ coverage.*
|
||||||
*.py,cover
|
*.py,cover
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Cache directories
|
# Cache directories
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
@ -51,6 +52,8 @@ 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/
|
||||||
|
|
|
||||||
40
Dockerfiles/storefront.Dockerfile
Normal file
40
Dockerfiles/storefront.Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# 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"]
|
||||||
|
|
@ -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,6 +214,30 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -619,6 +619,8 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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
Normal file
24
storefront/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
19
storefront/app.config.d.ts
vendored
Normal file
19
storefront/app.config.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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 {};
|
||||||
14
storefront/app/app.config.ts
Normal file
14
storefront/app/app.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { SUPPORTED_LOCALES } from '@appConstants';
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
i18n: {
|
||||||
|
supportedLocales: SUPPORTED_LOCALES,
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
showBreadcrumbs: true,
|
||||||
|
showSearchBar: true,
|
||||||
|
isHeaderFixed: true,
|
||||||
|
isAuthModals: false,
|
||||||
|
notificationPosition: 'top-right',
|
||||||
|
},
|
||||||
|
});
|
||||||
204
storefront/app/app.vue
Normal file
204
storefront/app/app.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
<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>
|
||||||
BIN
storefront/app/assets/fonts/Inter/Inter-Black.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Black.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-Bold.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-ExtraBold.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-Medium.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-Regular.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
storefront/app/assets/fonts/Inter/Inter-SemiBold.ttf
Normal file
BIN
storefront/app/assets/fonts/Inter/Inter-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
storefront/app/assets/fonts/SourceCodePro/SourceCodePro-Bold.ttf
Normal file
BIN
storefront/app/assets/fonts/SourceCodePro/SourceCodePro-Bold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
108
storefront/app/assets/styles/global/fonts.scss
Normal file
108
storefront/app/assets/styles/global/fonts.scss
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/* ===== SOURCE CODE PRO ===== */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-Black.ttf');
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-ExtraBold.ttf');
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-Bold.ttf');
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-SemiBold.ttf');
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-Medium.ttf');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-Regular.ttf');
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-Light.ttf');
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
src: url('../../fonts/SourceCodePro/SourceCodePro-ExtraLight.ttf');
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PLAYFAIR DISPLAY ===== */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Black.ttf');
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-ExtraBold.ttf');
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Bold.ttf');
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-SemiBold.ttf');
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Medium.ttf');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Regular.ttf');
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INTER ===== */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('../../fonts/Inter/Inter-Black.ttf');
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('../../fonts/Inter/Inter-ExtraBold.ttf');
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('../../fonts/Inter/Inter-Bold.ttf');
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('../../fonts/Inter/Inter-SemiBold.ttf');
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('../../fonts/Inter/Inter-Medium.ttf');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('../../fonts/Inter/Inter-Regular.ttf');
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
7
storefront/app/assets/styles/global/mixins.scss
Normal file
7
storefront/app/assets/styles/global/mixins.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
@mixin hover {
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
&:hover {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
storefront/app/assets/styles/global/variables.scss
Normal file
105
storefront/app/assets/styles/global/variables.scss
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
$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;
|
||||||
|
}
|
||||||
14
storefront/app/assets/styles/main.scss
Normal file
14
storefront/app/assets/styles/main.scss
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
@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";
|
||||||
|
|
||||||
|
|
||||||
76
storefront/app/assets/styles/modules/normalize.scss
vendored
Normal file
76
storefront/app/assets/styles/modules/normalize.scss
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
@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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
storefront/app/assets/styles/modules/transitions.scss
Normal file
28
storefront/app/assets/styles/modules/transitions.scss
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.opacity-enter-active,
|
||||||
|
.opacity-leave-active {
|
||||||
|
transition: 0.3s ease all;
|
||||||
|
}
|
||||||
|
.opacity-enter-from,
|
||||||
|
.opacity-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fromTop-enter-active,
|
||||||
|
.fromTop-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.fromTop-enter-from,
|
||||||
|
.fromTop-leave-to {
|
||||||
|
transform: translateY(-3rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fromLeft-enter-active,
|
||||||
|
.fromLeft-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.fromLeft-enter-from,
|
||||||
|
.fromLeft-leave-to {
|
||||||
|
transform: translateX(-3rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
5
storefront/app/assets/styles/ui/badge.scss
Normal file
5
storefront/app/assets/styles/ui/badge.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@use "../global/variables" as *;
|
||||||
|
|
||||||
|
.el-badge__content {
|
||||||
|
background-color: $disabled !important;
|
||||||
|
}
|
||||||
39
storefront/app/assets/styles/ui/collapse.scss
Normal file
39
storefront/app/assets/styles/ui/collapse.scss
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
52
storefront/app/assets/styles/ui/notification.scss
Normal file
52
storefront/app/assets/styles/ui/notification.scss
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
storefront/app/assets/styles/ui/rating.scss
Normal file
13
storefront/app/assets/styles/ui/rating.scss
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
7
storefront/app/assets/styles/ui/select.scss
Normal file
7
storefront/app/assets/styles/ui/select.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
@use "../global/variables" as *;
|
||||||
|
|
||||||
|
.el-select__wrapper {
|
||||||
|
height: 36px !important;
|
||||||
|
min-height: 36px !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
17
storefront/app/assets/styles/ui/slider.scss
Normal file
17
storefront/app/assets/styles/ui/slider.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
63
storefront/app/components/base/auth.vue
Normal file
63
storefront/app/components/base/auth.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<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>
|
||||||
133
storefront/app/components/base/footer/index.vue
Normal file
133
storefront/app/components/base/footer/index.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<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>
|
||||||
264
storefront/app/components/base/header/catalog.vue
Normal file
264
storefront/app/components/base/header/catalog.vue
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
<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>
|
||||||
362
storefront/app/components/base/header/index.vue
Normal file
362
storefront/app/components/base/header/index.vue
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
<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>
|
||||||
69
storefront/app/components/cards/brand.vue
Normal file
69
storefront/app/components/cards/brand.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<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>
|
||||||
69
storefront/app/components/cards/category.vue
Normal file
69
storefront/app/components/cards/category.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<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>
|
||||||
258
storefront/app/components/cards/order.vue
Normal file
258
storefront/app/components/cards/order.vue
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
<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>
|
||||||
67
storefront/app/components/cards/post.vue
Normal file
67
storefront/app/components/cards/post.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<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>
|
||||||
518
storefront/app/components/cards/product.vue
Normal file
518
storefront/app/components/cards/product.vue
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
<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>
|
||||||
317
storefront/app/components/demo/settings.vue
Normal file
317
storefront/app/components/demo/settings.vue
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="isDemoMode && isOpen" class="modal">
|
||||||
|
<div
|
||||||
|
class="modal__wrapper"
|
||||||
|
>
|
||||||
|
<demo-ui-button
|
||||||
|
@click="appStore.setDemoSettings(false)"
|
||||||
|
class="modal__close"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:close" size="30" />
|
||||||
|
</demo-ui-button>
|
||||||
|
<h2 class="modal__title">{{ toConstantCase(t('demo.settings.title')) }}</h2>
|
||||||
|
<div class="modal__inner">
|
||||||
|
<div class="modal__block">
|
||||||
|
<h3 class="modal__block-title">{{ toConstantCase(t('demo.settings.ui')) }}</h3>
|
||||||
|
<div
|
||||||
|
v-for="(flag, idx) in availableFlags"
|
||||||
|
:key="flag.key"
|
||||||
|
class="modal__block-item"
|
||||||
|
>
|
||||||
|
<demo-ui-checkbox
|
||||||
|
:label="toConstantCase(flag.label)"
|
||||||
|
v-model="localFlags[flag.key]"
|
||||||
|
:id="idx"
|
||||||
|
/>
|
||||||
|
<p>{{ flag.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<demo-ui-button @click="showCodePreview = !showCodePreview">{{ toConstantCase(t('demo.buttons.generateCode')) }}</demo-ui-button>
|
||||||
|
<div v-if="showCodePreview" class="modal__preview">
|
||||||
|
<div class="modal__preview-wrapper">
|
||||||
|
<pre class="modal__preview-code"><code class="language-typescript">{{ codePreview }}</code></pre>
|
||||||
|
<demo-ui-button
|
||||||
|
@click="copyConfig"
|
||||||
|
class="modal__preview-button"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:content-copy" size="16" />
|
||||||
|
</demo-ui-button>
|
||||||
|
</div>
|
||||||
|
<p class="modal__preview-text">{{ t('demo.preview.text') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal__buttons">
|
||||||
|
<demo-ui-button @click="resetToDefault">
|
||||||
|
<Icon name="material-symbols:refresh" size="20" />
|
||||||
|
{{ toConstantCase(t('demo.buttons.reset')) }}
|
||||||
|
</demo-ui-button>
|
||||||
|
<demo-ui-button @click="saveChanges">
|
||||||
|
<Icon name="material-symbols:save" size="20" />
|
||||||
|
{{ toConstantCase(t('demo.buttons.save')) }}
|
||||||
|
</demo-ui-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useProjectConfig } from '@composables/config';
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const {t} = useI18n();
|
||||||
|
|
||||||
|
const isOpen = computed(() => appStore.isDemoSettings);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDemoMode,
|
||||||
|
availableFlags,
|
||||||
|
setDemoFlag,
|
||||||
|
resetDemoFlags,
|
||||||
|
uiConfig,
|
||||||
|
generateConfigCode,
|
||||||
|
copyToClipboard
|
||||||
|
} = useProjectConfig();
|
||||||
|
|
||||||
|
const showCodePreview = ref<boolean>(false);
|
||||||
|
const localFlags = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const previewConfig = computed(() => {
|
||||||
|
const config: Record<string, boolean> = {};
|
||||||
|
availableFlags.value.forEach(flag => {
|
||||||
|
config[flag.key] = localFlags.value[flag.key] !== undefined
|
||||||
|
? localFlags.value[flag.key]
|
||||||
|
: flag.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatPreviewConfig = (config: Record<string, boolean>): string => {
|
||||||
|
const entries = Object.entries(config)
|
||||||
|
.map(([key, value]) => ` ${key}: ${value}`)
|
||||||
|
.join(',\n');
|
||||||
|
|
||||||
|
return `ui: {\n${entries}\n}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toConstantCase(text: string): string {
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
let placeholderIndex = 0;
|
||||||
|
|
||||||
|
const textWithPlaceholders = text.replace(/(['"])(.*?)\1/g, (match) => {
|
||||||
|
placeholders.push(match);
|
||||||
|
return `__QUOTE_PLACEHOLDER_${placeholderIndex++}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = textWithPlaceholders
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^A-Z0-9_]/g, '');
|
||||||
|
|
||||||
|
placeholders.forEach((placeholder, index) => {
|
||||||
|
result = result.replace(`__QUOTE_PLACEHOLDER_${index}__`, placeholder);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
const flags: Record<string, boolean> = {};
|
||||||
|
availableFlags.value.forEach(flag => {
|
||||||
|
flags[flag.key] = flag.value;
|
||||||
|
});
|
||||||
|
localFlags.value = flags;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const saveChanges = () => {
|
||||||
|
availableFlags.value.forEach(flag => {
|
||||||
|
const newValue = localFlags.value[flag.key];
|
||||||
|
const oldValue = flag.value;
|
||||||
|
|
||||||
|
if (newValue !== oldValue) {
|
||||||
|
setDemoFlag(flag.key, newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
appStore.setDemoSettings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToDefault = () => {
|
||||||
|
resetDemoFlags();
|
||||||
|
appStore.setDemoSettings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyConfig = async () => {
|
||||||
|
const code = codePreview.value;
|
||||||
|
await copyToClipboard(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const codePreview = computed(() => {
|
||||||
|
return formatPreviewConfig(previewConfig.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
|
||||||
|
&__wrapper {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
inset: 100px;
|
||||||
|
background-color: $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>
|
||||||
68
storefront/app/components/demo/ui/button.vue
Normal file
68
storefront/app/components/demo/ui/button.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<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>
|
||||||
187
storefront/app/components/demo/ui/checkbox.vue
Normal file
187
storefront/app/components/demo/ui/checkbox.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
<template>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="modelValue"
|
||||||
|
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span class="checkbox__box">
|
||||||
|
<span class="checkbox__mark"></span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="checkbox__label"
|
||||||
|
:data-text="label"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string;
|
||||||
|
modelValue: boolean;
|
||||||
|
id: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.checkbox {
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
--glitch-anim-duration: 0.3s;
|
||||||
|
|
||||||
|
& input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
border: 2px solid $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>
|
||||||
106
storefront/app/components/forms/contact.vue
Normal file
106
storefront/app/components/forms/contact.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<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>
|
||||||
77
storefront/app/components/forms/deposit.vue
Normal file
77
storefront/app/components/forms/deposit.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<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>
|
||||||
148
storefront/app/components/forms/login.vue
Normal file
148
storefront/app/components/forms/login.vue
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleLogin()" class="form">
|
||||||
|
<div class="form__top">
|
||||||
|
<h2 class="form__title">{{ t('forms.login.title') }}</h2>
|
||||||
|
<p class="form__subtitle">{{ t('forms.login.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="form__main">
|
||||||
|
<ui-input
|
||||||
|
:type="'email'"
|
||||||
|
:placeholder="t('fields.email')"
|
||||||
|
:label="t('fields.email')"
|
||||||
|
:rules="[isEmail]"
|
||||||
|
v-model="email"
|
||||||
|
:inputMode="'email'"
|
||||||
|
/>
|
||||||
|
<ui-input
|
||||||
|
:type="'password'"
|
||||||
|
:placeholder="t('fields.password')"
|
||||||
|
:label="t('fields.password')"
|
||||||
|
:rules="[required]"
|
||||||
|
v-model="password"
|
||||||
|
/>
|
||||||
|
<div class="form__main-block">
|
||||||
|
<ui-checkbox
|
||||||
|
v-model="isStayLogin"
|
||||||
|
>
|
||||||
|
{{ t('checkboxes.remember') }}
|
||||||
|
</ui-checkbox>
|
||||||
|
<ui-link
|
||||||
|
@click="redirectTo('reset-password')"
|
||||||
|
>
|
||||||
|
{{ t('forms.login.forgot') }}
|
||||||
|
</ui-link>
|
||||||
|
</div>
|
||||||
|
<ui-button
|
||||||
|
:type="'submit'"
|
||||||
|
class="form__button"
|
||||||
|
:isDisabled="!isFormValid"
|
||||||
|
:isLoading="loading"
|
||||||
|
>
|
||||||
|
{{ t('buttons.login') }}
|
||||||
|
</ui-button>
|
||||||
|
<p class="form__or">{{ t('forms.login.or') }}</p>
|
||||||
|
<ui-button
|
||||||
|
@click="redirectTo('sign-up')"
|
||||||
|
:type="'button'"
|
||||||
|
:style="'secondary'"
|
||||||
|
class="form__button"
|
||||||
|
>
|
||||||
|
{{ t('buttons.createAccount') }}
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {useLogin} from '@composables/auth';
|
||||||
|
import {useValidators} from '@composables/rules';
|
||||||
|
import {useProjectConfig} from "@composables/config";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
|
const { uiConfig } = useProjectConfig();
|
||||||
|
const { required, isEmail } = useValidators();
|
||||||
|
|
||||||
|
const email = ref<string>('');
|
||||||
|
const password = ref<string>('');
|
||||||
|
const isStayLogin = ref<boolean>(false);
|
||||||
|
|
||||||
|
const redirectTo = (to) => {
|
||||||
|
if (uiConfig.value.isAuthModals) {
|
||||||
|
appStore.setActiveAuthState(to);
|
||||||
|
} else {
|
||||||
|
navigateTo(localePath(`/auth/${to}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return (
|
||||||
|
isEmail(email.value) === true &&
|
||||||
|
required(password.value) === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { login, loading } = useLogin();
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
await login(email.value, password.value, isStayLogin.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.form {
|
||||||
|
width: 450px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: $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>
|
||||||
110
storefront/app/components/forms/new-password.vue
Normal file
110
storefront/app/components/forms/new-password.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<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>
|
||||||
243
storefront/app/components/forms/register.vue
Normal file
243
storefront/app/components/forms/register.vue
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
<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>
|
||||||
115
storefront/app/components/forms/reset-password.vue
Normal file
115
storefront/app/components/forms/reset-password.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleReset()" class="form">
|
||||||
|
<div class="form__top">
|
||||||
|
<h2 class="form__title">{{ t('forms.reset.title') }}</h2>
|
||||||
|
<p class="form__subtitle">{{ t('forms.reset.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="form__main">
|
||||||
|
<ui-input
|
||||||
|
:type="'email'"
|
||||||
|
:placeholder="t('fields.email')"
|
||||||
|
:label="t('fields.email')"
|
||||||
|
:rules="[isEmail]"
|
||||||
|
v-model="email"
|
||||||
|
:inputMode="'email'"
|
||||||
|
/>
|
||||||
|
<ui-button
|
||||||
|
:type="'submit'"
|
||||||
|
class="form__button"
|
||||||
|
:isDisabled="!isFormValid"
|
||||||
|
:isLoading="loading"
|
||||||
|
>
|
||||||
|
{{ t('buttons.sendLink') }}
|
||||||
|
</ui-button>
|
||||||
|
<ui-link
|
||||||
|
@click="redirectTo('sign-in')"
|
||||||
|
>
|
||||||
|
<icon name="material-symbols:arrow-left-alt" size="20" />
|
||||||
|
{{ t('forms.reset.backToLogin') }}
|
||||||
|
</ui-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {useValidators} from '@composables/rules';
|
||||||
|
import {usePasswordReset} from '@composables/auth';
|
||||||
|
import {useProjectConfig} from "@composables/config";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
|
const { uiConfig } = useProjectConfig();
|
||||||
|
const { isEmail } = useValidators();
|
||||||
|
|
||||||
|
const email = ref<string>('');
|
||||||
|
|
||||||
|
const redirectTo = (to) => {
|
||||||
|
if (uiConfig.value.isAuthModals) {
|
||||||
|
appStore.setActiveAuthState(to);
|
||||||
|
} else {
|
||||||
|
navigateTo(localePath(`/auth/${to}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return (
|
||||||
|
isEmail(email.value) === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resetPassword, loading } = usePasswordReset();
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
await resetPassword(email.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.form {
|
||||||
|
width: 450px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
|
||||||
|
&__top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: $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>
|
||||||
122
storefront/app/components/forms/update.vue
Normal file
122
storefront/app/components/forms/update.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<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>
|
||||||
85
storefront/app/components/home/ad.vue
Normal file
85
storefront/app/components/home/ad.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<template>
|
||||||
|
<div class="ad">
|
||||||
|
<div class="container">
|
||||||
|
<div class="ad__wrapper">
|
||||||
|
<div class="ad__block">
|
||||||
|
<h2 class="ad__title">{{ t('home.ad.title') }}</h2>
|
||||||
|
<p class="ad__subtext">{{ t('home.ad.text1') }}</p>
|
||||||
|
<p class="ad__text">{{ t('home.ad.text2') }}</p>
|
||||||
|
<nuxt-link-locale to="/shop" class="ad__button">{{ t('buttons.shopTheSale') }}</nuxt-link-locale>
|
||||||
|
</div>
|
||||||
|
<nuxt-img
|
||||||
|
format="webp"
|
||||||
|
densities="x1"
|
||||||
|
src="/images/saleImage.png"
|
||||||
|
alt="ad"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {t} = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ad {
|
||||||
|
&__wrapper {
|
||||||
|
background: $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>
|
||||||
57
storefront/app/components/home/blog.vue
Normal file
57
storefront/app/components/home/blog.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<div class="blog">
|
||||||
|
<div class="container">
|
||||||
|
<div class="blog__wrapper">
|
||||||
|
<h2 class="blog__title">{{ t('home.blog.title') }}</h2>
|
||||||
|
<div class="blog__posts">
|
||||||
|
<cards-post
|
||||||
|
v-for="post in filteredPosts.slice(0, 3)"
|
||||||
|
:key="post.node.id"
|
||||||
|
:post="post.node"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {IPost} from '@types';
|
||||||
|
import { docsSlugs } from '@appConstants';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
posts: { node: IPost }[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const {t} = useI18n();
|
||||||
|
|
||||||
|
const filteredPosts = computed(() => {
|
||||||
|
const excludedSlugs = Object.values(docsSlugs);
|
||||||
|
return props.posts?.filter(post => !excludedSlugs.includes(post.node.slug));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.blog {
|
||||||
|
&__wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: $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>
|
||||||
47
storefront/app/components/home/brands.vue
Normal file
47
storefront/app/components/home/brands.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<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>
|
||||||
93
storefront/app/components/home/categories.vue
Normal file
93
storefront/app/components/home/categories.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<div class="categories">
|
||||||
|
<div class="container">
|
||||||
|
<div class="categories__wrapper">
|
||||||
|
<h2 class="categories__title">{{ t('home.categories.title') }}</h2>
|
||||||
|
<swiper
|
||||||
|
class="swiper"
|
||||||
|
:modules="[Pagination]"
|
||||||
|
:spaceBetween="24"
|
||||||
|
:breakpoints="{
|
||||||
|
200: {
|
||||||
|
slidesPerView: 3
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<swiper-slide
|
||||||
|
class="swiper__slide"
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.node.uuid"
|
||||||
|
>
|
||||||
|
<nuxt-link-locale :to="`/catalog/${category.node.slug}`">
|
||||||
|
<nuxt-img
|
||||||
|
format="webp"
|
||||||
|
densities="x1"
|
||||||
|
:src="category.node.image"
|
||||||
|
:alt="category.node.name"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p>{{ category.node.name }}</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link-locale>
|
||||||
|
</swiper-slide>
|
||||||
|
</swiper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {Pagination} from "swiper/modules";
|
||||||
|
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||||
|
|
||||||
|
const {t} = useI18n();
|
||||||
|
const categoryStore = useCategoryStore();
|
||||||
|
|
||||||
|
const categories = computed(() => categoryStore.categories);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.categories {
|
||||||
|
&__wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: $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>
|
||||||
82
storefront/app/components/home/hero.vue
Normal file
82
storefront/app/components/home/hero.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<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>
|
||||||
117
storefront/app/components/profile/navigation.vue
Normal file
117
storefront/app/components/profile/navigation.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<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>
|
||||||
48
storefront/app/components/skeletons/cards/brand.vue
Normal file
48
storefront/app/components/skeletons/cards/brand.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<el-skeleton
|
||||||
|
class="sk"
|
||||||
|
animated
|
||||||
|
>
|
||||||
|
<template #template>
|
||||||
|
<el-skeleton-item
|
||||||
|
variant="image"
|
||||||
|
class="sk__image"
|
||||||
|
/>
|
||||||
|
<el-skeleton-item
|
||||||
|
variant="p"
|
||||||
|
class="sk__name"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-skeleton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isList?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sk {
|
||||||
|
width: 100%;
|
||||||
|
background-color: $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>
|
||||||
88
storefront/app/components/skeletons/cards/order.vue
Normal file
88
storefront/app/components/skeletons/cards/order.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<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>
|
||||||
169
storefront/app/components/skeletons/cards/product.vue
Normal file
169
storefront/app/components/skeletons/cards/product.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<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>
|
||||||
46
storefront/app/components/skeletons/docs.vue
Normal file
46
storefront/app/components/skeletons/docs.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<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>
|
||||||
31
storefront/app/components/skeletons/header/search.vue
Normal file
31
storefront/app/components/skeletons/header/search.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<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>
|
||||||
316
storefront/app/components/store/filter.vue
Normal file
316
storefront/app/components/store/filter.vue
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
<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>
|
||||||
226
storefront/app/components/store/index.vue
Normal file
226
storefront/app/components/store/index.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
<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>
|
||||||
136
storefront/app/components/store/top.vue
Normal file
136
storefront/app/components/store/top.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<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>
|
||||||
51
storefront/app/components/ui/breadcrumbs.vue
Normal file
51
storefront/app/components/ui/breadcrumbs.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<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>
|
||||||
89
storefront/app/components/ui/button.vue
Normal file
89
storefront/app/components/ui/button.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<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>
|
||||||
92
storefront/app/components/ui/checkbox.vue
Normal file
92
storefront/app/components/ui/checkbox.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<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>
|
||||||
168
storefront/app/components/ui/input.vue
Normal file
168
storefront/app/components/ui/input.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<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>
|
||||||
143
storefront/app/components/ui/language-switcher.vue
Normal file
143
storefront/app/components/ui/language-switcher.vue
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<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>
|
||||||
55
storefront/app/components/ui/link.vue
Normal file
55
storefront/app/components/ui/link.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<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>
|
||||||
59
storefront/app/components/ui/loader.vue
Normal file
59
storefront/app/components/ui/loader.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<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>
|
||||||
297
storefront/app/components/ui/search.vue
Normal file
297
storefront/app/components/ui/search.vue
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<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>
|
||||||
112
storefront/app/components/ui/textarea.vue
Normal file
112
storefront/app/components/ui/textarea.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<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>
|
||||||
34
storefront/app/components/ui/theme-toggle.vue
Normal file
34
storefront/app/components/ui/theme-toggle.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<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>
|
||||||
70
storefront/app/components/ui/title.vue
Normal file
70
storefront/app/components/ui/title.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<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>
|
||||||
6
storefront/app/composables/auth/index.ts
Normal file
6
storefront/app/composables/auth/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from './useLogin';
|
||||||
|
export * from './useLogout';
|
||||||
|
export * from './useNewPassword';
|
||||||
|
export * from './usePasswordReset';
|
||||||
|
export * from './useRefresh';
|
||||||
|
export * from './useRegister';
|
||||||
79
storefront/app/composables/auth/useLogin.ts
Normal file
79
storefront/app/composables/auth/useLogin.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
33
storefront/app/composables/auth/useLogout.ts
Normal file
33
storefront/app/composables/auth/useLogout.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export function useLogout() {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const cartStore = useCartStore();
|
||||||
|
const wishlistStore = useWishlistStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { $appHelpers } = useNuxtApp();
|
||||||
|
|
||||||
|
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
|
||||||
|
default: () => '',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
|
||||||
|
default: () => '',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
userStore.setUser(null);
|
||||||
|
cartStore.setCurrentOrders(null);
|
||||||
|
wishlistStore.setWishlist(null);
|
||||||
|
|
||||||
|
cookieRefresh.value = '';
|
||||||
|
cookieAccess.value = '';
|
||||||
|
|
||||||
|
await router.push({
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
57
storefront/app/composables/auth/useNewPassword.ts
Normal file
57
storefront/app/composables/auth/useNewPassword.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
46
storefront/app/composables/auth/usePasswordReset.ts
Normal file
46
storefront/app/composables/auth/usePasswordReset.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
121
storefront/app/composables/auth/useRefresh.ts
Normal file
121
storefront/app/composables/auth/useRefresh.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
storefront/app/composables/auth/useRegister.ts
Normal file
83
storefront/app/composables/auth/useRegister.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
2
storefront/app/composables/brands/index.ts
Normal file
2
storefront/app/composables/brands/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './useBrandBySlug';
|
||||||
|
export * from './useBrands';
|
||||||
29
storefront/app/composables/brands/useBrandBySlug.ts
Normal file
29
storefront/app/composables/brands/useBrandBySlug.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
83
storefront/app/composables/brands/useBrands.ts
Normal file
83
storefront/app/composables/brands/useBrands.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { GET_BRANDS } from '@graphql/queries/standalone/brands';
|
||||||
|
import type { IBrand, IBrandsResponse } from '@types';
|
||||||
|
|
||||||
|
interface IBrandArgs {
|
||||||
|
brandAfter?: string;
|
||||||
|
brandOrderBy?: string;
|
||||||
|
brandName?: string;
|
||||||
|
brandSearch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IBrandVars {
|
||||||
|
brandFirst: number;
|
||||||
|
brandAfter?: string;
|
||||||
|
brandOrderBy?: string;
|
||||||
|
brandName?: string;
|
||||||
|
brandSearch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBrands(args: IBrandArgs = {}) {
|
||||||
|
const variables = reactive<IBrandVars>({
|
||||||
|
brandFirst: 45,
|
||||||
|
brandAfter: args.brandAfter,
|
||||||
|
brandOrderBy: args.orderBy,
|
||||||
|
brandName: args.brandName,
|
||||||
|
brandSearch: args.brandSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = ref<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
storefront/app/composables/breadcrumbs/index.ts
Normal file
1
storefront/app/composables/breadcrumbs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './useBreadcrumbs';
|
||||||
88
storefront/app/composables/breadcrumbs/useBreadcrumbs.ts
Normal file
88
storefront/app/composables/breadcrumbs/useBreadcrumbs.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
4
storefront/app/composables/categories/index.ts
Normal file
4
storefront/app/composables/categories/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './useCategories';
|
||||||
|
export * from './useCategoryBySlug';
|
||||||
|
export * from './useCategoryBySlugSeo';
|
||||||
|
export * from './useCategoryTags';
|
||||||
39
storefront/app/composables/categories/useCategories.ts
Normal file
39
storefront/app/composables/categories/useCategories.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { GET_CATEGORIES } from '@graphql/queries/standalone/categories';
|
||||||
|
import type { ICategoriesResponse } from '@types';
|
||||||
|
|
||||||
|
export async function useCategories() {
|
||||||
|
const categoryStore = useCategoryStore();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
const getCategories = async (cursor?: string): Promise<void> => {
|
||||||
|
const { data, error } = await useAsyncQuery<ICategoriesResponse>(GET_CATEGORIES, {
|
||||||
|
level: 0,
|
||||||
|
whole: true,
|
||||||
|
categoryAfter: cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!error.value && data.value?.categories.edges) {
|
||||||
|
if (!cursor) {
|
||||||
|
categoryStore.setCategories(data.value.categories.edges);
|
||||||
|
} else {
|
||||||
|
categoryStore.addCategories(data.value.categories.edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = data.value.categories.pageInfo;
|
||||||
|
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
|
||||||
|
await getCategories(pageInfo.endCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.value) console.error('useCategories error:', error.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(locale, async () => {
|
||||||
|
categoryStore.setCategories([]);
|
||||||
|
await getCategories();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCategories,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue