Initial commit

This commit is contained in:
Никита Круглицкий
2024-11-26 16:15:12 +03:00
commit b3c99a667a
97 changed files with 14314 additions and 0 deletions

111
components/app-footer.vue Normal file
View File

@@ -0,0 +1,111 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer__inner">
<div class="footer__documents">
<a href="/documents/privacy-policy.pdf" target="_blank">Политика конфиденциальности</a>
<a href="/documents/policy.pdf" target="_blank">Публичная оферта</a>
<a href="/documents/payment-policy.pdf" target="_blank">Политика оплаты</a>
</div>
<div class="footer__copyright">
@ 2024-2025 Quantum. Все права защищены
</div>
<div class="footer__socials">
<SocialLink href="https://www.instagram.com/samatk7/?igsh=Mm9keGxnbXBrdGQ4">
<IMonoInstagram />
</SocialLink>
<SocialLink href="https://t.me/+vOQDGC3VoUJmY2Qy">
<IMonoTelegram />
</SocialLink>
<SocialLink href="https://api.whatsapp.com/send/?phone=7077407714&text&type=phone_number&app_absent=0">
<IMonoWhatsapp />
</SocialLink>
</div>
<a class="footer__dev" href="https://4keller.com/" target="_blank" rel="noopener noreferrer">
<svg xmlns="http://www.w3.org/2000/svg" height="29" viewBox="0 0 75 30">
<path fill="currentColor" d="M26.465 10.712H23.93l-4.174 9.176v-9.176h-2.317v18.465h2.317v-4.871l1.77-3.58l2.863 8.451h2.644L22.859 18.03zm3.125 0h-1.203v18.465h7.649V26.89h-5.332v-5.732h4.72V18.87h-4.72V13h5.332v-2.289zm10.599.001h-2.316v18.465h7.386V26.89h-5.07zm9.244 0h-2.338v18.465h7.408V26.89h-5.07zm8.108 0h-1.202v18.465h7.626V26.89h-5.31v-5.732h4.699V18.87h-4.699V13h5.31v-2.289zM75 29.177l-2.622-8.292c.022 0 .022-.023.043-.023a3.55 3.55 0 0 0 1.552-1.472c.35-.657.524-1.428.524-2.334v-2.152c0-.884-.174-1.631-.524-2.266a3.34 3.34 0 0 0-1.552-1.427c-.677-.317-1.486-.499-2.447-.499H65.8v18.465h2.316v-7.816h1.989l2.316 7.816zm-6.862-16.199h2.054c.416 0 .765.09 1.071.25c.306.158.525.407.678.747c.153.317.24.725.24 1.178v1.88c0 .43-.087.793-.24 1.11a1.6 1.6 0 0 1-.678.703c-.306.159-.656.25-1.07.25h-2.055zM0 .2v22.09L5.18.2zm3.65 23.155h4.72v-10.83h3.627v10.83h1.333V.2H8.916zm9.702 4.35h-1.355v1.472h1.355zm-4.982 0H0v1.472h8.37zM19.23.268h-1.813v7.295h1.814q.49 0 .852-.204c.24-.136.437-.317.546-.543q.197-.375.197-.884v-4.01c0-.521-.131-.929-.415-1.2c-.284-.295-.678-.454-1.18-.454m.372 5.528a.67.67 0 0 1-.131.43c-.087.114-.218.16-.372.16h-.502V1.468h.502c.154 0 .285.045.372.158a.67.67 0 0 1 .131.43zM25.087.268h-.524v7.295h3.125v-1.2h-1.923V4.55h1.682v-1.2h-1.682V1.49h1.923V.268zm9.201 3.738a2 2 0 0 0-.416-.34l-.459-.272c-.174-.09-.35-.181-.502-.272a1.8 1.8 0 0 1-.394-.294a.56.56 0 0 1-.153-.408v-.476c0-.158.044-.294.153-.385q.164-.136.394-.136c.175 0 .284.045.393.159a.67.67 0 0 1 .131.43v.408l1.202-.023V1.99c0-.567-.153-1.02-.459-1.315C33.872.358 33.458.2 32.911.2s-.984.158-1.29.475c-.305.318-.458.748-.458 1.315v.385c0 .294.043.543.13.77q.132.306.328.544a3.6 3.6 0 0 0 .438.385c.152.113.327.204.48.272c.175.09.328.158.46.249c.13.09.261.181.349.272a.6.6 0 0 1 .13.385v.634c0 .182-.043.318-.152.43c-.087.114-.24.16-.415.16s-.306-.046-.416-.16c-.109-.112-.152-.248-.152-.43v-.385H31.14v.385c0 .386.065.703.218.975c.131.271.35.475.612.611s.59.204.962.204c.546 0 .983-.158 1.289-.476s.459-.747.459-1.336v-.612c0-.295-.044-.544-.11-.725a1.2 1.2 0 0 0-.283-.521M39.532.268H38.33v7.295h1.202zm5.508 4.554h.59v.997c0 .18-.044.317-.154.407a.63.63 0 0 1-.415.159q-.262 0-.393-.136c-.11-.09-.153-.226-.153-.385V2.08c0-.18.044-.34.13-.453c.088-.113.22-.158.394-.158c.153 0 .262.045.35.158c.087.09.131.25.131.408v.566h1.224v-.566c0-.385-.066-.702-.197-.997a1.5 1.5 0 0 0-.59-.634a2 2 0 0 0-.918-.227c-.371 0-.677.068-.94.227a1.5 1.5 0 0 0-.59.634q-.196.408-.196 1.02v3.806c0 .385.065.702.218.951c.132.272.35.476.612.612c.263.136.59.204.962.204c.371 0 .677-.068.94-.226c.262-.136.458-.363.59-.635c.13-.272.196-.612.196-.997V3.598h-1.77v1.224zm7.954-.068L51.486.268h-.962v7.295h1.115V3.327l1.508 4.236h.961V.268h-1.114zM65.21 3.78c.328-.068.568-.182.765-.408q.261-.306.262-.816v-.77c0-.476-.131-.838-.393-1.11C65.58.404 65.21.29 64.729.29h-1.814v7.295h1.923c.481 0 .853-.136 1.093-.408c.262-.272.393-.634.393-1.133v-.997c0-.362-.087-.657-.284-.86a1.4 1.4 0 0 0-.83-.408m-.612-2.334c.131 0 .24.045.328.113c.087.09.109.204.109.34v.793a.7.7 0 0 1-.11.408a.44.44 0 0 1-.327.136h-.48v-1.79zm.524 4.486c0 .136-.043.272-.13.34c-.088.09-.198.113-.35.113h-.547V4.369h.525c.153 0 .284.045.371.158c.088.09.131.25.131.43zM71.984.268l-.612 3.036l-.677-3.036h-1.246l1.333 4.509v2.786h1.18V4.777l1.29-4.509z" />
</svg>
</a>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.footer {
padding-bottom: 60px;
margin-top: 100px;
@include mobile {
margin-top: 40px;
padding-bottom: 48px;
}
&__inner {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas:
'documents copyright'
'socials dev';
padding-inline: 60px;
gap: 32px;
@include mobile {
grid-template-areas:
'documents documents'
'copyright copyright'
'socials dev';
padding-inline: 16px;
column-gap: 16px;
}
}
&__documents {
grid-area: documents;
display: inline-flex;
align-items: center;
gap: 24px;
@include mobile {
align-items: flex-start;
flex-direction: column;
}
}
&__copyright {
@include font(16px, 400, 20px);
grid-area: copyright;
justify-self: flex-end;
color: $color-gray-600;
@include mobile {
@include font(14px, 400, 18px);
text-align: center;
justify-self: stretch;
}
}
&__socials {
grid-area: socials;
display: inline-flex;
align-items: center;
gap: 24px;
padding-inline: 12px;
font-size: 24px;
}
&__dev {
grid-area: dev;
justify-self: flex-end;
color: $color-gray-600;
}
}
</style>

105
components/app-header.vue Normal file
View File

@@ -0,0 +1,105 @@
<template>
<header class="header">
<div class="container">
<div class="header__inner">
<NuxtImg src="/logo.svg" alt="one2one" class="header__logo" draggable="false" />
<hr class="header__divider desktop-only">
<a href="#how-it-works" class="header__demo desktop-only">
<IMonoPlay />
<span>Демо видео о продукте</span>
</a>
<div class="header__socials">
<SocialLink href="https://www.instagram.com/samatk7/?igsh=Mm9keGxnbXBrdGQ4">
<IMonoInstagram />
</SocialLink>
<SocialLink href="https://t.me/+vOQDGC3VoUJmY2Qy">
<IMonoTelegram />
</SocialLink>
<SocialLink href="https://api.whatsapp.com/send/?phone=7077407714&text&type=phone_number&app_absent=0">
<IMonoWhatsapp />
</SocialLink>
</div>
<hr class="header__divider">
<PhoneNumber />
</div>
</div>
</header>
</template>
<script lang="ts" setup>
import SocialLink from '~/components/social-link.vue'
</script>
<style lang="scss">
.header {
position: sticky;
top: 0;
background-color: $color-gray-200;
z-index: 100;
&__inner {
display: flex;
align-items: center;
height: 80px;
padding: 16px 24px;
justify-content: flex-start;
@include mobile {
padding: 12px 0;
height: unset;
}
}
&__logo {
height: 21px;
@include mobile {
height: 16px;
}
}
&__divider {
height: 21px;
width: 1px;
border: none;
background-color: $color-gray-400;
margin-inline: 24px;
@include mobile {
margin-inline: 12px;
}
}
&__demo {
@include font(12px, 500, 15px);
display: flex;
align-items: center;
gap: 8px;
color: $color-gray-700;
> svg {
font-size: 24px;
color: $color-green-500;
}
}
&__socials {
display: flex;
align-items: center;
gap: 24px;
margin-left: auto;
font-size: 24px;
@include mobile {
font-size: 16px;
gap: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="any-questions">
<div class="container">
<div class="any-questions__inner">
<div class="any-questions__left">
<IDuoMail class="any-questions__icon" />
<div>
<div class="any-questions__title">
Остались вопросы?
</div>
<a class="any-questions__link" href="mailto:support@quantumbot.kz">
support@quantumbot.kz
</a>
</div>
</div>
<div class="any-questions__right">
<p class="any-questions__call-us">
Позвоните нам
</p>
<PhoneNumber class="any-questions__phone-number" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.any-questions {
&__inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 56px 60px;
border-radius: 12px;
background-color: $color-gray-200;
@include mobile {
flex-direction: column;
align-items: unset;
gap: 24px;
padding: 24px 16px;
}
}
&__left {
display: flex;
align-items: center;
gap: 32px;
@include mobile {
gap: 12px;
}
}
&__icon {
flex-shrink: 0;
font-size: 69px;
}
&__title {
@include font(32px, 600, 41px);
color: $color-black;
text-transform: uppercase;
@include mobile {
@include font(20px, 600, 24px);
text-transform: unset;
}
}
&__link {
@include font(16px, 400, 20px);
color: $color-gray-600;
@include mobile {
@include font(14px, 300, 17px);
}
}
&__right {}
&__call-us {
color: $color-gray-600;
margin-bottom: 8px;
}
&__phone-number {
@include font(20px, 700, 26px, 'phone-number', true);
@include mobile {
@include font(16px, 700, 20px, 'phone-number', true);
}
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="faq">
<div class="container">
<div class="faq__inner">
<div class="faq__title">
Вопросы и ответы
</div>
<div class="faq__items">
<UiAccordion>
<UiAccordionItem title="Как подключить сервис к моему аккаунту Kaspi.kz?">
Достаточно добавить аккаунт на Kaspi.kz с ограниченными правами и подключить его к нашему сервису. Весь процесс регистрации занимает 2 минуты
</UiAccordionItem>
<UiAccordionItem title="Какой уровень безопасности данных обеспечивает ваш сервис?">
Мы обеспечиваем высокий уровень безопасности, используя современные методы шифрования данных. Все ваши учетные данные и информация о товарах надежно защищены и не передаются третьим лицам.
</UiAccordionItem>
<UiAccordionItem title="Подходит ли ваш сервис для всех категорий товаров?">
Да, наш сервис поддерживает все категории товаров, представленных на Kaspi.kz. Вы можете регулировать цены как для небольшого ассортимента, так и для крупной базы товаров.
</UiAccordionItem>
<UiAccordionItem title="Как быстро я увижу результаты?">
Многие наши клиенты отмечают увеличение продаж уже в первые минуты использования сервиса. Точные результаты зависят от конкуренции в вашей категории и динамики рынка.
</UiAccordionItem>
</UiAccordion>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.faq {
&__inner {
border-radius: 12px;
padding: 40px;
background-color: $color-gray-200;
@include mobile {
padding: 16px;
}
}
&__title {
@include font(32px, 900, 45px);
text-transform: uppercase;
margin-bottom: 40px;
@include mobile {
@include font(20px, 900, 28px);
margin-bottom: 24px;
}
}
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<div class="homepage-hero">
<div class="container">
<div class="homepage-hero__description">
Приложение для автоматической регулировки цен товаров
</div>
<h1 class="homepage-hero__title">
Автоматизация цен <br> <strong>для максимальной прибыли</strong> на <b>Kaspi.kz</b>
</h1>
<div class="homepage-hero__subtitle">
Помогаем увеличивать продажи и оставаться конкурентоспособными
</div>
<div class="homepage-hero__actions desktop-only">
<UiButton type="secondary" href="#hero-cards">
Узнать подробности
</UiButton>
<InfoButton />
</div>
<NuxtImg height="650" class="homepage-hero__image desktop-only" src="/hero-desktop.png" draggable="false" />
<NuxtImg height="650" class="homepage-hero__image mobile-only" src="/hero-mobile.png" draggable="false" />
<div class="homepage-hero__actions mobile-only">
<UiButton type="secondary">
Узнать подробности
</UiButton>
<InfoButton />
</div>
<div id="hero-cards" class="homepage-hero__cards">
<div class="homepage-hero-card">
<IDuoThumbsUp class="homepage-hero-card__icon" />
<div class="homepage-hero-card__title">
Удобно
</div>
<div class="homepage-hero-card__description">
Автоматическое обновление цен в реальном времени
</div>
</div>
<div class="homepage-hero-card">
<IDuoLightning class="homepage-hero-card__icon" />
<div class="homepage-hero-card__title">
Быстро
</div>
<div class="homepage-hero-card__description">
Простота интеграции с Kaspi.kz
</div>
</div>
<div class="homepage-hero-card">
<IDuoFlipChart class="homepage-hero-card__icon" />
<div class="homepage-hero-card__title">
Выгодно
</div>
<div class="homepage-hero-card__description">
Увеличение продаж за счет конкурентных цен
</div>
</div>
<div class="homepage-hero-card">
<IDuoGroupChat class="homepage-hero-card__icon" />
<div class="homepage-hero-card__title">
Без проблем
</div>
<div class="homepage-hero-card__description">
Поддержка 24/7
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.homepage-hero {
text-align: center;
&__description {
@include font(15px, 300, 18px);
color: $color-gray-600;
margin-bottom: 24px;
@include mobile {
@include font(11px, 300, 13px);
margin-bottom: 16px;
}
}
&__title {
@include font(36px, 600, 46px);
text-transform: uppercase;
@include mobile {
@include font(18px, 600, 23px);
}
strong {
font-weight: 800;
}
b {
color: $color-green-500;
}
}
&__subtitle {
@include font(24px, 400, 31px);
color: $color-gray-700;
margin-top: 24px;
@include mobile {
@include font(14px, 400, 18px);
margin-top: 16px;
}
}
&__image {
@include mobile {
height: auto;
margin-top: 32px;
}
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
padding-blocK: 16px;
margin-top: 24px;
@include mobile {
flex-direction: column;
align-items: unset;
gap: 16px;
padding: 0;
margin-top: 32px;
}
}
&__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 60px;
padding-inline: 78px;
@include mobile {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 24px;
padding: 0;
}
}
}
.homepage-hero-card {
text-align: left;
@include mobile {
display: grid;
grid-template-columns: 24px 1fr;
padding: 16px;
border-radius: 12px;
background-color: $color-gray-200;
column-gap: 12px;
row-gap: 16px;
}
&__icon {
font-size: 40px;
@include mobile {
font-size: 24px;
}
}
&__title {
@include font(22px, 600, 26px);
margin-top: 24px;
@include mobile {
@include font(20px, 600, 24px);
flex: 1;
margin-top: 0;
}
}
&__description {
@include font(16px, 300, 19px);
margin-top: 24px;
@include mobile {
@include font(12px, 300, 14px);
margin-top: 0;
grid-column: span 2;
}
}
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<HomepageSection
id="how-it-works"
class="how-it-works"
title="Как работает наш сервис"
description="Четыре простых шага для увеличения ваших продаж"
>
<div class="how-it-works__cards">
<div class="how-it-works-card">
<div class="how-it-works-card__title">
Подключение
</div>
<div class="how-it-works-card__description">
Подключите свой аккаунт Kaspi.kz <br>
к нашему сервису
</div>
</div>
<div class="how-it-works-card">
<div class="how-it-works-card__title">
Настройка
</div>
<div class="how-it-works-card__description">
Настройте минимальные цены <br>
для товаров
</div>
</div>
<div class="how-it-works-card">
<div class="how-it-works-card__title">
Мониторинг
</div>
<div class="how-it-works-card__description">
Наш сервис автоматически мониторит <br>
конкурентов и обновляет цены
</div>
</div>
<div class="how-it-works-card">
<div class="how-it-works-card__title">
Прирост
</div>
<div class="how-it-works-card__description">
Получите кратный прирост <br>
продаж и прибыли
</div>
</div>
</div>
<div class="how-it-works-demo">
<div class="how-it-works-demo__title">
Демо видео о продукте
</div>
<iframe
class="how-it-works-demo__video"
src="https://www.youtube.com/embed/dQw4w9WgXcQ?si=dZCL9UFbyboiQzSl"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
/>
<ul class="how-it-works-demo__list">
<li>Ознакомьтесь с интерфейсом приложения для ПК</li>
<li>Оцените удобство мобильного интерфейса</li>
<li>Просмотрите пример добавления товаров и управления ими через приложение</li>
</ul>
<UiButton class="how-it-works-demo__action">
Попробовать бесплатно
</UiButton>
</div>
</HomepageSection>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.how-it-works {
&__cards {
gap: 40px;
@include desktop {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
@include mobile {
display: flex;
flex-direction: column;
}
}
}
.how-it-works-card {
position: relative;
padding: 32px 16px;
background-color: $color-gray-300;
border-radius: 12px;
text-align: center;
counter-increment: how-it-works-card;
&::before {
@include font(24px, 600, 40px);
content: counter(how-it-works-card);
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
padding-inline: 16px;
border-radius: 12px;
background-color: $color-black;
color: $color-white;
min-width: 46px;
}
@include mobile {
padding: 24px 16px;
}
&__title {
@include font(20px, 700, 24px);
}
&__description {
@include font(14px, 400, 17px);
color: $color-gray-700;
margin-top: 10px;
}
}
.how-it-works-demo {
padding: 40px 100px;
background-color: $color-gray-300;
margin-top: 60px;
border-radius: 12px;
@include desktop {
display: grid;
justify-items: flex-start;
grid-template-columns: 485px 1fr;
grid-template-rows: 1fr auto 1fr;
grid-template-areas:
'title video'
'list video'
'action video';
gap: 24px;
}
@include mobile {
padding: 16px;
margin-top: 32px;
}
&__title {
@include font(28px, 700, 39px);
grid-area: title;
@include desktop {
align-self: flex-end;
}
@include mobile {
@include font(20px, 700, 28px);
text-align: center;
margin-bottom: 24px;
}
}
&__video {
grid-area: video;
aspect-ratio: 16 / 9;
@include desktop {
justify-self: center;
height: 270px;
}
@include mobile {
width: 100%;
}
}
&__list {
@include font(16px, 400, 29px);
color: $color-gray-700;
grid-area: list;
@include mobile {
@include font(12px, 400, 22px);
margin-top: 24px;
}
}
&__action {
grid-area: action;
@include mobile {
width: 100%;
margin-top: 24px;
}
}
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<HomepageSection
class="results"
title="Результаты наших клиентов"
description="Усредненные улучшения, которые отмечают наши клиенты"
>
<div class="results__cards">
<div class="result-card">
<p class="result-card__text">
<strong>Увеличение продаж</strong>
</p>
<p class="result-card__sub">
+ 100%
</p>
<NuxtImg class="result-card__image" src="/results/1.png" height="110px" loading="lazy" />
</div>
<div class="result-card">
<p class="result-card__text">
<strong>Увеличение прибыли</strong>
</p>
<p class="result-card__sub">
на 15%
</p>
<NuxtImg class="result-card__image" src="/results/2.png" height="110px" loading="lazy" />
</div>
<div class="result-card">
<p class="result-card__text">
<strong>+30% конверсия</strong>
заказов
</p>
<p class="result-card__sub">
заказов
</p>
<NuxtImg class="result-card__image" src="/results/3.png" height="110px" loading="lazy" />
</div>
<div class="result-card">
<p class="result-card__text">
<strong>Экономия 10+ часов</strong>
</p>
<p class="result-card__sub">
в неделю на ручном <br> мониторинге
</p>
<NuxtImg class="result-card__image" src="/results/4.png" height="110px" loading="lazy" />
</div>
</div>
<div class="results__contact-us results-contact-us">
<div class="results-contact-us__left">
<IDuoThunderMove class="results-contact-us__icon" />
<div class="results-contact-us__title">
Хотите таких же результатов?
</div>
<div class="results-contact-us__description">
Подключите наш сервис уже сегодня и начните увеличивать свои продажи на Kaspi.kz
</div>
</div>
<InfoButton class="results-contact-us__action" />
</div>
</HomepageSection>
</template>
<script setup lang="ts">
import InfoButton from '~/components/info-button.vue'
</script>
<style lang="scss">
.results {
&__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
@include mobile {
display: flex;
flex-direction: column;
}
}
&__contact-us {
margin-top: 40px;
@include desktop {
display: flex;
align-items: center;
justify-content: space-between;
}
@include mobile {
margin-top: 32px;
}
}
}
.result-card {
position: relative;
padding: 24px;
background-color: $color-gray-300;
border-radius: 12px;
@include desktop {
height: 200px;
}
@include mobile {
display: grid;
grid-template-columns: 50px 1fr;
grid-template-rows: auto 1fr;
column-gap: 24px;
padding: 16px;
}
&__text {
@include font(18px, 800, 25px);
text-transform: uppercase;
@include mobile {
@include font(15px, 800, 21px);
grid-row: 1;
grid-column: 2;
}
}
&__sub {
@include font(18px, 500, 25px);
@include mobile {
@include font(15px, 500, 21px);
grid-column: 2;
grid-row: 2;
}
}
&__image {
@include desktop {
position: absolute;
bottom: 12px;
right: 24px;
}
@include mobile {
height: auto;
grid-column: 1;
grid-row: span 2;
}
}
}
.results-contact-us {
background-color: $color-gray-200;
border-radius: 12px;
padding: 32px 40px;
@include mobile {
padding: 16px;
}
&__left {
display: grid;
grid-template-columns: 59px 1fr;
column-gap: 24px;
@include mobile {
column-gap: 12px;
row-gap: 16px;
}
}
&__icon {
font-size: 59px;
@include desktop {
grid-row: span 2;
}
}
&__title {
@include font(22px, 700, 31px);
@include mobile {
@include font(20px, 600, 24px);
align-self: center;
}
}
&__description {
@include font(20px, 300, 28px);
@include mobile {
@include font(14px, 300, 17px);
grid-column: span 2;
}
}
&__action {
@include mobile {
margin-top: 24px;
}
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<HomepageSection
class="reviews"
title="Отзывы"
description="Честно о нас"
>
<div class="reviews__inner">
<div class="reviews-navigation-card desktop-only">
<div class="reviews-navigation-card__arrows">
<button class="reviews-navigation-card__arrow" type="button" @click="move('<')">
<IMonoChevronLeft />
</button>
<button class="reviews-navigation-card__arrow" type="button" @click="move('>')">
<IMonoChevronRight />
</button>
</div>
<div>
<div class="reviews-navigation-card__title">
1000+
</div>
<div class="reviews-navigation-card__subtitle">
селлеров уже выбирают нас
</div>
</div>
<InfoButton class="reviews-navigation-card__action" />
</div>
<div class="mobile-only">
<div class="reviews__title">
1000+
</div>
<div class="reviews__subtitle">
селлеров уже выбирают нас
</div>
</div>
<Splide ref="splideEl" class="reviews__carousel" :options="splideOptions">
<SplideSlide v-for="i in 6" :key="i">
<NuxtImg class="reviews__review" :src="`/reviews/${i}.png`" height="416" />
</SplideSlide>
</Splide>
<InfoButton class="reviews__action mobile-only" />
</div>
</HomepageSection>
</template>
<script setup lang="ts">
import type { Options as SplideOptions } from '@splidejs/vue-splide'
import { Splide, SplideSlide } from '@splidejs/vue-splide'
const splideEl = ref()
const splideOptions = computed(() => ({
gap: 40,
autoWidth: true,
focus: 0,
omitEnd: true,
arrows: false,
pagination: false,
padding: { right: 40 },
type: 'loop',
breakpoints: {
480: {
padding: 0,
gap: 16,
},
},
} as SplideOptions))
function move(dir: '<' | '>'): void {
splideEl.value.go(dir)
}
</script>
<style lang="scss">
.reviews {
&__inner {
background-color: $color-gray-300;
border-radius: 12px;
@include desktop {
display: grid;
grid-template-columns: 270px 1fr;
gap: 24px;
padding: 40px 0 40px 40px;
}
@include mobile {
padding: 16px;
}
}
&__title {
@include font(32px, 700, 38px);
color: $color-gray-700;
}
&__subtitle {
@include font(14px, 400, 17px);
color: $color-gray-600;
margin-bottom: 24px;
}
&__review {
border-radius: 15px;
}
&__action {
margin-top: 24px;
}
}
.reviews-navigation-card {
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: $color-gray-200;
border: 1px solid $color-gray-400;
border-radius: 15px;
padding: 24px;
&__arrows {
display: flex;
gap: 8px;
}
&__arrow {
cursor: pointer;
color: $color-gray-700;
transition: color $transition-duration $transition-easing;
font-size: 24px;
background: none;
border: none;
padding: 0;
&:hover,
&:focus {
color: $color-green-400;
}
&:active {
color: $color-green-500;
}
}
&__title {
@include font(48px, 700, 58px);
color: $color-gray-700;
}
&__subtitle {
@include font(20px, 400, 24px);
color: $color-gray-600;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="homepage-section">
<div class="container">
<div class="homepage-section__title">
{{ title }}
</div>
<p class="homepage-section__description">
{{ description }}
</p>
<div class="homepage-section__content">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<style lang="scss">
.homepage-section {
&__title {
@include font(36px, 700, 46px);
text-align: center;
@include mobile {
@include font(20px, 700, 26px);
}
}
&__description {
@include font(14px, 300, 17px);
text-align: center;
margin-top: 8px;
@include mobile {
@include font(13px, 300, 16px);
}
}
&__content {
margin-top: 40px;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<HomepageSection
class="tariff"
title="Единый тариф"
description="Получите полный доступ ко всему функционалу за фиксированную цену"
>
<div class="tariff__inner">
<div class="tariff__header">
<NuxtImg class="mobile-only" src="/price-bg.png" height="35px" />
<div class="tariff__price">
30 000 / месяц
</div>
</div>
<ul class="tariff__list">
<li>Безлимитное количество товаров</li>
<li>Поддержка 24/7</li>
<li>Высокая скорость обновления цен</li>
</ul>
<UiButton class="tariff__action">
Попробовать 14 дней бесплатно
</UiButton>
</div>
</HomepageSection>
</template>
<script setup>
const img = useImage()
const desktopBg = computed(() => {
return `url('${img('/price-bg.png', { height: '352px' })}')`
})
</script>
<style lang="scss">
.tariff {
&__inner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
height: 400px;
padding: 24px 100px;
border-radius: 12px;
background-color: $color-gray-200;
@include desktop {
background-image: v-bind(desktopBg);
background-repeat: no-repeat;
background-position: center right 128px;
}
@include mobile {
padding: 16px;
height: unset;
}
}
&__header {
display: flex;
align-items: center;
gap: 24px;
}
&__price {
@include font(28px, 700, 39px);
@include mobile {
@include font(20px, 700, 28px);
}
}
&__list {
@include font(16px, 400, 29px);
color: $color-gray-700;
margin-top: 24px;
@include mobile {
@include font(12px, 400, 22px);
}
}
&__action {
margin-top: 24px;
@include mobile {
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="info-button">
<UiButton class="info-button__button">
<slot>Начать бесплатно</slot>
</UiButton>
<Tooltip distance="8" placement="bottom-end">
<IMonoInfo class="info-button__info-trigger desktop-only" />
<template #popper>
Зарегистрируйтесь и получите <br>
7 дней бесплатной подписки для <br>
ознакомления с сервисом
</template>
</Tooltip>
<p class="info-button__hint mobile-only">
Зарегистрируйтесь и получите 14 дней бесплатной подписки для ознакомления с сервисом
</p>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'floating-vue'
// const infoTriggerEl = ref()
// const tooltipEl = ref()
// const tooltipArrowEl = ref()
//
// const { floatingStyles: tooltipStyles, middlewareData } = useFloating(
// infoTriggerEl,
// tooltipEl,
// {
// placement: 'bottom-end',
// middleware: [offset(8), arrow({ element: tooltipArrowEl })],
// },
// )
//
// const arrowStyles = computed(() => {
// if (!middlewareData.value.arrow) {
// return {
// position: 'absolute',
// }
// }
//
// return {
// position: 'absolute',
// left: `${middlewareData.value.arrow.x ?? 0}px`,
// top: `${middlewareData.value.arrow.y ?? 0}px`,
// }
// })
</script>
<style lang="scss">
.info-button {
@include desktop {
display: flex;
align-items: center;
gap: 8px;
}
&__button {
width: 100%;
}
&__info-trigger {
flex-shrink: 0;
font-size: 24px;
color: $color-gray-500;
}
&__hint {
@include font(11px, 500, 14px);
text-align: center;
margin-top: 8px;
color: $color-gray-600;
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="phone-number">
<IDuoPhone class="phone-number__icon" />
<a href="tel:+77054002066" class="phone-number__number">
+7 (705) 400 20 66
</a>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.phone-number {
display: inline-flex;
align-items: center;
gap: 8px;
&__icon {
font-size: 24px;
flex-shrink: 0;
@include mobile {
display: none;
}
}
&__number {
@include font(14px, 700, 18px, 'phone-number');
color: $color-black;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<Transition name="scroll-to-top">
<button v-show="y > 100" class="scroll-to-top desktop-only" type="button" @click="y = 0">
<IMonoArrowUp />
</button>
</Transition>
</template>
<script lang="ts" setup>
import { useWindowScroll } from '@vueuse/core'
const { y } = useWindowScroll()
</script>
<style lang="scss">
.scroll-to-top {
position: fixed;
right: 60px;
bottom: 60px;
border-radius: 16px;
color: $color-gray-700;
width: 64px;
height: 64px;
transition: $transition-duration $transition-easing;
transition-property: background-color, color;
z-index: 1000;
background-color: $color-gray-300;
outline: none;
border: none;
cursor: pointer;
&:hover {
color: $color-black;
background-color: $color-gray-400;
}
}
.scroll-to-top-leave-active,
.scroll-to-top-enter-active {
transition: transform $transition-duration $transition-easing;
}
.scroll-to-top-enter-from,
.scroll-to-top-leave-to {
transform: scale(0);
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<a class="social-link" target="_blank" rel="noopener noreferrer">
<slot />
</a>
</template>
<style lang="scss">
.social-link {
display: inline-block;
color: $color-gray-600;
}
</style>

View File

@@ -0,0 +1,4 @@
import type { InjectionKey } from 'vue'
import type { AccordionContext } from './types'
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('UI_ACCORDION')

View File

@@ -0,0 +1,78 @@
<template>
<div :class="[cn.b()]">
<slot />
</div>
</template>
<script setup lang="ts">
import type { AccordionActiveId, AccordionModelValue } from './types'
import { castArray } from 'lodash-es'
import { provide, ref, watch } from 'vue'
import { accordionContextKey } from './constants'
export interface Props {
multiple?: boolean
modelValue?: AccordionModelValue
}
defineOptions({
name: 'UiAccordion',
})
const props = withDefaults(defineProps<Props>(), {
multiple: false,
})
const emit = defineEmits<{
'update:modelValue': [value: Props['modelValue']]
}>()
const cn = useClassname('ui-accordion')
const activeItems = ref(castArray(props.modelValue))
function setActiveItems(_activeItems: AccordionActiveId[]) {
activeItems.value = _activeItems
const value = !props.multiple ? activeItems.value[0] : activeItems.value
emit('update:modelValue', value)
}
function handleItemClick(id: AccordionActiveId) {
if (!props.multiple) {
setActiveItems([activeItems.value[0] === id ? '' : id])
}
else {
const _activeItems = [...activeItems.value]
const index = _activeItems.indexOf(id)
if (index > -1)
_activeItems.splice(index, 1)
else
_activeItems.push(id)
setActiveItems(_activeItems)
}
}
function isActive(id: AccordionActiveId) {
return activeItems.value.includes(id)
}
watch(
() => props.modelValue,
value => (activeItems.value = castArray(value)),
{ deep: true },
)
provide(accordionContextKey, {
activeItems,
handleItemClick,
isActive,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div :class="[cn.b(), cn.is('focused', focused), cn.is('active', isActive)]">
<button
:class="[cn.e('head')]"
type="button"
@focus="focused = true"
@blur="focused = false"
@click="handleClick"
@keydown.space.enter.stop.prevent="handleClick"
>
<slot name="title">
<h3 :class="[cn.e('title')]">
{{ title }}
</h3>
</slot>
<Component :is="isActive ? IMonoMinus : IMonoPlus" :class="cn.e('icon')" />
</button>
<UiCollapseTransition>
<div
v-show="isActive"
:class="[cn.e('wrapper')]"
>
<div :class="[cn.e('content')]">
<slot />
</div>
</div>
</UiCollapseTransition>
</div>
</template>
<script setup lang="ts">
import IMonoMinus from '~icons/mono/minus'
import IMonoPlus from '~icons/mono/plus'
import { computed, inject, ref } from 'vue'
import UiCollapseTransition from '~/components/ui/collapse-transition/index.vue'
import { accordionContextKey } from './constants'
export interface Props {
id?: string | number
title?: string
}
defineOptions({
name: 'UiAccordionItem',
})
const props = withDefaults(defineProps<Props>(), {
id: () => useId(),
title: '',
})
const accordion = inject(accordionContextKey)
const cn = useClassname('ui-accordion-item')
const focused = ref(false)
const isActive = computed(() => accordion?.isActive(props.id) ?? false)
function handleClick() {
accordion?.handleItemClick(props.id)
}
</script>

View File

@@ -0,0 +1,89 @@
@use '../../../styles/variables' as *;
@use '../../../styles/mixins' as *;
.ui-accordion {
display: flex;
flex-direction: column;
gap: 16px;
}
.ui-accordion-item {
$self: &;
border-radius: 12px;
background-color: $color-white;
transition: outline-color $transition-duration $transition-easing;
-webkit-tap-highlight-color: transparent;
outline: 1px solid $color-gray-500;
outline-offset: -1px;
&:focus,
&:hover,
&.is-active {
outline-width: 2px;
outline-offset: -2px;
}
&.is-active {
outline-color: $color-gray-600;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
border: none;
padding: 24px;
background: transparent;
transition: padding $transition-duration $transition-easing;
cursor: pointer;
outline: none;
user-select: none;
color: $color-black;
@include mobile {
padding: 16px;
}
#{$self}.is-active & {
padding: 24px 24px 4px;
@include mobile {
padding: 16px 16px 4px;
}
}
}
&__title {
@include font(18px, 600, 23px);
@include mobile {
@include font(13px, 600, 17px);
}
}
&__icon {
flex-shrink: 0;
font-size: 24px;
color: $color-gray-600;
}
&__wrapper {
will-change: height;
overflow: hidden;
}
&__content {
@include font(14px, 400, 18px);
color: $color-gray-700;
padding: 0 24px 24px;
@include mobile {
padding: 0 16px 16px;
}
}
}

View File

@@ -0,0 +1,10 @@
import type { Ref } from 'vue'
export type AccordionActiveId = string | number
export type AccordionModelValue = AccordionActiveId | AccordionActiveId[]
export interface AccordionContext {
activeItems: Ref<AccordionActiveId[]>
handleItemClick: (id: AccordionActiveId) => void
isActive: (id: AccordionActiveId) => boolean
}

View File

@@ -0,0 +1,105 @@
<template>
<Component
:is="as"
:class="[
cn.b(),
cn.m(type),
cn.is('one-icon-only', oneIconOnly),
cn.has('icon', hasIcon),
cn.is('disabled', disabled),
]"
:disabled="disabled"
v-bind="attributes"
@click="handleClick"
>
<span
v-if="hasLeftIcon && displayIcon"
:class="[cn.e('icon'), cn.em('icon', 'left')]"
>
<slot name="left-icon" />
</span>
<span
v-if="!iconOnly"
:class="[cn.e('content')]"
><slot /></span>
<span
v-if="hasRightIcon && displayIcon"
:class="[cn.e('icon'), cn.em('icon', 'right')]"
>
<slot name="right-icon" />
</span>
</Component>
</template>
<script setup lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
export interface Props {
type?: 'primary' | 'secondary'
nativeType?: ButtonHTMLAttributes['type']
disabled?: boolean
}
defineOptions({
name: 'UiButton',
})
const props = withDefaults(defineProps<Props>(), {
type: 'primary',
nativeType: 'button',
disabled: false,
})
const emit = defineEmits<{
click: [e: Event]
}>()
const slots = useSlots()
const attrs = useAttrs()
const cn = useClassname('ui-button')
const as = computed(() => (attrs.href ? 'a' : 'button'))
const attributes = computed(() => {
if (as.value === 'button') {
return {
type: props.nativeType,
...attrs,
}
}
return attrs
})
const hasLeftIcon = computed(() => !!slots['left-icon'])
const hasRightIcon = computed(() => !!slots['right-icon'])
const hasIcon = computed(() => hasLeftIcon.value || hasRightIcon.value)
const oneIconOnly = computed(
() =>
((hasLeftIcon.value && !hasRightIcon.value)
|| (!hasLeftIcon.value && hasRightIcon.value))
&& !slots.default,
)
const iconOnly = computed(
() => hasIcon.value && !slots.default,
)
const displayIcon = computed(() => hasIcon.value)
function handleClick(event: Event) {
if (props.disabled) {
event.stopPropagation()
event.preventDefault()
}
else {
emit('click', event)
}
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,113 @@
@use "sass:list";
@use '../../../styles/mixins' as *;
@use '../../../styles/variables' as *;
$button-types: 'filled', 'outlined', 'ghost', 'link';
$button-colors: 'primary', 'secondary';
/* prettier-ignore */
$button-variants: [
[
'primary',
(
'color': $color-white,
'background': $color-green-500,
'hover-background': $color-green-400,
'active-background': $color-gray-600,
)
],
[
'secondary',
(
'color': $color-green-500,
'background': $color-gray-300,
'hover-background': $color-gray-400,
'active-background': $color-gray-500,
)
]
];
.ui-button {
$self: &;
@include font(16px, 500, 20px);
--spinner-size: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
position: relative;
cursor: pointer;
outline: none;
padding: 13px 23px;
background: var(--button-background, transparent);
color: var(--button-color, inherit);
border-radius: 12px;
width: var(--button-width, unset);
height: var(--button-height, 48px);
border: 1px solid var(--button-border-color, transparent);
white-space: nowrap;
transition: $transition-duration $transition-easing;
transition-property: background-color, border-color, color, opacity;
&:visited {
color: var(--button-color, inherit);
}
&:hover,
&:focus-visible {
--button-icon-color: var(--button-hover-color, var(--button-color));
background: var(--button-hover-background, var(--button-background));
color: var(--button-hover-color, var(--button-color));
border-color: var(--button-hover-border-color, transparent);
}
&:active {
--button-icon-color: var(--button-active-color, var(--button-color));
background: var(--button-active-background, var(--button-background));
color: var(--button-active-color, var(--button-color));
border-color: var(--button-active-border-color, transparent);
}
&.is-disabled {
background: var(--button-disabled-background, transparent);
color: var(--button-disabled-color);
border-color: var(--button-disabled-border-color, transparent);
cursor: not-allowed;
}
&__icon {
color: var(--button-icon-color, inherit);
line-height: 1;
transition: color $transition-duration $transition-easing;
#{$self}.is-one-icon-only:not(#{$self}--link) & {
color: inherit;
}
}
&.is-one-icon-only {
padding: 0;
width: var(--button-width, var(--button-height, 40px));
}
@each $variant in $button-variants {
$color: list.nth($variant, 1);
$scheme: list.nth($variant, 2);
&--#{$color} {
@each $property, $value in $scheme {
--button-#{$property}: var(--button-#{$color}-#{$property});
}
}
/* prettier-ignore */
@include element-variant('button', $color, $scheme);
}
}

View File

@@ -0,0 +1,103 @@
<template>
<Transition :name="ns.b()" v-on="bindings">
<slot />
</Transition>
</template>
<script lang="ts" setup>
import type { RendererElement } from 'vue'
defineOptions({
name: 'UiCollapseTransition',
})
const emit = defineEmits<{
expanded: []
collapsed: []
}>()
const ns = useClassname('ui-collapse-transition')
const bindings = {
beforeEnter(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.elExistsHeight = el.style.height ?? undefined
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
},
enter(el: RendererElement) {
requestAnimationFrame(() => {
el.dataset.oldOverflow = el.style.overflow
if (el.dataset.elExistsHeight)
el.style.maxHeight = el.dataset.elExistsHeight
else if (el.scrollHeight !== 0)
el.style.maxHeight = `${el.scrollHeight}px`
else
el.style.maxHeight = 0
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
el.style.overflow = 'hidden'
})
},
afterEnter(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
emit('expanded')
},
enterCancelled(el: RendererElement) {
reset(el)
},
beforeLeave(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.oldOverflow = el.style.overflow
el.style.maxHeight = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
},
leave(el: RendererElement) {
if (el.scrollHeight !== 0) {
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
}
},
afterLeave(el: RendererElement) {
reset(el)
emit('collapsed')
},
leaveCancelled(el: RendererElement) {
reset(el)
},
}
function reset(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,24 @@
@use '../../../styles/variables' as *;
.ui-collapse-transition {
transition-property: height, padding-top, padding-bottom;
transition-timing-function: var(--collapse-transition-easing, $transition-easing);
transition-duration: var(--collapse-transition-duration, $transition-duration);
}
.ui-collapse-transition-leave-active,
.ui-collapse-transition-enter-active {
transition-property: max-height, padding-top, padding-bottom, opacity;
transition-timing-function: var(--collapse-transition-easing, $transition-easing);
transition-duration: var(--collapse-transition-duration, $transition-duration);
}
.ui-collapse-transition-enter-to,
.ui-collapse-transition-leave-from {
opacity: 1;
}
.ui-collapse-transition-enter-from,
.ui-collapse-transition-leave-to {
opacity: 0;
}

View File

@@ -0,0 +1,128 @@
<template>
<Transition
:name="cn.b()"
@before-leave="$emit('close')"
@after-leave="$emit('destroy')"
>
<UiAlert
v-show="visible"
:id="id"
:class="[cn.b(), verticalSide, horizontalSide]"
:title="title"
:type="type"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<slot />
</UiAlert>
</Transition>
</template>
<script setup lang="ts">
import type { NotificationPlacement, NotificationType } from './types'
import { computed, onMounted, ref } from 'vue'
export interface Props {
duration?: number
id?: string | number
offset?: number
placement?: NotificationPlacement
type?: NotificationType
title?: string
zIndex?: number
}
defineOptions({
name: 'UiNotification',
})
const props = withDefaults(defineProps<Props>(), {
type: 'neutral',
duration: 5000,
offset: 0,
placement: 'top-right',
zIndex: 0,
})
defineEmits(['close', 'destroy'])
const cn = useClassname('ui-notification')
const timerId = ref()
const visible = ref(false)
const verticalSide = computed(() => props.placement.split('-')[0])
const horizontalSide = computed(() => props.placement.split('-')[1])
function close() {
visible.value = false
}
function startTimer() {
if (props.duration > 0) {
timerId.value = setTimeout(() => {
if (visible.value)
close()
}, props.duration)
}
}
function clearTimer() {
if (timerId.value)
clearTimeout(timerId.value)
}
onMounted(() => {
startTimer()
visible.value = true
})
defineExpose({
visible,
close,
})
</script>
<style lang="scss">
.ui-notification {
position: fixed;
transition-duration: $transition-duration;
transition-property: opacity, transform, left, right, top, bottom;
z-index: calc(8000 + v-bind('zIndex'));
width: 400px;
transform-origin: right top;
@include mobile {
width: calc(100% - 32px);
}
&.top {
top: calc(v-bind('`${offset}px`') + 80px);
@include mobile {
top: auto;
bottom: v-bind('`${offset}px`');
}
}
&.bottom {
bottom: v-bind('`${offset}px`');
}
&.left {
left: 32px;
@include mobile {
left: 16px;
}
}
&.right {
right: 32px;
@include mobile {
right: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,132 @@
import { createVNode, render } from 'vue'
import { defu } from 'defu'
import NotificationConstructor from './notification.vue'
import type {
NotificationOptions,
NotificationPlacement,
NotificationQueue,
} from './types'
const notifications: Record<NotificationPlacement, NotificationQueue> = {
'top-left': [],
'top-right': [],
'bottom-left': [],
'bottom-right': [],
}
const GAP_SIZE = 16
let SEED = 1
const DEFAULT_OPTIONS: NotificationOptions = {
text: '',
placement: 'top-right',
duration: 5000,
onClose: () => {},
}
const notify = function (options: NotificationOptions, context = null) {
// if (process.server) return { close: () => undefined };
options = defu(options, DEFAULT_OPTIONS)
const orientedNotifications = notifications[options.placement!]
const id = options.id
? `${options.placement!}_${options.id}`
: `notification_${SEED++}`
const idx = orientedNotifications.findIndex(
({ vm }) => vm.component?.props.id === id,
)
if (idx > -1)
return
let verticalOffset = options.offset || 0
notifications[options.placement!].forEach(({ vm }) => {
verticalOffset += (vm.el?.offsetHeight || 0) + GAP_SIZE
})
verticalOffset += GAP_SIZE
const userOnClose = options.onClose
const props = {
...options,
offset: verticalOffset,
id,
onClose: () => {
close(id, options.placement!, userOnClose)
},
}
const container = document.createElement('div')
const vm = createVNode(
NotificationConstructor,
props,
options.text
? {
default: () => options.text,
}
: null,
)
vm.appContext = context ?? notify._context
vm.props!.onDestroy = () => {
render(null, container)
}
render(vm, container)
notifications[options.placement!].push({ vm })
document.body.appendChild(container.firstElementChild!)
return {
close: () => {
vm.component!.exposed!.close()
},
}
}
export function close(
id: NotificationOptions['id'],
placement: NotificationOptions['placement'],
userOnClose: NotificationOptions['onClose'],
) {
const orientedNotifications = notifications[placement!]
const idx = orientedNotifications.findIndex(
({ vm }) => vm.component?.props.id === id,
)
if (idx === -1)
return
const { vm } = orientedNotifications[idx]
if (!vm)
return
userOnClose?.(vm)
const removedHeight = vm.el!.offsetHeight
const verticalPos = placement!.split('-')[0]
orientedNotifications.splice(idx, 1)
if (orientedNotifications.length < 1)
return
for (let i = idx; i < orientedNotifications.length; i++) {
const { el, component } = orientedNotifications[i].vm
const styles = getComputedStyle(el as Element)
const pos = Number.parseInt(styles.getPropertyValue(verticalPos), 10)
component!.props.offset = pos - removedHeight - GAP_SIZE
}
}
export function closeAll() {
for (const orientedNotifications of Object.values(notifications)) {
orientedNotifications.forEach(({ vm }) => {
vm.component!.exposed!.close()
})
}
}
notify.closeAll = closeAll
notify._context = null
export default notify

View File

@@ -0,0 +1,33 @@
import type { VNode } from 'vue'
export const NOTIFICATION_TYPES = [
'neutral',
'positive',
'warning',
'negative',
] as const
export type NotificationType = (typeof NOTIFICATION_TYPES)[number]
export type NotificationPlacement =
| 'top-right'
| 'top-left'
| 'bottom-right'
| 'bottom-left'
export interface NotificationOptions {
type?: NotificationType
text: string
title?: string
duration?: number
placement?: NotificationPlacement
id?: string | number
offset?: number
onClose?: (vm?: VNode) => void
}
export interface NotificationItem {
vm: VNode
}
export type NotificationQueue = NotificationItem[]