Compare commits
4 Commits
main
...
fix/bugs-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1da2c834a | ||
|
|
57cab6209d | ||
|
|
60002a7169 | ||
|
|
8b0f93bf23 |
144
.github/workflows/deploy.yml
vendored
144
.github/workflows/deploy.yml
vendored
@ -1,43 +1,19 @@
|
||||
name: Build & Deploy Web, Bot & Cache Proxy
|
||||
name: Build & Deploy Web & Bot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
web_tag: ${{ steps.vars.outputs.web_tag }}
|
||||
bot_tag: ${{ steps.vars.outputs.bot_tag }}
|
||||
cache_proxy_tag: ${{ steps.vars.outputs.cache_proxy_tag }}
|
||||
# Добавляем output-ы для отслеживания, какие проекты были собраны
|
||||
web_built: ${{ steps.filter.outputs.web }}
|
||||
bot_built: ${{ steps.filter.outputs.bot }}
|
||||
cache_proxy_built: ${{ steps.filter.outputs.cache_proxy }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# --- НОВОЕ: Шаг 1: dorny/paths-filter для условной сборки ---
|
||||
- name: Filter changed paths
|
||||
uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- 'apps/web/**'
|
||||
- 'packages/**'
|
||||
bot:
|
||||
- 'apps/bot/**'
|
||||
- 'packages/**'
|
||||
cache_proxy:
|
||||
- 'apps/cache-proxy/**'
|
||||
# -----------------------------------------------------------
|
||||
- name: Create .env file for build
|
||||
- name: Create fake .env file for build
|
||||
run: |
|
||||
echo "BOT_TOKEN=fake" > .env
|
||||
echo "LOGIN_GRAPHQL=fake" >> .env
|
||||
@ -46,54 +22,25 @@ jobs:
|
||||
echo "EMAIL_GRAPHQL=fake@example.com" >> .env
|
||||
echo "NEXTAUTH_SECRET=fakesecret" >> .env
|
||||
echo "BOT_URL=http://localhost:3000" >> .env
|
||||
echo "REDIS_PASSWORD=fake" >> .env
|
||||
echo "BOT_PROVIDER_TOKEN=fake" >> .env
|
||||
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
|
||||
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
|
||||
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
|
||||
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
|
||||
|
||||
- name: Set image tags
|
||||
id: vars
|
||||
run: |
|
||||
echo "web_tag=web-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
echo "bot_tag=bot-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
echo "cache_proxy_tag=cache-proxy-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
# --- ИЗМЕНЕНО: Условное выполнение Build/Push ---
|
||||
- name: Build web image
|
||||
if: steps.filter.outputs.web == 'true'
|
||||
run: |
|
||||
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} -f ./apps/web/Dockerfile .
|
||||
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:latest -f ./apps/web/Dockerfile .
|
||||
|
||||
- name: Push web image to Docker Hub
|
||||
if: steps.filter.outputs.web == 'true'
|
||||
run: |
|
||||
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }}
|
||||
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:latest
|
||||
|
||||
- name: Build bot image
|
||||
if: steps.filter.outputs.bot == 'true'
|
||||
run: |
|
||||
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} -f ./apps/bot/Dockerfile .
|
||||
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:latest -f ./apps/bot/Dockerfile .
|
||||
|
||||
- name: Push bot image to Docker Hub
|
||||
if: steps.filter.outputs.bot == 'true'
|
||||
run: |
|
||||
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }}
|
||||
|
||||
- name: Build cache-proxy image
|
||||
if: steps.filter.outputs.cache_proxy == 'true'
|
||||
run: |
|
||||
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }} -f ./apps/cache-proxy/Dockerfile .
|
||||
|
||||
- name: Push cache-proxy image to Docker Hub
|
||||
if: steps.filter.outputs.cache_proxy == 'true'
|
||||
run: |
|
||||
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }}
|
||||
# -------------------------------------------------
|
||||
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:latest
|
||||
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
@ -115,10 +62,8 @@ jobs:
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p /home/${{ secrets.VPS_USER }}/zapishis"
|
||||
|
||||
# --- НОВОЕ: Шаг 2: Создание основного .env БЕЗ ТЕГОВ ---
|
||||
- name: Create .env file for deploy
|
||||
- name: Create real .env file for production
|
||||
run: |
|
||||
# Включаем все секреты, КРОМЕ тегов
|
||||
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
|
||||
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
|
||||
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
|
||||
@ -126,26 +71,8 @@ jobs:
|
||||
echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env
|
||||
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env
|
||||
echo "BOT_URL=${{ secrets.BOT_URL }}" >> .env
|
||||
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
|
||||
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
|
||||
echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env
|
||||
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
|
||||
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
|
||||
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
|
||||
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
|
||||
|
||||
# --- НОВОЕ: Шаг 3: Создание файлов тегов (.project.env) ---
|
||||
- name: Create Project Tag Env Files
|
||||
run: |
|
||||
# Создаем файлы, которые будут содержать только одну переменную с тегом
|
||||
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" > .env.web
|
||||
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" > .env.bot
|
||||
echo "CACHE_PROXY_IMAGE_TAG=${{ needs.build-and-push.outputs.cache_proxy_tag }}" > .env.cache-proxy
|
||||
|
||||
# --- Шаг 4: Копирование .env и УСЛОВНОЕ копирование тегов ---
|
||||
|
||||
# Копируем основной .env всегда
|
||||
- name: Copy .env to VPS via SCP (Always)
|
||||
- name: Copy .env to VPS via SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
@ -155,42 +82,6 @@ jobs:
|
||||
source: '.env'
|
||||
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
|
||||
|
||||
# Копируем .env.web ТОЛЬКО, если web был собран (обновляем тег на VPS)
|
||||
- name: Copy .env.web to VPS
|
||||
if: ${{ needs.build-and-push.outputs.web_built == 'true' }}
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT }}
|
||||
source: '.env.web'
|
||||
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
|
||||
|
||||
# Копируем .env.bot ТОЛЬКО, если bot был собран
|
||||
- name: Copy .env.bot to VPS
|
||||
if: ${{ needs.build-and-push.outputs.bot_built == 'true' }}
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT }}
|
||||
source: '.env.bot'
|
||||
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
|
||||
|
||||
# Копируем .env.cache-proxy ТОЛЬКО, если cache-proxy был собран
|
||||
- name: Copy .env.cache-proxy to VPS
|
||||
if: ${{ needs.build-and-push.outputs.cache_proxy_built == 'true' }}
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT }}
|
||||
source: '.env.cache-proxy'
|
||||
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
|
||||
|
||||
- name: Copy docker-compose.yml to VPS via SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
@ -201,27 +92,12 @@ jobs:
|
||||
source: 'docker-compose.yml'
|
||||
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
|
||||
|
||||
# --- ФИНАЛЬНЫЙ ДЕПЛОЙ ---
|
||||
- name: Login and deploy on VPS
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
cd /home/${{ secrets.VPS_USER }}/zapishis && \
|
||||
|
||||
# 1. Объединение ВСЕХ ENV-файлов в один основной .env
|
||||
# Теги из .env.web/.env.bot переопределят любые старые/пустые значения,
|
||||
# и .env станет полным и актуальным.
|
||||
echo \"Merging environment files into .env...\" && \
|
||||
cat .env .env.web .env.bot .env.cache-proxy > .temp_env && \
|
||||
mv .temp_env .env && \
|
||||
|
||||
# 2. Логин
|
||||
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# 3. Pull ВСЕХ сервисов (Docker Compose автоматически использует обновленный .env)
|
||||
echo \"Pulling all services...\" && \
|
||||
docker compose pull
|
||||
|
||||
# 4. Перезапуск
|
||||
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
|
||||
docker compose pull && \
|
||||
docker compose down && \
|
||||
docker compose up -d
|
||||
"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -36,4 +36,3 @@ yarn-error.log*
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.cmd
|
||||
@ -11,7 +11,7 @@ ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN apk add --no-cache libc6-compat && \
|
||||
corepack enable && \
|
||||
pnpm install turbo@2.3.2 dotenv-cli --global
|
||||
pnpm install turbo dotenv-cli --global
|
||||
|
||||
FROM base AS pruner
|
||||
ARG PROJECT
|
||||
|
||||
@ -5,10 +5,5 @@ export default [
|
||||
...typescript,
|
||||
{
|
||||
ignores: ['**/types/**', '*.config.*'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'canonical/id-match': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,148 +0,0 @@
|
||||
# Общие
|
||||
-support-contact = ℹ️ По всем вопросам и обратной связи: @v_dev_support
|
||||
|
||||
# Описание бота
|
||||
short-description =
|
||||
Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅
|
||||
|
||||
{ -support-contact }
|
||||
description =
|
||||
📲 Запишись.онлайн — это встроенное в Telegram приложение + бот для мастеров и тренеров в вашем смартфоне.
|
||||
|
||||
Возможности:
|
||||
• 📅 Ведение графика и запись клиентов
|
||||
• 👥 Клиентская база в одном месте
|
||||
• 🔔 Уведомления о новых и предстоящих записях
|
||||
• 🧑 Работа мастером или тренером прямо в Telegram
|
||||
• 🚀 Создание записи на услугу в пару кликов
|
||||
|
||||
✨ Всё, что нужно — ваш смартфон.
|
||||
|
||||
|
||||
{ -support-contact }
|
||||
|
||||
# Команды
|
||||
start =
|
||||
.description = Запуск бота
|
||||
addcontact =
|
||||
.description = Добавить контакт
|
||||
sharebot =
|
||||
.description = Поделиться ботом
|
||||
subscribe =
|
||||
.description = Приобрести Pro доступ
|
||||
pro =
|
||||
.description = Информация о вашем Pro доступе
|
||||
help =
|
||||
.description = Список команд и поддержка
|
||||
commands-list =
|
||||
📋 Доступные команды:
|
||||
• /addcontact — добавить контакт
|
||||
• /sharebot — поделиться ботом
|
||||
• /subscribe — приобрести Pro доступ
|
||||
• /pro — информация о вашем Pro доступе
|
||||
• /help — список команд
|
||||
|
||||
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
|
||||
support =
|
||||
{ -support-contact }
|
||||
documents =
|
||||
.description = Документы
|
||||
|
||||
# Кнопки
|
||||
btn-add-contact = 👤 Добавить контакт
|
||||
btn-share-bot = 🤝 Поделиться ботом
|
||||
btn-pro = 👑 Pro доступ
|
||||
btn-subscribe = 👑 Приобрести Pro
|
||||
btn-pro-info = ℹ️ Мой Pro доступ
|
||||
btn-open-app = 📱 Открыть приложение
|
||||
btn-faq = 📖 Инструкция
|
||||
btn-documents = 📋 Документы
|
||||
btn-back = ◀️ Назад
|
||||
|
||||
|
||||
# Согласие
|
||||
share-phone-agreement =
|
||||
<i> Нажимая кнопку <b>«Отправить номер телефона»</b></i>,
|
||||
<i>вы:
|
||||
- соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
|
||||
- подтверждаете согласие на обработку персональных данных согласно <a href='{ $privacyUrl }'>Политике конфиденциальности</a></i>
|
||||
share-contact-agreement =
|
||||
<i> Отправляя контакт, имя и номер телефона, вы подтверждаете, что имеете согласие этого человека на передачу его контактных данных и на их обработку в рамках нашего сервиса.
|
||||
(Пункт 4.5 <a href='{ $privacyUrl }'>Политики конфиденциальности</a>)</i>
|
||||
payment-agreement =
|
||||
Совершая оплату, вы соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
|
||||
agreement-links =
|
||||
<a href='{ $offerUrl }'>Публичная оферта</a>
|
||||
<a href='{ $privacyUrl }'>Политика конфиденциальности</a>
|
||||
|
||||
|
||||
# Приветственные сообщения
|
||||
msg-welcome =
|
||||
👋 Добро пожаловать!
|
||||
Пожалуйста, поделитесь своим номером телефона для регистрации
|
||||
msg-welcome-back = 👋 С возвращением, { $name }!
|
||||
|
||||
|
||||
# Сообщения о телефоне
|
||||
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона.
|
||||
msg-phone-saved =
|
||||
✅ Спасибо! Мы сохранили ваш номер телефона
|
||||
Теперь вы можете открыть приложение или воспользоваться командами бота
|
||||
msg-already-registered =
|
||||
✅ Вы уже зарегистрированы в системе
|
||||
|
||||
<i>Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)</i>
|
||||
msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999
|
||||
|
||||
# Сообщения о контактах
|
||||
msg-send-client-contact = 👤 Отправьте контакт пользователя, которого вы хотите добавить.
|
||||
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении
|
||||
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram
|
||||
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
|
||||
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
|
||||
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
|
||||
msg-contact-added =
|
||||
✅ Добавили { $fullname } в список ваших контактов
|
||||
|
||||
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи
|
||||
msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i>
|
||||
|
||||
# Сообщения для шаринга
|
||||
msg-share-bot =
|
||||
📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
|
||||
Нажмите кнопку ниже, чтобы начать
|
||||
|
||||
|
||||
# Системные сообщения
|
||||
msg-cancel = ❌ Операция отменена
|
||||
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
|
||||
msg-cancel-operation = <i>Для отмены операции используйте команду /cancel</i>
|
||||
|
||||
# Ошибки
|
||||
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
||||
err-banned = 🚫 Ваш аккаунт заблокирован
|
||||
err-with-details = ❌ Произошла ошибка
|
||||
{ $error }
|
||||
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного
|
||||
err-missing-telegram-id = ❌ Telegram ID не найден
|
||||
err-cannot-add-self = ❌ Нельзя добавить свой номер телефона как контакт
|
||||
|
||||
|
||||
# Сообщения о доступе
|
||||
msg-subscribe =
|
||||
👑 Pro доступ
|
||||
• Разблокирует неограниченное количество заказов
|
||||
msg-subscribe-success = ✅ Платеж успешно обработан!
|
||||
msg-subscribe-error = ❌ Произошла ошибка при обработке платежа
|
||||
msg-subscription-inactive = 🔴 Pro доступ неактивен
|
||||
msg-subscription-active = 🟢 Ваш Pro доступ активен
|
||||
msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date }
|
||||
msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days }
|
||||
msg-subscription-active-days-short = Осталось дней: { $days }
|
||||
msg-subscription-expired =
|
||||
Ваш Pro доступ истек.
|
||||
Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов
|
||||
msg-subscribe-disabled = 🟢 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉
|
||||
|
||||
# Информация о лимитах
|
||||
msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count }
|
||||
@ -12,27 +12,10 @@
|
||||
"lint-staged": "lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grammyjs/auto-chat-action": "^0.1.1",
|
||||
"@grammyjs/commands": "^1.2.0",
|
||||
"@grammyjs/conversations": "^2.1.0",
|
||||
"@grammyjs/hydrate": "^1.6.0",
|
||||
"@grammyjs/i18n": "^1.1.2",
|
||||
"@grammyjs/menu": "^1.3.1",
|
||||
"@grammyjs/parse-mode": "^2.2.0",
|
||||
"@grammyjs/ratelimiter": "^1.2.1",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/storage-redis": "^2.5.1",
|
||||
"@grammyjs/types": "^3.22.1",
|
||||
"@repo/graphql": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"grammy": "^1.38.1",
|
||||
"ioredis": "^5.7.0",
|
||||
"libphonenumber-js": "^1.12.24",
|
||||
"pino": "^9.9.0",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"radashi": "catalog:",
|
||||
"telegraf": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import { type AutoChatActionFlavor } from '@grammyjs/auto-chat-action';
|
||||
import { type CommandsFlavor } from '@grammyjs/commands';
|
||||
import { type ConversationFlavor } from '@grammyjs/conversations';
|
||||
import { type HydrateFlavor } from '@grammyjs/hydrate';
|
||||
import { type I18nFlavor } from '@grammyjs/i18n';
|
||||
import { type Context as DefaultContext, type SessionFlavor } from 'grammy';
|
||||
|
||||
export type Context = ConversationFlavor<
|
||||
HydrateFlavor<
|
||||
AutoChatActionFlavor & CommandsFlavor & DefaultContext & I18nFlavor & SessionFlavor<SessionData>
|
||||
>
|
||||
>;
|
||||
|
||||
export type SessionData = {};
|
||||
@ -1,156 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable id-length */
|
||||
import { type Context } from '@/bot/context';
|
||||
import { env } from '@/config/env';
|
||||
import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
|
||||
import { parseContact } from '@/utils/contact';
|
||||
import { combine } from '@/utils/messages';
|
||||
import { type Conversation } from '@grammyjs/conversations';
|
||||
import { CustomersService } from '@repo/graphql/api/customers';
|
||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
|
||||
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
|
||||
// Все пользователи могут добавлять контакты
|
||||
const telegramId = ctx.from?.id;
|
||||
if (!telegramId) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
||||
}
|
||||
|
||||
const registrationService = new RegistrationService();
|
||||
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
|
||||
|
||||
if (!customer) {
|
||||
return ctx.reply(
|
||||
await conversation.external(({ t }) =>
|
||||
combine(
|
||||
t('msg-need-phone'),
|
||||
t('share-phone-agreement', {
|
||||
offerUrl: env.URL_OFFER,
|
||||
privacyUrl: env.URL_PRIVACY,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' },
|
||||
);
|
||||
}
|
||||
|
||||
// Просим отправить контакт или номер телефона
|
||||
await ctx.reply(
|
||||
await conversation.external(({ t }) =>
|
||||
combine(
|
||||
t('msg-send-client-contact-or-phone'),
|
||||
t('msg-cancel-operation'),
|
||||
t('share-contact-agreement', {
|
||||
offerUrl: env.URL_OFFER,
|
||||
privacyUrl: env.URL_PRIVACY,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{
|
||||
parse_mode: 'HTML',
|
||||
},
|
||||
);
|
||||
|
||||
// Ждём первое сообщение: контакт или текст с номером
|
||||
const firstCtx = await conversation.wait();
|
||||
|
||||
let name = '';
|
||||
let surname = '';
|
||||
let phone = '';
|
||||
|
||||
if (firstCtx.message?.contact) {
|
||||
/**
|
||||
* Отправлен контакт
|
||||
*/
|
||||
const { contact } = firstCtx.message;
|
||||
const parsedContact = parseContact(contact);
|
||||
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
|
||||
|
||||
name = parsedContact.name;
|
||||
surname = parsedContact.surname;
|
||||
|
||||
if (!parsedPhone?.isValid() || !parsedPhone.number) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
|
||||
}
|
||||
|
||||
phone = parsedPhone.number;
|
||||
} else if (firstCtx.message?.text) {
|
||||
/**
|
||||
* Номер в тексте сообщения
|
||||
*/
|
||||
const parsedPhone = parsePhoneNumber(firstCtx.message.text, 'RU');
|
||||
if (!parsedPhone?.isValid() || !parsedPhone.number) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
|
||||
}
|
||||
|
||||
// Нельзя добавлять свой собственный номер телефона
|
||||
if (customer.phone && customer.phone === parsedPhone.number) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('err-cannot-add-self')));
|
||||
}
|
||||
|
||||
phone = parsedPhone.number;
|
||||
|
||||
// Просим ввести имя клиента
|
||||
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-name')));
|
||||
const nameCtx = await conversation.wait();
|
||||
const typedName = nameCtx.message?.text?.trim() || '';
|
||||
if (!typedName) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-name')));
|
||||
}
|
||||
|
||||
name = typedName;
|
||||
|
||||
// Просим ввести фамилию клиента
|
||||
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-surname')));
|
||||
const surnameCtx = await conversation.wait();
|
||||
const typedSurname = surnameCtx.message?.text?.trim() || '';
|
||||
if (!typedSurname) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-surname')));
|
||||
}
|
||||
|
||||
surname = typedSurname;
|
||||
} else {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone')));
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, есть ли клиент с таким номером
|
||||
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
|
||||
phone,
|
||||
});
|
||||
let documentId = existingCustomer?.documentId;
|
||||
|
||||
// Если клиента нет, создаём нового
|
||||
if (!documentId) {
|
||||
const createCustomerResult = await registrationService.createCustomer({
|
||||
data: { name, phone, surname },
|
||||
});
|
||||
|
||||
documentId = createCustomerResult?.createCustomer?.documentId;
|
||||
if (!documentId) throw new Error('Клиент не создан');
|
||||
}
|
||||
|
||||
// Добавляем текущего пользователя к приглашенному
|
||||
const invitedBy = [customer.documentId];
|
||||
const customerService = new CustomersService({ telegramId });
|
||||
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
|
||||
|
||||
// Отправляем подтверждения и инструкции
|
||||
await ctx.reply(
|
||||
await conversation.external(({ t }) =>
|
||||
t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }),
|
||||
),
|
||||
);
|
||||
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')), {
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT);
|
||||
} catch (error) {
|
||||
await ctx.reply(
|
||||
await conversation.external(({ t }) => t('err-with-details', { error: String(error) })),
|
||||
);
|
||||
}
|
||||
|
||||
return conversation.halt();
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './add-contact';
|
||||
export * from './subscription';
|
||||
@ -1,193 +0,0 @@
|
||||
/* eslint-disable id-length */
|
||||
import { type Context } from '@/bot/context';
|
||||
import { env } from '@/config/env';
|
||||
import { formatMoney } from '@/utils/format';
|
||||
import { combine } from '@/utils/messages';
|
||||
import { type Conversation } from '@grammyjs/conversations';
|
||||
import { fmt, i } from '@grammyjs/parse-mode';
|
||||
import { CustomersService } from '@repo/graphql/api/customers';
|
||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||
import * as GQL from '@repo/graphql/types';
|
||||
import { InlineKeyboard } from 'grammy';
|
||||
import { sift } from 'radashi';
|
||||
|
||||
export async function subscription(conversation: Conversation<Context, Context>, ctx: Context) {
|
||||
const telegramId = ctx.from?.id;
|
||||
if (!telegramId) {
|
||||
return replyError(ctx, conversation);
|
||||
}
|
||||
|
||||
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||
|
||||
const {
|
||||
hasActiveSubscription,
|
||||
remainingDays,
|
||||
subscription: currentSubscription,
|
||||
} = await subscriptionsService.getSubscription({
|
||||
telegramId,
|
||||
});
|
||||
|
||||
const { subscriptionPrices } = await subscriptionsService.getSubscriptionPrices({
|
||||
filters: {
|
||||
active: {
|
||||
eq: true,
|
||||
},
|
||||
period: {
|
||||
ne: GQL.Enum_Subscriptionprice_Period.Trial,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prices = sift(subscriptionPrices);
|
||||
|
||||
// строим клавиатуру с указанием даты окончания после покупки
|
||||
const keyboard = buildPricesKeyboard(
|
||||
prices,
|
||||
currentSubscription?.expiresAt,
|
||||
hasActiveSubscription,
|
||||
);
|
||||
|
||||
// сообщение с выбором плана
|
||||
const messageWithPrices = await ctx.reply(
|
||||
combine(
|
||||
await conversation.external(({ t }) => {
|
||||
let statusLine = t('msg-subscribe');
|
||||
if (hasActiveSubscription && currentSubscription?.expiresAt) {
|
||||
statusLine = t('msg-subscription-active-until', {
|
||||
date: new Date(currentSubscription.expiresAt).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}),
|
||||
});
|
||||
} else if (remainingDays) {
|
||||
statusLine = t('msg-subscription-active-days', { days: remainingDays });
|
||||
}
|
||||
|
||||
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
|
||||
}),
|
||||
),
|
||||
{ parse_mode: 'HTML', reply_markup: keyboard },
|
||||
);
|
||||
|
||||
// ждём выбора
|
||||
const selectPlanWaitCtx = await conversation.wait();
|
||||
|
||||
// удаляем сообщение с выбором
|
||||
try {
|
||||
await ctx.api.deleteMessage(telegramId, messageWithPrices.message_id);
|
||||
} catch {
|
||||
/* игнорируем, если не удалось удалить */
|
||||
}
|
||||
|
||||
const selectedPeriod = selectPlanWaitCtx.callbackQuery?.data;
|
||||
if (!selectedPeriod) return replyError(ctx, conversation);
|
||||
|
||||
const selectedPrice = prices.find((price) => price?.period === selectedPeriod);
|
||||
if (!selectedPrice) return replyError(ctx, conversation);
|
||||
|
||||
// создаём invoice (с указанием даты, до которой будет доступ)
|
||||
const baseDate = currentSubscription?.expiresAt
|
||||
? new Date(Math.max(Date.now(), new Date(currentSubscription.expiresAt).getTime()))
|
||||
: new Date();
|
||||
const targetDate = addDays(baseDate, selectedPrice.days ?? 0);
|
||||
const targetDateRu = targetDate.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const agreementText = await conversation.external(({ t }) => {
|
||||
return t('payment-agreement', {
|
||||
offerUrl: env.URL_OFFER,
|
||||
privacyUrl: env.URL_PRIVACY,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.reply(agreementText, {
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
|
||||
const customerService = new CustomersService({ telegramId });
|
||||
const { customer } = await customerService.getCustomer({ telegramId });
|
||||
|
||||
return ctx.replyWithInvoice(
|
||||
'Оплата Pro доступа',
|
||||
combine(
|
||||
`${selectedPrice.description || 'Pro доступ'} — до ${targetDateRu}`,
|
||||
'(Автопродление отключено)',
|
||||
),
|
||||
JSON.stringify({ period: selectedPrice.period }),
|
||||
'RUB',
|
||||
[
|
||||
{
|
||||
amount: selectedPrice.amount * 100, // Telegram ждёт в копейках
|
||||
label: `${selectedPrice.description || 'К оплате'} — до ${targetDateRu}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
protect_content: true,
|
||||
provider_data: JSON.stringify({
|
||||
receipt: {
|
||||
customer: {
|
||||
phone: customer?.phone.replaceAll(/\D/gu, ''),
|
||||
},
|
||||
items: [
|
||||
{
|
||||
amount: {
|
||||
currency: 'RUB',
|
||||
value: selectedPrice.amount,
|
||||
},
|
||||
description: selectedPrice.description || 'Pro доступ',
|
||||
payment_mode: 'full_payment',
|
||||
payment_subject: 'payment',
|
||||
quantity: 1,
|
||||
vat_code: 1,
|
||||
},
|
||||
],
|
||||
tax_system_code: 1,
|
||||
},
|
||||
}),
|
||||
provider_token: env.BOT_PROVIDER_TOKEN,
|
||||
start_parameter: 'get_access',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function buildPricesKeyboard(
|
||||
prices: GQL.SubscriptionPriceFieldsFragment[],
|
||||
currentExpiresAt?: string,
|
||||
hasActiveSubscription = false,
|
||||
) {
|
||||
const keyboard = new InlineKeyboard();
|
||||
const baseTime = currentExpiresAt
|
||||
? Math.max(Date.now(), new Date(currentExpiresAt).getTime())
|
||||
: Date.now();
|
||||
for (const price of prices) {
|
||||
const targetDate = addDays(new Date(baseTime), price.days ?? 0);
|
||||
const targetDateRu = targetDate.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
keyboard.row({
|
||||
callback_data: price.period,
|
||||
pay: true,
|
||||
text: `${hasActiveSubscription ? 'Продлить' : 'Доступ'} до ${targetDateRu} (${formatMoney(price.amount)})`,
|
||||
});
|
||||
}
|
||||
|
||||
return keyboard;
|
||||
}
|
||||
|
||||
async function replyError(ctx: Context, conversation: Conversation<Context, Context>) {
|
||||
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { handleAddContact } from '../handlers/add-contact';
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('addcontact', logHandle('command-add-contact'), handleAddContact);
|
||||
|
||||
export { composer as addContact };
|
||||
@ -1,12 +0,0 @@
|
||||
import { handleDocuments } from '../handlers/documents';
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('documents', logHandle('command-documents'), handleDocuments);
|
||||
|
||||
export { composer as documents };
|
||||
@ -1,14 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { mainMenu } from '@/config/keyboards';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('help', logHandle('command-help'), async (ctx) => {
|
||||
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
|
||||
});
|
||||
|
||||
export { composer as help };
|
||||
@ -1,8 +0,0 @@
|
||||
export * from './add-contact';
|
||||
export * from './documents';
|
||||
export * from './help';
|
||||
export * from './pro';
|
||||
export * from './registration';
|
||||
export * from './share-bot';
|
||||
export * from './subscription';
|
||||
export * from './welcome';
|
||||
@ -1,11 +0,0 @@
|
||||
import { handlePro } from '../handlers/pro';
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('pro', logHandle('command-pro'), handlePro);
|
||||
|
||||
export { composer as pro };
|
||||
@ -1,81 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards';
|
||||
import { parseContact } from '@/utils/contact';
|
||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||
import { Composer } from 'grammy';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
// Обработка получения контакта от пользователя (регистрация или обновление)
|
||||
feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
|
||||
const telegramId = ctx.from.id;
|
||||
const { contact } = ctx.message;
|
||||
const { name, surname } = parseContact(contact);
|
||||
|
||||
// Проверяем, не зарегистрирован ли уже пользователь
|
||||
const registrationService = new RegistrationService();
|
||||
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
|
||||
telegramId,
|
||||
});
|
||||
|
||||
if (existingCustomer) {
|
||||
return ctx.reply(ctx.t('msg-already-registered'), {
|
||||
...KEYBOARD_REMOVE,
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
}
|
||||
|
||||
// Проверка наличия номера телефона
|
||||
if (!contact.phone_number) {
|
||||
return ctx.reply(ctx.t('msg-invalid-phone'));
|
||||
}
|
||||
|
||||
// Нормализация и валидация номера
|
||||
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
|
||||
if (!parsedPhone?.isValid() || !parsedPhone?.number) {
|
||||
return ctx.reply(ctx.t('msg-invalid-phone'));
|
||||
}
|
||||
|
||||
try {
|
||||
const { customer } = await registrationService._NOCACHE_GetCustomer({
|
||||
phone: parsedPhone.number,
|
||||
});
|
||||
|
||||
if (customer && !customer.telegramId) {
|
||||
// Пользователь добавлен ранее мастером — обновляем данные
|
||||
await registrationService.updateCustomer({
|
||||
data: { active: true, name, surname, telegramId },
|
||||
documentId: customer.documentId,
|
||||
});
|
||||
|
||||
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE });
|
||||
|
||||
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
|
||||
}
|
||||
|
||||
// Новый пользователь — создаём и активируем
|
||||
const response = await registrationService.createCustomer({
|
||||
data: { name, phone: parsedPhone.number, surname, telegramId },
|
||||
});
|
||||
|
||||
const documentId = response?.createCustomer?.documentId;
|
||||
if (!documentId) return ctx.reply(ctx.t('err-generic'));
|
||||
|
||||
await registrationService.updateCustomer({
|
||||
data: { active: true },
|
||||
documentId,
|
||||
});
|
||||
|
||||
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE });
|
||||
|
||||
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
|
||||
} catch (error) {
|
||||
return ctx.reply(ctx.t('err-with-details', { error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
export { composer as registration };
|
||||
@ -1,12 +0,0 @@
|
||||
import { handleShareBot } from '../handlers/share-bot';
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('sharebot', logHandle('command-share-bot'), handleShareBot);
|
||||
|
||||
export { composer as shareBot };
|
||||
@ -1,48 +0,0 @@
|
||||
import { handleSubscribe } from '../handlers/subscription';
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
// Telegram требует отвечать на pre_checkout_query
|
||||
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
|
||||
await ctx.answerPreCheckoutQuery(true);
|
||||
});
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
// команда для входа в flow подписки
|
||||
feature.command('subscribe', logHandle('command-subscribe'), handleSubscribe);
|
||||
|
||||
// успешная оплата
|
||||
feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
|
||||
const telegramId = ctx.from.id;
|
||||
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||
|
||||
try {
|
||||
const rawPayload = ctx.message?.successful_payment.invoice_payload;
|
||||
if (!rawPayload) throw new Error('Missing invoice payload');
|
||||
|
||||
const payload = JSON.parse(rawPayload);
|
||||
|
||||
const provider_payment_charge_id = ctx.message?.successful_payment?.provider_payment_charge_id;
|
||||
|
||||
const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(
|
||||
payload,
|
||||
provider_payment_charge_id,
|
||||
);
|
||||
|
||||
await ctx.reply(ctx.t('msg-subscribe-success'));
|
||||
await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate }));
|
||||
} catch (error) {
|
||||
await ctx.reply(ctx.t('msg-subscribe-error'));
|
||||
logger.error(
|
||||
'Failed to process subscription after successful payment\n' + (error as Error)?.message,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export { composer as subscription };
|
||||
@ -1,17 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.on('message', logHandle('unhandled-message'), (ctx) => {
|
||||
return ctx.reply(ctx.t('msg-unhandled'));
|
||||
});
|
||||
|
||||
feature.on('callback_query', logHandle('unhandled-callback-query'), (ctx) => {
|
||||
return ctx.answerCallbackQuery();
|
||||
});
|
||||
|
||||
export { composer as unhandledFeature };
|
||||
@ -1,42 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { env } from '@/config/env';
|
||||
import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards';
|
||||
import { combine } from '@/utils/messages';
|
||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('start', logHandle('command-start'), async (ctx) => {
|
||||
const telegramId = ctx.from.id;
|
||||
|
||||
const registrationService = new RegistrationService();
|
||||
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
|
||||
|
||||
if (customer) {
|
||||
// Пользователь уже зарегистрирован — приветствуем
|
||||
return ctx.reply(ctx.t('msg-welcome-back', { name: customer.name }), {
|
||||
reply_markup: mainMenu,
|
||||
});
|
||||
}
|
||||
|
||||
// Новый пользователь — просим поделиться номером
|
||||
return ctx.reply(
|
||||
combine(
|
||||
ctx.t('msg-welcome'),
|
||||
ctx.t('share-phone-agreement', {
|
||||
offerUrl: env.URL_OFFER,
|
||||
privacyUrl: env.URL_PRIVACY,
|
||||
}),
|
||||
),
|
||||
{
|
||||
...KEYBOARD_SHARE_PHONE,
|
||||
parse_mode: 'HTML',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export { composer as welcome };
|
||||
@ -1,7 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
await ctx.conversation.enter('addContact');
|
||||
}
|
||||
|
||||
export { handler as handleAddContact };
|
||||
@ -1,18 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { env } from '@/config/env';
|
||||
import { KEYBOARD_REMOVE } from '@/config/keyboards';
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
await ctx.reply(
|
||||
ctx.t('agreement-links', {
|
||||
offerUrl: env.URL_OFFER,
|
||||
privacyUrl: env.URL_PRIVACY,
|
||||
}),
|
||||
{
|
||||
...KEYBOARD_REMOVE,
|
||||
parse_mode: 'HTML',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { handler as handleDocuments };
|
||||
@ -1,19 +0,0 @@
|
||||
import { type Context } from '../context';
|
||||
import { getUpdateInfo } from '../helpers/logging';
|
||||
import { KEYBOARD_REMOVE } from '@/config/keyboards';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { ERRORS } from '@repo/graphql/constants/errors';
|
||||
import { type ErrorHandler } from 'grammy';
|
||||
|
||||
export const errorHandler: ErrorHandler<Context> = async (error) => {
|
||||
const { ctx } = error;
|
||||
|
||||
const text = error.message.includes(ERRORS.NO_PERMISSION) ? 'err-banned' : 'err-generic';
|
||||
|
||||
await ctx.reply(ctx.t(text), { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
|
||||
|
||||
logger.error({
|
||||
err: error.error,
|
||||
update: getUpdateInfo(ctx),
|
||||
});
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './add-contact';
|
||||
export * from './documents';
|
||||
export * from './pro';
|
||||
export * from './share-bot';
|
||||
export * from './subscription';
|
||||
@ -1,41 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { combine } from '@/utils/messages';
|
||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
const telegramId = ctx?.from?.id;
|
||||
|
||||
if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id'));
|
||||
|
||||
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||
|
||||
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
||||
const proEnabled = subscriptionSetting?.proEnabled;
|
||||
|
||||
if (!proEnabled) {
|
||||
await ctx.reply(ctx.t('msg-subscribe-disabled'));
|
||||
}
|
||||
|
||||
const { hasActiveSubscription, remainingDays, remainingOrdersCount } =
|
||||
await subscriptionsService.getSubscription({ telegramId });
|
||||
|
||||
if (hasActiveSubscription && remainingDays > 0) {
|
||||
await ctx.reply(
|
||||
combine(
|
||||
ctx.t('msg-subscription-active'),
|
||||
ctx.t('msg-subscription-active-days-short', { days: remainingDays }),
|
||||
remainingDays === 0 ? ctx.t('msg-subscription-expired') : '',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await ctx.reply(
|
||||
combine(
|
||||
ctx.t('msg-subscription-inactive'),
|
||||
ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }),
|
||||
remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { handler as handlePro };
|
||||
@ -1,9 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { KEYBOARD_SHARE_BOT } from '@/config/keyboards';
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
await ctx.reply(ctx.t('msg-contact-forward'), { parse_mode: 'HTML' });
|
||||
await ctx.reply(ctx.t('msg-share-bot'), { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
export { handler as handleShareBot };
|
||||
@ -1,22 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
const telegramId = ctx?.from?.id;
|
||||
|
||||
if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id'));
|
||||
|
||||
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||
|
||||
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
||||
|
||||
const proEnabled = subscriptionSetting?.proEnabled;
|
||||
|
||||
if (proEnabled) {
|
||||
await ctx.conversation.enter('subscription');
|
||||
} else {
|
||||
await ctx.reply(ctx.t('msg-subscribe-disabled'));
|
||||
}
|
||||
}
|
||||
|
||||
export { handler as handleSubscribe };
|
||||
@ -1,22 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { type Context } from '../context';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { type Update } from '@grammyjs/types';
|
||||
import { type Middleware } from 'grammy';
|
||||
|
||||
export function getUpdateInfo(context: Context): Omit<Update, 'update_id'> {
|
||||
const { update_id, ...update } = context.update;
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
export function logHandle(id: string): Middleware<Context> {
|
||||
return (context, next) => {
|
||||
logger.info({
|
||||
msg: `Handle "${id}"`,
|
||||
...(id.startsWith('unhandled') ? { update: getUpdateInfo(context) } : {}),
|
||||
});
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { type Context } from './context';
|
||||
import { I18n } from '@grammyjs/i18n';
|
||||
import path from 'node:path';
|
||||
|
||||
export const i18n = new I18n<Context>({
|
||||
defaultLocale: 'ru',
|
||||
directory: path.resolve(process.cwd(), 'locales'),
|
||||
fluentBundleOptions: {
|
||||
useIsolating: false,
|
||||
},
|
||||
useSession: true,
|
||||
});
|
||||
|
||||
export const isMultipleLocales = i18n.locales.length > 1;
|
||||
@ -1,71 +0,0 @@
|
||||
import { type Context } from './context';
|
||||
import * as conversations from './conversations';
|
||||
import * as features from './features';
|
||||
import { unhandledFeature } from './features/unhandled';
|
||||
import { errorHandler } from './handlers/errors';
|
||||
import { i18n } from './i18n';
|
||||
import * as middlewares from './middlewares';
|
||||
import { setCommands, setInfo } from './settings';
|
||||
import { env } from '@/config/env';
|
||||
import { mainMenu } from '@/config/keyboards';
|
||||
import { getRedisInstance } from '@/utils/redis';
|
||||
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action';
|
||||
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
|
||||
import { hydrate } from '@grammyjs/hydrate';
|
||||
import { limit } from '@grammyjs/ratelimiter';
|
||||
import { Bot } from 'grammy';
|
||||
|
||||
type Parameters_ = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const redis = getRedisInstance();
|
||||
|
||||
export function createBot({ token }: Parameters_) {
|
||||
const bot = new Bot<Context>(token);
|
||||
|
||||
bot.use(i18n);
|
||||
|
||||
bot.use(
|
||||
limit({
|
||||
keyGenerator: (ctx) => ctx.from?.id.toString(),
|
||||
limit: env.RATE_LIMIT,
|
||||
onLimitExceeded: async (ctx) => {
|
||||
await ctx.reply(ctx.t('err-limit-exceeded'));
|
||||
},
|
||||
storageClient: redis,
|
||||
timeFrame: env.RATE_LIMIT_TIME,
|
||||
}),
|
||||
);
|
||||
|
||||
bot.use(autoChatAction(bot.api));
|
||||
|
||||
bot.use(chatAction('typing'));
|
||||
|
||||
bot.use(grammyConversations()).command('cancel', async (ctx) => {
|
||||
await ctx.conversation.exitAll();
|
||||
await ctx.reply(ctx.t('msg-cancel'));
|
||||
});
|
||||
|
||||
for (const conversation of Object.values(conversations)) {
|
||||
bot.use(createConversation(conversation));
|
||||
}
|
||||
|
||||
bot.use(mainMenu);
|
||||
|
||||
setInfo(bot);
|
||||
setCommands(bot);
|
||||
|
||||
const protectedBot = bot.errorBoundary(errorHandler);
|
||||
|
||||
protectedBot.use(middlewares.updateLogger());
|
||||
protectedBot.use(hydrate());
|
||||
|
||||
for (const feature of Object.values(features)) {
|
||||
protectedBot.use(feature);
|
||||
}
|
||||
|
||||
protectedBot.use(unhandledFeature);
|
||||
|
||||
return bot;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './session';
|
||||
export * from './update-logger';
|
||||
@ -1,20 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { TTL_SESSION } from '@/config/redis';
|
||||
import { getRedisInstance } from '@/utils/redis';
|
||||
import { getSessionKey } from '@/utils/session';
|
||||
import { RedisAdapter } from '@grammyjs/storage-redis';
|
||||
import { session as createSession, type Middleware } from 'grammy';
|
||||
|
||||
const storage = new RedisAdapter({
|
||||
autoParseDates: true,
|
||||
instance: getRedisInstance(),
|
||||
ttl: TTL_SESSION,
|
||||
});
|
||||
|
||||
export function session(): Middleware<Context> {
|
||||
return createSession({
|
||||
getSessionKey,
|
||||
initial: () => ({}),
|
||||
storage,
|
||||
});
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { getUpdateInfo } from '@/bot/helpers/logging';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { type Middleware } from 'grammy';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
export function updateLogger(): Middleware<Context> {
|
||||
return async (ctx, next) => {
|
||||
ctx.api.config.use((previous, method, payload, signal) => {
|
||||
logger.debug({
|
||||
method,
|
||||
msg: 'Bot API call',
|
||||
payload,
|
||||
});
|
||||
|
||||
return previous(method, payload, signal);
|
||||
});
|
||||
|
||||
logger.debug({
|
||||
msg: 'Update received',
|
||||
update: getUpdateInfo(ctx),
|
||||
});
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
return next();
|
||||
} finally {
|
||||
const endTime = performance.now();
|
||||
logger.debug({
|
||||
elapsed: endTime - startTime,
|
||||
msg: 'Update processed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { i18n } from '@/bot/i18n';
|
||||
import { Command, CommandGroup } from '@grammyjs/commands';
|
||||
import { type LanguageCode } from '@grammyjs/types';
|
||||
import { type Api, type Bot, type RawApi } from 'grammy';
|
||||
|
||||
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
|
||||
const commands = createCommands([
|
||||
'start',
|
||||
'addcontact',
|
||||
'sharebot',
|
||||
'help',
|
||||
'subscribe',
|
||||
'pro',
|
||||
'documents',
|
||||
]);
|
||||
|
||||
for (const command of commands) {
|
||||
addLocalizations(command);
|
||||
}
|
||||
|
||||
const commandsGroup = new CommandGroup().add(commands);
|
||||
|
||||
await commandsGroup.setCommands({ api });
|
||||
}
|
||||
|
||||
function addLocalizations(command: Command) {
|
||||
for (const locale of i18n.locales) {
|
||||
command.localize(
|
||||
locale as LanguageCode,
|
||||
command.name,
|
||||
i18n.t(locale, `${command.name}.description`),
|
||||
);
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
function createCommand(name: string) {
|
||||
return new Command(name, i18n.t('en', `${name}.description`)).addToScope({
|
||||
type: 'all_private_chats',
|
||||
});
|
||||
}
|
||||
|
||||
function createCommands(names: string[]) {
|
||||
return names.map((name) => createCommand(name));
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './commands';
|
||||
export * from './info';
|
||||
@ -1,10 +0,0 @@
|
||||
import { type Context } from '../context';
|
||||
import { i18n } from '../i18n';
|
||||
import { type Api, type Bot, type RawApi } from 'grammy';
|
||||
|
||||
export async function setInfo({ api }: Bot<Context, Api<RawApi>>) {
|
||||
for (const locale of i18n.locales) {
|
||||
await api.setMyDescription(i18n.t(locale, 'description'));
|
||||
await api.setMyShortDescription(i18n.t(locale, 'short-description'));
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,9 @@
|
||||
/* eslint-disable unicorn/prevent-abbreviations */
|
||||
import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
BOT_PROVIDER_TOKEN: z.string(),
|
||||
BOT_TOKEN: z.string(),
|
||||
BOT_URL: z.string(),
|
||||
RATE_LIMIT: z
|
||||
.string()
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
.default('2'),
|
||||
RATE_LIMIT_TIME: z
|
||||
.string()
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
.default('3000'),
|
||||
REDIS_HOST: z.string().default('redis'),
|
||||
REDIS_PASSWORD: z.string(),
|
||||
REDIS_PORT: z
|
||||
.string()
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
.default('6379'),
|
||||
URL_FAQ: z.string(),
|
||||
URL_OFFER: z.string(),
|
||||
URL_PRIVACY: z.string(),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
import { env } from './env';
|
||||
import { type Context } from '@/bot/context';
|
||||
import {
|
||||
handleAddContact,
|
||||
handleDocuments,
|
||||
handlePro,
|
||||
handleShareBot,
|
||||
handleSubscribe,
|
||||
} from '@/bot/handlers';
|
||||
import { Menu } from '@grammyjs/menu';
|
||||
import {
|
||||
type InlineKeyboardMarkup,
|
||||
type ReplyKeyboardMarkup,
|
||||
type ReplyKeyboardRemove,
|
||||
} from '@grammyjs/types';
|
||||
|
||||
export const KEYBOARD_SHARE_PHONE = {
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[
|
||||
{
|
||||
request_contact: true,
|
||||
text: ' Отправить номер телефона',
|
||||
},
|
||||
],
|
||||
],
|
||||
one_time_keyboard: true,
|
||||
} as ReplyKeyboardMarkup,
|
||||
};
|
||||
|
||||
export const KEYBOARD_REMOVE = {
|
||||
reply_markup: {
|
||||
remove_keyboard: true,
|
||||
} as ReplyKeyboardRemove,
|
||||
};
|
||||
|
||||
export const KEYBOARD_SHARE_BOT = {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: ' Воспользоваться ботом',
|
||||
url: env.BOT_URL + '?start=new',
|
||||
},
|
||||
],
|
||||
],
|
||||
} as InlineKeyboardMarkup,
|
||||
};
|
||||
|
||||
// Главное меню
|
||||
export const mainMenu = new Menu<Context>('main-menu', { autoAnswer: true })
|
||||
.text((ctx) => ctx.t('btn-add-contact'), handleAddContact)
|
||||
.row()
|
||||
.text((ctx) => ctx.t('btn-subscribe'), handleSubscribe)
|
||||
.text((ctx) => ctx.t('btn-pro-info'), handlePro)
|
||||
.row()
|
||||
.text((ctx) => ctx.t('btn-share-bot'), handleShareBot)
|
||||
.row()
|
||||
.text((ctx) => ctx.t('btn-documents'), handleDocuments)
|
||||
.row()
|
||||
.url(
|
||||
(ctx) => ctx.t('btn-faq'),
|
||||
() => env.URL_FAQ,
|
||||
)
|
||||
.row()
|
||||
.url(
|
||||
(ctx) => ctx.t('btn-open-app'),
|
||||
() => {
|
||||
const botUrl = new URL(env.BOT_URL);
|
||||
botUrl.searchParams.set('startapp', '');
|
||||
return botUrl.toString();
|
||||
},
|
||||
);
|
||||
@ -1 +0,0 @@
|
||||
export const TTL_SESSION = 5 * 60; // 5 minutes in seconds
|
||||
@ -1,46 +1,276 @@
|
||||
import { createBot } from './bot';
|
||||
/* eslint-disable canonical/id-match */
|
||||
/* eslint-disable consistent-return */
|
||||
import { env as environment } from './config/env';
|
||||
import { logger } from './utils/logger';
|
||||
import { getRedisInstance } from './utils/redis';
|
||||
import { run } from '@grammyjs/runner';
|
||||
import {
|
||||
commandsList,
|
||||
KEYBOARD_REMOVE,
|
||||
KEYBOARD_SHARE_BOT,
|
||||
KEYBOARD_SHARE_PHONE,
|
||||
MESSAGE_CANCEL,
|
||||
MESSAGE_INVALID_PHONE,
|
||||
MESSAGE_NOT_MASTER,
|
||||
MESSAGE_SHARE_BOT,
|
||||
MSG_ALREADY_MASTER,
|
||||
MSG_BECOME_MASTER,
|
||||
MSG_CONTACT_ADDED,
|
||||
MSG_CONTACT_FORWARD,
|
||||
MSG_ERROR,
|
||||
MSG_NEED_PHONE,
|
||||
MSG_PHONE_SAVED,
|
||||
MSG_SEND_CLIENT_CONTACT,
|
||||
MSG_WELCOME,
|
||||
MSG_WELCOME_BACK,
|
||||
} from './message';
|
||||
import { isCustomerMaster } from './utils/customer';
|
||||
import { isValidPhoneNumber, normalizePhoneNumber } from './utils/phone';
|
||||
import { CustomersService } from '@repo/graphql/api/customers';
|
||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||
import { Scenes, session, Telegraf, type Context as TelegrafContext } from 'telegraf';
|
||||
import { message } from 'telegraf/filters';
|
||||
import {
|
||||
type SceneContextScene,
|
||||
type SceneSession,
|
||||
type WizardContextWizard,
|
||||
type WizardSessionData,
|
||||
} from 'telegraf/typings/scenes';
|
||||
|
||||
const bot = createBot({
|
||||
token: environment.BOT_TOKEN,
|
||||
// Расширяем контекст бота для работы с сценами и сессиями
|
||||
type BotContext = TelegrafContext & {
|
||||
scene: SceneContextScene<BotContext, WizardSessionData>;
|
||||
session: SceneSession<WizardSessionData>;
|
||||
wizard: WizardContextWizard<BotContext>;
|
||||
};
|
||||
|
||||
// Создаём экземпляр бота с токеном
|
||||
const bot = new Telegraf<BotContext>(environment.BOT_TOKEN);
|
||||
|
||||
// Создаём менеджер сцен и подключаем сессию
|
||||
const stage = new Scenes.Stage<BotContext>();
|
||||
bot.use(session({ defaultSession: () => ({ __scenes: { cursor: 0, state: {} } }) }));
|
||||
bot.use(stage.middleware());
|
||||
|
||||
// Сцена добавления контакта клиента мастером
|
||||
const addContactScene = new Scenes.WizardScene<BotContext>(
|
||||
'add-contact',
|
||||
|
||||
// Шаг 1: Просим отправить контакт клиента
|
||||
async (context) => {
|
||||
await context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' });
|
||||
return context.wizard.next();
|
||||
},
|
||||
|
||||
// Шаг 2: Обрабатываем полученный контакт
|
||||
async (context) => {
|
||||
if (!context.from) {
|
||||
await context.reply('Ошибка: не удалось определить пользователя');
|
||||
return context.scene.leave();
|
||||
}
|
||||
|
||||
// Команда отмены — выход из сцены
|
||||
if (context.message && 'text' in context.message && context.message.text === '/cancel') {
|
||||
await context.reply(MESSAGE_CANCEL + commandsList, { parse_mode: 'HTML' });
|
||||
return context.scene.leave();
|
||||
}
|
||||
|
||||
// Проверяем, что отправлен контакт (через кнопку Telegram)
|
||||
if (!context.message || !('contact' in context.message)) {
|
||||
await context.reply('Пожалуйста, отправьте контакт клиента через кнопку Telegram');
|
||||
return; // остаёмся в сцене, ждем корректный контакт
|
||||
}
|
||||
|
||||
const telegramId = context.from.id;
|
||||
const customerService = new CustomersService({ telegramId });
|
||||
|
||||
// Проверяем, что текущий пользователь — мастер
|
||||
const { customer } = await customerService.getCustomer({ telegramId });
|
||||
if (!customer || !isCustomerMaster(customer)) {
|
||||
await context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
|
||||
return context.scene.leave();
|
||||
}
|
||||
|
||||
const { contact } = context.message;
|
||||
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
||||
const phone = normalizePhoneNumber(contact.phone_number);
|
||||
|
||||
// Проверяем валидность номера телефона
|
||||
if (!isValidPhoneNumber(phone)) {
|
||||
await context.reply(MESSAGE_INVALID_PHONE, { parse_mode: 'HTML' });
|
||||
return; // остаёмся в сцене, ждем правильный номер
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, есть ли клиент с таким номером
|
||||
const { customer: existingCustomer } = await customerService.getCustomer({ phone });
|
||||
let documentId = existingCustomer?.documentId;
|
||||
|
||||
// Если клиента нет, создаём нового
|
||||
if (!documentId) {
|
||||
const registrationService = new RegistrationService();
|
||||
const createCustomerResult = await registrationService.createCustomer({ name, phone });
|
||||
|
||||
documentId = createCustomerResult?.createCustomer?.documentId;
|
||||
if (!documentId) throw new Error('Клиент не создан');
|
||||
}
|
||||
|
||||
// Добавляем текущего мастера к клиенту
|
||||
const masters = [customer.documentId];
|
||||
await customerService.addMasters({ data: { masters }, documentId });
|
||||
|
||||
// Отправляем подтверждения и инструкции
|
||||
await context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' });
|
||||
await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' });
|
||||
await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
|
||||
} catch (error) {
|
||||
await context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
|
||||
} finally {
|
||||
await context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
|
||||
context.scene.leave();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Регистрируем сцену
|
||||
stage.register(addContactScene);
|
||||
|
||||
// Команда /start — приветствие и запрос номера, если пользователь новый
|
||||
bot.start(async (context) => {
|
||||
const telegramId = context.from.id;
|
||||
|
||||
const customerService = new CustomersService({ telegramId });
|
||||
const { customer } = await customerService.getCustomer({ telegramId });
|
||||
|
||||
if (customer) {
|
||||
// Пользователь уже зарегистрирован — приветствуем
|
||||
return context.reply(MSG_WELCOME_BACK(customer.name) + commandsList, {
|
||||
...KEYBOARD_REMOVE,
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
}
|
||||
|
||||
// Новый пользователь — просим поделиться номером
|
||||
return context.reply(MSG_WELCOME, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
|
||||
});
|
||||
|
||||
bot.catch((error) => {
|
||||
logger.error('Grammy bot error:');
|
||||
logger.error(`Message: ${error?.message}`);
|
||||
logger.error(error.error);
|
||||
// Команда /help — список команд
|
||||
bot.command('help', async (context) => {
|
||||
return context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
|
||||
});
|
||||
|
||||
const runner = run(bot);
|
||||
const redis = getRedisInstance();
|
||||
// Команда /addcontact — начать сцену добавления контакта
|
||||
bot.command('addcontact', async (context) => {
|
||||
const telegramId = context.from.id;
|
||||
const customerService = new CustomersService({ telegramId });
|
||||
const { customer } = await customerService.getCustomer({ telegramId });
|
||||
|
||||
async function gracefulShutdown(signal: string) {
|
||||
logger.info(`Received ${signal}, starting graceful shutdown...`);
|
||||
if (!customer) {
|
||||
// Нет номера — просим поделиться
|
||||
return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
if (!isCustomerMaster(customer)) {
|
||||
// Нет прав мастера
|
||||
return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
// Входим в сцену
|
||||
return context.scene.enter('add-contact');
|
||||
});
|
||||
|
||||
// Команда /becomemaster — запрос статуса мастера
|
||||
bot.command('becomemaster', async (context) => {
|
||||
const telegramId = context.from.id;
|
||||
const customerService = new CustomersService({ telegramId });
|
||||
const { customer } = await customerService.getCustomer({ telegramId });
|
||||
|
||||
if (!customer) {
|
||||
return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
if (isCustomerMaster(customer)) {
|
||||
return context.reply(MSG_ALREADY_MASTER, { parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
// Обновляем роль клиента на мастер
|
||||
const response = await customerService
|
||||
.updateCustomer({
|
||||
data: { role: Enum_Customer_Role.Master },
|
||||
})
|
||||
.catch((error) => {
|
||||
context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
|
||||
});
|
||||
|
||||
if (response) {
|
||||
return context.reply(MSG_BECOME_MASTER, { parse_mode: 'HTML' });
|
||||
}
|
||||
});
|
||||
|
||||
// Команда /sharebot — прислать ссылку на бота
|
||||
bot.command('sharebot', async (context) => {
|
||||
await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' });
|
||||
await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
|
||||
});
|
||||
|
||||
// Обработка получения контакта от пользователя (регистрация или обновление)
|
||||
bot.on(message('contact'), async (context) => {
|
||||
const telegramId = context.from.id;
|
||||
const { contact } = context.message;
|
||||
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
||||
|
||||
// Проверка наличия номера телефона
|
||||
if (!contact.phone_number) {
|
||||
return context.reply(MESSAGE_INVALID_PHONE, { parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
// Нормализация и валидация номера
|
||||
const phone = normalizePhoneNumber(contact.phone_number);
|
||||
if (!isValidPhoneNumber(phone)) {
|
||||
return context.reply(MESSAGE_INVALID_PHONE, { parse_mode: 'HTML' });
|
||||
}
|
||||
|
||||
const registrationService = new RegistrationService();
|
||||
|
||||
try {
|
||||
await runner.stop();
|
||||
logger.info('Bot stopped');
|
||||
const { customer } = await registrationService.getCustomer({ phone });
|
||||
|
||||
redis.disconnect();
|
||||
logger.info('Redis disconnected');
|
||||
if (customer && !customer.telegramId) {
|
||||
// Пользователь добавлен ранее мастером — обновляем данные
|
||||
await registrationService.updateCustomer({
|
||||
data: { active: true, name, telegramId },
|
||||
documentId: customer.documentId,
|
||||
});
|
||||
|
||||
return context.reply(MSG_PHONE_SAVED + commandsList, {
|
||||
...KEYBOARD_REMOVE,
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
}
|
||||
|
||||
// Новый пользователь — создаём и активируем
|
||||
const response = await registrationService.createCustomer({ name, phone, telegramId });
|
||||
|
||||
const documentId = response?.createCustomer?.documentId;
|
||||
if (!documentId) {
|
||||
throw new Error('Не удалось создать клиента: отсутствует documentId');
|
||||
}
|
||||
|
||||
await registrationService.updateCustomer({
|
||||
data: { active: true },
|
||||
documentId,
|
||||
});
|
||||
|
||||
return context.reply(MSG_PHONE_SAVED + commandsList, {
|
||||
...KEYBOARD_REMOVE,
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
} catch (error) {
|
||||
const err_ = error as Error;
|
||||
logger.error('Error during graceful shutdown:' + err_.message || '');
|
||||
return context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
|
||||
}
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('Unhandled Rejection: ' + reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception: ' + error);
|
||||
});
|
||||
// Запуск бота
|
||||
bot.launch();
|
||||
|
||||
logger.info('Bot started');
|
||||
// Корректное завершение работы
|
||||
process.once('SIGINT', () => bot.stop('SIGINT'));
|
||||
process.once('SIGTERM', () => bot.stop('SIGTERM'));
|
||||
|
||||
83
apps/bot/src/message.ts
Normal file
83
apps/bot/src/message.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { env as environment } from './config/env';
|
||||
import { type ReplyKeyboardRemove } from 'telegraf/types';
|
||||
|
||||
export const commandsList = `
|
||||
\n<b>📋 Доступные команды:</b>
|
||||
• <b>/addcontact</b> — добавить контакт клиента
|
||||
• <b>/becomemaster</b> — стать мастером
|
||||
• <b>/sharebot</b> — поделиться ботом
|
||||
• <b>/help</b> — список команд
|
||||
\n
|
||||
Откройте приложение кнопкой <b>"Открыть"</b>, чтобы отредактировать свой профиль или создать запись
|
||||
`;
|
||||
|
||||
export const KEYBOARD_SHARE_PHONE = {
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[
|
||||
{
|
||||
request_contact: true,
|
||||
text: '📱 Отправить номер телефона',
|
||||
},
|
||||
],
|
||||
],
|
||||
one_time_keyboard: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const KEYBOARD_REMOVE = {
|
||||
reply_markup: {
|
||||
remove_keyboard: true,
|
||||
} as ReplyKeyboardRemove,
|
||||
};
|
||||
|
||||
export const KEYBOARD_SHARE_BOT = {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: '🤖Воспользоваться ботом',
|
||||
url: environment.BOT_URL,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MESSAGE_NOT_MASTER =
|
||||
'⛔️ <b>Только мастер может добавлять контакты</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>';
|
||||
|
||||
export const MSG_WELCOME =
|
||||
'👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации';
|
||||
|
||||
export const MSG_WELCOME_BACK = (name: string) =>
|
||||
`👋 <b>С возвращением, ${name}!</b>`;
|
||||
|
||||
export const MSG_NEED_PHONE =
|
||||
'📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона</b>';
|
||||
|
||||
export const MSG_SEND_CLIENT_CONTACT =
|
||||
'👤 <b>Отправьте контакт клиента, которого вы хотите добавить. \n<em>Для отмены операции используйте команду /cancel</em></b>';
|
||||
|
||||
export const MSG_ALREADY_MASTER = '🎉 <b>Вы уже являетесь мастером!</b>';
|
||||
|
||||
export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер</b>';
|
||||
|
||||
export const MSG_ERROR = (error?: unknown) =>
|
||||
`❌ <b>Произошла ошибка</b>\n${error ? String(error) : ''}`;
|
||||
|
||||
export const MSG_PHONE_SAVED =
|
||||
'✅ <b>Спасибо! Мы сохранили ваш номер телефона</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота';
|
||||
|
||||
export const MSG_CONTACT_ADDED = (name: string) =>
|
||||
`✅ <b>Добавили <b>${name}</b> в список ваших клиентов</b>\n\nПригласите клиента в приложение, чтобы вы могли добавлять с ним записи`;
|
||||
|
||||
export const MSG_CONTACT_FORWARD =
|
||||
'<em>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</em>';
|
||||
|
||||
export const MESSAGE_SHARE_BOT =
|
||||
'📅 <b>Воспользуйтесь этим ботом для записи к вашему мастеру!</b>\nНажмите кнопку ниже, чтобы начать';
|
||||
|
||||
export const MESSAGE_CANCEL = '<b>❌ Отменена операции</b>';
|
||||
|
||||
export const MESSAGE_INVALID_PHONE = '❌ <b>Некорректный номер телефона</b>';
|
||||
@ -1,9 +0,0 @@
|
||||
import { type Contact } from '@grammyjs/types';
|
||||
|
||||
export function parseContact(contact: Contact) {
|
||||
return {
|
||||
name: contact?.first_name?.trim() || '',
|
||||
phone: contact?.phone_number?.trim() || '',
|
||||
surname: contact?.last_name?.trim() || '',
|
||||
};
|
||||
}
|
||||
5
apps/bot/src/utils/customer.ts
Normal file
5
apps/bot/src/utils/customer.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import * as GQL from '@repo/graphql/types';
|
||||
|
||||
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
|
||||
return customer?.role === GQL.Enum_Customer_Role.Master;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export const formatMoney = Intl.NumberFormat('ru-RU', {
|
||||
currency: 'RUB',
|
||||
style: 'currency',
|
||||
}).format;
|
||||
@ -1,19 +0,0 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: true,
|
||||
},
|
||||
target: 'pino-pretty',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info = logger.info.bind(logger);
|
||||
logger.debug = logger.debug.bind(logger);
|
||||
logger.error = logger.error.bind(logger);
|
||||
|
||||
export { logger };
|
||||
@ -1,3 +0,0 @@
|
||||
export function combine(...messages: Array<string | undefined>) {
|
||||
return messages.filter(Boolean).join('\n\n');
|
||||
}
|
||||
9
apps/bot/src/utils/phone.ts
Normal file
9
apps/bot/src/utils/phone.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function isValidPhoneNumber(phone: string) {
|
||||
return /^\+7\d{10}$/u.test(phone);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(phone: string): string {
|
||||
const digitsOnly = phone.replaceAll(/\D/gu, '');
|
||||
|
||||
return `+${digitsOnly}`;
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { env } from '@/config/env';
|
||||
import { logger } from '@/utils/logger';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const instance: Redis = createRedisInstance();
|
||||
|
||||
export function getRedisInstance() {
|
||||
if (!instance) return createRedisInstance();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
function createRedisInstance() {
|
||||
const redis = new Redis({
|
||||
host: env.REDIS_HOST,
|
||||
password: env.REDIS_PASSWORD,
|
||||
port: env.REDIS_PORT,
|
||||
});
|
||||
|
||||
redis.on('error', logger.error);
|
||||
|
||||
return redis;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
|
||||
export function getSessionKey(ctx: Omit<Context, 'session'>) {
|
||||
return ctx.chat?.id.toString();
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { TIKTOK_URL_REGEX } from '@/constants/regex';
|
||||
|
||||
export function validateTikTokUrl(url: string) {
|
||||
return TIKTOK_URL_REGEX.test(url);
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
"moduleResolution": "Node",
|
||||
"module": "CommonJS",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
@ -7,7 +7,7 @@ export default defineConfig({
|
||||
external: ['telegraf', 'zod'],
|
||||
format: 'cjs',
|
||||
loader: { '.json': 'copy' },
|
||||
minify: false,
|
||||
minify: true,
|
||||
noExternal: ['@repo'],
|
||||
outDir: './dist',
|
||||
sourcemap: false,
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
.git
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
*.log
|
||||
dist
|
||||
README.md
|
||||
@ -1,13 +0,0 @@
|
||||
import { typescript } from '@repo/eslint-config/typescript';
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default [
|
||||
...typescript,
|
||||
{
|
||||
ignores: ['**/types/**', '*.config.*', '*.config.js', '.eslintrc.js'],
|
||||
rules: {
|
||||
'import/no-duplicates': 'off',
|
||||
'import/consistent-type-specifier-style': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
56
apps/cache-proxy/.gitignore
vendored
56
apps/cache-proxy/.gitignore
vendored
@ -1,56 +0,0 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
@ -1,51 +0,0 @@
|
||||
ARG NODE_VERSION=22
|
||||
ARG PROJECT=cache-proxy
|
||||
|
||||
# Alpine image
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
FROM alpine as base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN apk add --no-cache libc6-compat && \
|
||||
corepack enable && \
|
||||
pnpm install turbo@2.3.2 dotenv-cli --global
|
||||
|
||||
FROM base AS pruner
|
||||
ARG PROJECT
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN turbo prune --scope=${PROJECT} --docker
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --prod --frozen-lockfile
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
COPY turbo.json turbo.json
|
||||
COPY .env .env
|
||||
|
||||
RUN dotenv -e .env turbo run build --filter=${PROJECT}...
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm prune --prod --no-optional
|
||||
RUN rm -rf ./**/*/src
|
||||
|
||||
FROM alpine AS runner
|
||||
ARG PROJECT
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 appuser
|
||||
USER appuser
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=nodejs:nodejs /app .
|
||||
WORKDIR /app/apps/${PROJECT}
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
@ -1,73 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](LICENSE).
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"monorepo": true,
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
{
|
||||
"name": "cache-proxy",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev": "dotenv -e ../../.env.local nest start -- --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/cache-manager": "^2.2.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/platform-fastify": "^10.3.3",
|
||||
"@types/node": "catalog:",
|
||||
"fastify": "^4.26.1",
|
||||
"dotenv-cli": "catalog:",
|
||||
"cache-manager": "^5.4.0",
|
||||
"cache-manager-ioredis": "^2.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"eslint": "catalog:",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "catalog:",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { ProxyModule } from './proxy/proxy.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
ProxyModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [],
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
export class AppModule {}
|
||||
@ -1,3 +0,0 @@
|
||||
import { seconds } from 'src/utils/time';
|
||||
|
||||
export const DEFAULT_CACHE_TTL = seconds().fromMinutes(5);
|
||||
@ -1,3 +0,0 @@
|
||||
import envSchema from './schema/env';
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
@ -1,22 +0,0 @@
|
||||
import { DEFAULT_CACHE_TTL } from '../constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
CACHE_TTL: z
|
||||
.string()
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.default(DEFAULT_CACHE_TTL.toString()),
|
||||
PORT: z
|
||||
.string()
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.default('5000'),
|
||||
REDIS_HOST: z.string().default('redis'),
|
||||
REDIS_PASSWORD: z.string(),
|
||||
REDIS_PORT: z
|
||||
.string()
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
.default('6379'),
|
||||
URL_GRAPHQL: z.string(),
|
||||
});
|
||||
|
||||
export default envSchema;
|
||||
@ -1,11 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('api')
|
||||
export class HealthController {
|
||||
@Get('health')
|
||||
public health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { AppModule } from './app.module';
|
||||
import { env } from './config/env';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { FastifyAdapter } from '@nestjs/platform-fastify';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(),
|
||||
);
|
||||
|
||||
await app.listen(env.PORT, '0.0.0.0');
|
||||
}
|
||||
bootstrap();
|
||||
@ -1,16 +0,0 @@
|
||||
import { seconds } from 'src/utils/time';
|
||||
|
||||
export const queryTTL: Record<string, number | false> = {
|
||||
GetCustomer: seconds().fromHours(12),
|
||||
GetCustomers: false,
|
||||
GetInvited: false,
|
||||
GetInvitedBy: false,
|
||||
GetOrders: false,
|
||||
GetServices: false,
|
||||
GetSlots: false,
|
||||
GetSlotsOrders: false,
|
||||
GetSubscriptionHistory: false,
|
||||
GetSubscriptions: false,
|
||||
GetSubscriptionSettings: seconds().fromHours(12),
|
||||
Login: false,
|
||||
};
|
||||
@ -1,8 +0,0 @@
|
||||
import { env } from 'src/config/env';
|
||||
import { queryTTL } from './config';
|
||||
|
||||
export function getQueryTTL(operationName: string) {
|
||||
if (operationName.includes('NOCACHE')) return false;
|
||||
|
||||
return queryTTL[operationName] ?? env.CACHE_TTL;
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
import type { GQLRequest, GQLResponse } from './types';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import {
|
||||
All,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { env } from 'src/config/env';
|
||||
import { extractDocumentId, getQueryType } from 'src/utils/query';
|
||||
import { getQueryTTL } from './lib/utils';
|
||||
|
||||
type RedisStore = Omit<Cache, 'set'> & {
|
||||
set: (key: string, value: unknown, { ttl }: { ttl: number }) => Promise<void>;
|
||||
};
|
||||
|
||||
@Controller('api')
|
||||
export class ProxyController {
|
||||
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: RedisStore) {}
|
||||
|
||||
@All('/graphql')
|
||||
public async graphql(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
const { operationName, query, variables } = req.body as GQLRequest;
|
||||
|
||||
const queryType = getQueryType(query);
|
||||
|
||||
const key = `${operationName} ${JSON.stringify(variables)}`;
|
||||
|
||||
if (queryType.action === 'query') {
|
||||
const cached = await this.cacheManager.get(key);
|
||||
if (cached) return reply.send(cached);
|
||||
}
|
||||
|
||||
const response = await fetch(env.URL_GRAPHQL, {
|
||||
body: JSON.stringify({ operationName, query, variables }),
|
||||
headers: {
|
||||
Authorization: req.headers.authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: req.headers.cookie,
|
||||
},
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
const data = (await response.json()) as GQLResponse;
|
||||
|
||||
if (!response.ok || data?.error || data?.errors?.length)
|
||||
throw new HttpException(
|
||||
response.statusText,
|
||||
response.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
|
||||
if (queryType.action === 'mutation' && queryType.entity) {
|
||||
const documentId = extractDocumentId(data);
|
||||
const keys = await this.cacheManager.store.keys(`*${queryType.entity}*`);
|
||||
for (const key of keys) {
|
||||
if (key.includes(documentId)) {
|
||||
await this.cacheManager.del(key);
|
||||
|
||||
// console.log(`🗑 Cache invalidated (by key): ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = await this.cacheManager.get(key);
|
||||
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
if (serialized?.includes(documentId)) {
|
||||
await this.cacheManager.del(key);
|
||||
|
||||
// console.log(`🗑 Cache invalidated (by value): ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ttl = getQueryTTL(operationName);
|
||||
if (queryType.action === 'query' && data && ttl !== false)
|
||||
await this.cacheManager.set(key, data, { ttl });
|
||||
|
||||
return reply.send(data);
|
||||
}
|
||||
|
||||
@Get('/get-queries')
|
||||
public async getQueriesList(@Res() reply: FastifyReply) {
|
||||
const keys: string[] = await this.cacheManager.store.keys('*');
|
||||
|
||||
const entries = await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
try {
|
||||
const value = await this.cacheManager.get(key);
|
||||
return { key, value };
|
||||
} catch (e) {
|
||||
return { key, error: e.message };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return reply.send(entries);
|
||||
}
|
||||
|
||||
@Delete('/delete-query')
|
||||
public async deleteQuery(@Query('queryKey') queryKey: string, @Res() reply: FastifyReply) {
|
||||
try {
|
||||
await this.cacheManager.del(queryKey);
|
||||
|
||||
return reply.send('ok');
|
||||
} catch (error) {
|
||||
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('/reset')
|
||||
public async reset(@Res() reply: FastifyReply) {
|
||||
try {
|
||||
await this.cacheManager.reset();
|
||||
|
||||
return reply.send('ok');
|
||||
} catch (error) {
|
||||
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/get-query')
|
||||
public async getQueryValue(@Query('queryKey') queryKey: string, @Res() reply: FastifyReply) {
|
||||
try {
|
||||
const value = await this.cacheManager.get(queryKey);
|
||||
|
||||
return reply.send(value);
|
||||
} catch (error) {
|
||||
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { ProxyController } from './proxy.controller';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import * as redisStore from 'cache-manager-ioredis';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import { env } from 'src/config/env';
|
||||
|
||||
@Module({
|
||||
controllers: [ProxyController],
|
||||
imports: [
|
||||
CacheModule.register<RedisOptions>({
|
||||
host: env.REDIS_HOST,
|
||||
port: env.REDIS_PORT,
|
||||
store: redisStore,
|
||||
ttl: env.CACHE_TTL,
|
||||
password: env.REDIS_PASSWORD,
|
||||
db: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
export class ProxyModule {}
|
||||
@ -1,16 +0,0 @@
|
||||
export type GQLRequest = {
|
||||
operationName: string;
|
||||
query: string;
|
||||
variables: string;
|
||||
};
|
||||
|
||||
export type GQLResponse = {
|
||||
data: unknown;
|
||||
error?: unknown;
|
||||
errors?: unknown[];
|
||||
};
|
||||
|
||||
export type QueryItem = {
|
||||
queries: string[];
|
||||
ttl: number | false;
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
import { GQLResponse } from 'src/proxy/types';
|
||||
|
||||
export function getQueryType(query: string) {
|
||||
const actionMatch = query.match(/\b(query|mutation)\b/u);
|
||||
const action = actionMatch ? (actionMatch[1] as 'query' | 'mutation') : null;
|
||||
|
||||
const entityMatch = query.match(
|
||||
/\b(mutation|query)\s+\w*([A-Z][A-Za-z0-9_]+)/u,
|
||||
);
|
||||
const entity = entityMatch ? entityMatch[2] : null;
|
||||
|
||||
return { action, entity };
|
||||
}
|
||||
|
||||
export function extractDocumentId(data: GQLResponse) {
|
||||
if (!data?.data) return null;
|
||||
|
||||
const firstKey = Object.keys(data.data)[0];
|
||||
if (!firstKey) return null;
|
||||
|
||||
return data.data[firstKey]?.documentId || null;
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
export function seconds() {
|
||||
return {
|
||||
fromDays(days: number) {
|
||||
return days * 24 * 60 * 60;
|
||||
},
|
||||
fromHours(hours: number) {
|
||||
return hours * 60 * 60;
|
||||
},
|
||||
fromMinutes(minutes: number) {
|
||||
return minutes * 60;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
@ -1,240 +0,0 @@
|
||||
# Система проверки бана пользователей
|
||||
|
||||
## Обзор
|
||||
|
||||
Реализована многоуровневая система проверки бана пользователей, которая предотвращает доступ заблокированных пользователей к функциональности приложения.
|
||||
|
||||
## 1. База данных (`bannedUntil` поле)
|
||||
|
||||
В Strapi добавлено поле `bannedUntil` типа `datetime` в модель `Customer`:
|
||||
- `null` = пользователь не забанен
|
||||
- `дата в будущем` = временный бан до указанной даты
|
||||
- `дата в далеком будущем` = постоянный бан
|
||||
|
||||
## 2. Утилита проверки (`packages/utils/src/customer.ts`)
|
||||
|
||||
```typescript
|
||||
export function isCustomerBanned(customer: { bannedUntil?: string | null }): boolean {
|
||||
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Next Auth проверка (`apps/web/config/auth.ts`)
|
||||
|
||||
В `authorize` callback добавлена проверка бана:
|
||||
|
||||
```typescript
|
||||
async authorize(credentials) {
|
||||
const { telegramId } = credentials ?? {};
|
||||
if (!telegramId) { throw new Error('Invalid Telegram ID'); }
|
||||
|
||||
try {
|
||||
const { query } = await getClientWithToken();
|
||||
const result = await query({
|
||||
query: GetCustomerDocument,
|
||||
variables: { telegramId: Number(telegramId) },
|
||||
});
|
||||
const customer = result.data.customers.at(0);
|
||||
|
||||
if (!customer || isCustomerBanned(customer)) {
|
||||
throw new Error('User is banned or not found');
|
||||
}
|
||||
return { id: telegramId };
|
||||
} catch (error) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Универсальная проверка в BaseService (`packages/graphql/api/base.ts`)
|
||||
|
||||
Добавлен метод `checkIsBanned()` в `BaseService`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Универсальная проверка статуса бана пользователя
|
||||
* Должна вызываться в начале каждого метода сервиса
|
||||
*/
|
||||
protected async checkIsBanned() {
|
||||
const { query } = await getClientWithToken();
|
||||
const result = await query({
|
||||
query: GQL.GetCustomerDocument,
|
||||
variables: this._user,
|
||||
});
|
||||
const customer = result.data.customers.at(0);
|
||||
|
||||
if (!customer) {
|
||||
throw new Error(BASE_ERRORS.NOT_FOUND_CUSTOMER);
|
||||
}
|
||||
|
||||
if (isCustomerBanned(customer)) {
|
||||
throw new Error(ERRORS.NO_PERMISSION);
|
||||
}
|
||||
|
||||
return { customer };
|
||||
}
|
||||
```
|
||||
|
||||
**Использование в сервисах:**
|
||||
```typescript
|
||||
async someMethod() {
|
||||
await this.checkIsBanned(); // Проверка бана в начале метода
|
||||
// ... остальная логика
|
||||
}
|
||||
```
|
||||
|
||||
**Обновленные сервисы:**
|
||||
- ✅ `CustomersService` - все методы
|
||||
- ✅ `ServicesService` - все методы
|
||||
- ✅ `OrdersService` - все методы
|
||||
- ✅ `SlotsService` - все методы
|
||||
- ✅ `RegistrationService` - добавлена собственная проверка
|
||||
|
||||
**Преимущества:**
|
||||
- Автоматическая проверка во всех сервисах, наследующих от BaseService
|
||||
- Единая точка проверки бана
|
||||
- Работает как в веб-приложении, так и в боте
|
||||
- Простота добавления в новые методы
|
||||
- Защита всех API методов от забаненных пользователей
|
||||
|
||||
## 5. Защита от изменения статуса бана (`packages/graphql/api/customers.ts` и `registration.ts`)
|
||||
|
||||
Пользователи не могут изменять поле `bannedUntil` самостоятельно:
|
||||
|
||||
```typescript
|
||||
// В CustomersService
|
||||
async updateCustomer(variables: Omit<VariablesOf<typeof GQL.UpdateCustomerDocument>, 'documentId'>) {
|
||||
await this.checkBanStatus();
|
||||
|
||||
const { customer } = await this._getUser();
|
||||
|
||||
// Проверяем, что пользователь не пытается изменить поле bannedUntil
|
||||
if (variables.data.bannedUntil !== undefined) {
|
||||
throw new Error(ERRORS.NO_PERMISSION);
|
||||
}
|
||||
|
||||
// ... остальная логика обновления
|
||||
}
|
||||
|
||||
// В RegistrationService
|
||||
async updateCustomer(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
|
||||
// Проверяем бан для существующего пользователя
|
||||
if (variables.documentId) {
|
||||
const { query } = await getClientWithToken();
|
||||
const result = await query({
|
||||
query: GQL.GetCustomerDocument,
|
||||
variables: { documentId: variables.documentId },
|
||||
});
|
||||
const customer = result.data.customers.at(0);
|
||||
if (customer && isCustomerBanned(customer)) {
|
||||
throw new Error(ERRORS.NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
if (variables.data.bannedUntil) {
|
||||
throw new Error(ERRORS.NO_PERMISSION);
|
||||
}
|
||||
|
||||
// ... остальная логика обновления
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- Пользователи не могут снять с себя бан
|
||||
- Только администраторы могут изменять статус блокировки
|
||||
- Дополнительный уровень безопасности
|
||||
- Защита работает во всех сервисах, которые обновляют данные пользователей
|
||||
- Единая ошибка `NO_PERMISSION` для всех случаев отсутствия доступа
|
||||
|
||||
## 6. Client-side Check (`components/auth/ban-check.tsx`)
|
||||
|
||||
React компонент для проверки бана на клиенте:
|
||||
|
||||
```typescript
|
||||
export function BanCheck({ children }: Readonly<PropsWithChildren>) {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const isBanned = useIsBanned();
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.telegramId && isBanned) {
|
||||
router.push('/banned');
|
||||
}
|
||||
}, [session?.user?.telegramId, isBanned, router]);
|
||||
|
||||
if (session?.user?.telegramId && isBanned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
**Использование в layout:**
|
||||
```typescript
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return (
|
||||
<Provider>
|
||||
<BanCheck>
|
||||
<UpdateProfile />
|
||||
<main className="grow">{children}</main>
|
||||
<BottomNav />
|
||||
</BanCheck>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Hook для проверки бана (`hooks/api/customers.ts`)
|
||||
|
||||
```typescript
|
||||
export const useIsBanned = () => {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
if (!customer) return false;
|
||||
return isCustomerBanned(customer);
|
||||
};
|
||||
```
|
||||
|
||||
## 8. Страница для забаненных пользователей (`apps/web/app/(auth)/banned/page.tsx`)
|
||||
|
||||
Создана специальная страница с информацией о бане и возможностью выхода из аккаунта.
|
||||
|
||||
## 9. Централизованные ошибки (`packages/graphql/constants/errors.ts`)
|
||||
|
||||
```typescript
|
||||
export const ERRORS = {
|
||||
NO_PERMISSION: 'Нет доступа',
|
||||
} as const;
|
||||
```
|
||||
|
||||
## Архитектура системы
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Next Auth │ │ BaseService │ │ Client-side │
|
||||
│ (авторизация) │ │ (API методы) │ │ (UI) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ isCustomerBanned│ │ checkBanStatus()│ │ BanCheck │
|
||||
│ (утилита) │ │ (метод) │ │ (компонент) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┼───────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ bannedUntil (DB) │
|
||||
│ (Strapi/PostgreSQL) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## Преимущества системы
|
||||
|
||||
✅ **Многоуровневая защита** - проверка на всех уровнях приложения
|
||||
✅ **Универсальность** - работает в веб-приложении и боте
|
||||
✅ **Простота использования** - один вызов `checkBanStatus()` в начале метода
|
||||
✅ **Безопасность** - пользователи не могут обойти бан
|
||||
✅ **UX** - понятные сообщения и страница для забаненных
|
||||
✅ **DRY принцип** - нет дублирования кода
|
||||
✅ **Легкость расширения** - просто добавить новые проверки
|
||||
@ -11,7 +11,7 @@ ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN apk add --no-cache libc6-compat openssl && \
|
||||
corepack enable && \
|
||||
pnpm install turbo@2.3.2 dotenv-cli --global
|
||||
pnpm install turbo dotenv-cli --global
|
||||
|
||||
FROM base AS pruner
|
||||
ARG PROJECT
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import * as customers from './server/customers';
|
||||
import { wrapClientAction } from '@/utils/actions';
|
||||
|
||||
export const addInvitedBy = wrapClientAction(customers.addInvitedBy);
|
||||
export const getInvited = wrapClientAction(customers.getInvited);
|
||||
export const addMasters = wrapClientAction(customers.addMasters);
|
||||
export const getClients = wrapClientAction(customers.getClients);
|
||||
export const getCustomer = wrapClientAction(customers.getCustomer);
|
||||
export const getCustomers = wrapClientAction(customers.getCustomers);
|
||||
export const getInvitedBy = wrapClientAction(customers.getInvitedBy);
|
||||
export const getMasters = wrapClientAction(customers.getMasters);
|
||||
export const updateCustomer = wrapClientAction(customers.updateCustomer);
|
||||
|
||||
@ -6,10 +6,16 @@ import { CustomersService } from '@repo/graphql/api/customers';
|
||||
|
||||
const getService = useService(CustomersService);
|
||||
|
||||
export async function addInvitedBy(...variables: Parameters<CustomersService['addInvitedBy']>) {
|
||||
export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.addInvitedBy(...variables));
|
||||
return wrapServerAction(() => service.addMasters(...variables));
|
||||
}
|
||||
|
||||
export async function getClients(...variables: Parameters<CustomersService['getClients']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getClients(...variables));
|
||||
}
|
||||
|
||||
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
|
||||
@ -18,22 +24,10 @@ export async function getCustomer(...variables: Parameters<CustomersService['get
|
||||
return wrapServerAction(() => service.getCustomer(...variables));
|
||||
}
|
||||
|
||||
export async function getCustomers(...variables: Parameters<CustomersService['getCustomers']>) {
|
||||
export async function getMasters(...variables: Parameters<CustomersService['getMasters']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getCustomers(...variables));
|
||||
}
|
||||
|
||||
export async function getInvited(...variables: Parameters<CustomersService['getInvited']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getInvited(...variables));
|
||||
}
|
||||
|
||||
export async function getInvitedBy(...variables: Parameters<CustomersService['getInvitedBy']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getInvitedBy(...variables));
|
||||
return wrapServerAction(() => service.getMasters(...variables));
|
||||
}
|
||||
|
||||
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {
|
||||
|
||||
@ -6,12 +6,6 @@ import { ServicesService } from '@repo/graphql/api/services';
|
||||
|
||||
const getServicesService = useService(ServicesService);
|
||||
|
||||
export async function createService(...variables: Parameters<ServicesService['createService']>) {
|
||||
const service = await getServicesService();
|
||||
|
||||
return wrapServerAction(() => service.createService(...variables));
|
||||
}
|
||||
|
||||
export async function getService(...variables: Parameters<ServicesService['getService']>) {
|
||||
const service = await getServicesService();
|
||||
|
||||
@ -23,9 +17,3 @@ export async function getServices(...variables: Parameters<ServicesService['getS
|
||||
|
||||
return wrapServerAction(() => service.getServices(...variables));
|
||||
}
|
||||
|
||||
export async function updateService(...variables: Parameters<ServicesService['updateService']>) {
|
||||
const service = await getServicesService();
|
||||
|
||||
return wrapServerAction(() => service.updateService(...variables));
|
||||
}
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
'use server';
|
||||
import { useService } from '../lib/service';
|
||||
import { wrapServerAction } from '@/utils/actions';
|
||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||
|
||||
const getService = useService(SubscriptionsService);
|
||||
|
||||
export async function createSubscription(
|
||||
...variables: Parameters<SubscriptionsService['createSubscription']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.createSubscription(...variables));
|
||||
}
|
||||
|
||||
export async function createSubscriptionHistory(
|
||||
...variables: Parameters<SubscriptionsService['createSubscriptionHistory']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.createSubscriptionHistory(...variables));
|
||||
}
|
||||
|
||||
export async function createTrialSubscription() {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.createTrialSubscription());
|
||||
}
|
||||
|
||||
export async function getSubscription(
|
||||
...variables: Parameters<SubscriptionsService['getSubscription']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getSubscription(...variables));
|
||||
}
|
||||
|
||||
export async function getSubscriptionHistory(
|
||||
...variables: Parameters<SubscriptionsService['getSubscriptionHistory']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getSubscriptionHistory(...variables));
|
||||
}
|
||||
|
||||
export async function getSubscriptionPrices(
|
||||
...variables: Parameters<SubscriptionsService['getSubscriptionPrices']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getSubscriptionPrices(...variables));
|
||||
}
|
||||
|
||||
export async function getSubscriptions(
|
||||
...variables: Parameters<SubscriptionsService['getSubscriptions']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getSubscriptions(...variables));
|
||||
}
|
||||
|
||||
export async function getSubscriptionSettings(
|
||||
...variables: Parameters<SubscriptionsService['getSubscriptionSettings']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getSubscriptionSettings(...variables));
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
...variables: Parameters<SubscriptionsService['updateSubscription']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.updateSubscription(...variables));
|
||||
}
|
||||
|
||||
export async function updateSubscriptionHistory(
|
||||
...variables: Parameters<SubscriptionsService['updateSubscriptionHistory']>
|
||||
) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.updateSubscriptionHistory(...variables));
|
||||
}
|
||||
@ -3,5 +3,3 @@ import { wrapClientAction } from '@/utils/actions';
|
||||
|
||||
export const getServices = wrapClientAction(services.getServices);
|
||||
export const getService = wrapClientAction(services.getService);
|
||||
export const createService = wrapClientAction(services.createService);
|
||||
export const updateService = wrapClientAction(services.updateService);
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import * as subscriptions from './server/subscriptions';
|
||||
import { wrapClientAction } from '@/utils/actions';
|
||||
|
||||
export const getSubscription = wrapClientAction(subscriptions.getSubscription);
|
||||
export const getSubscriptions = wrapClientAction(subscriptions.getSubscriptions);
|
||||
export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings);
|
||||
export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices);
|
||||
export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory);
|
||||
export const createSubscription = wrapClientAction(subscriptions.createSubscription);
|
||||
export const updateSubscription = wrapClientAction(subscriptions.updateSubscription);
|
||||
export const createSubscriptionHistory = wrapClientAction(subscriptions.createSubscriptionHistory);
|
||||
export const updateSubscriptionHistory = wrapClientAction(subscriptions.updateSubscriptionHistory);
|
||||
export const createTrialSubscription = wrapClientAction(subscriptions.createTrialSubscription);
|
||||
@ -2,15 +2,12 @@
|
||||
|
||||
import { authOptions } from '@/config/auth';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function getSessionUser() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user;
|
||||
|
||||
if (!user?.telegramId) {
|
||||
return redirect('/');
|
||||
}
|
||||
if (!user?.telegramId) throw new Error('Missing session');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Container } from '@/components/layout';
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@repo/ui/components/ui/card';
|
||||
import { AlertTriangle, Ban } from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOut({ callbackUrl: '/' });
|
||||
};
|
||||
|
||||
export default function BannedPage() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<Ban className="size-8 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Аккаунт заблокирован</CardTitle>
|
||||
<CardDescription>
|
||||
Ваш аккаунт был заблокирован администратором. Для получения дополнительной информации
|
||||
обратитесь в поддержку.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 size-5 text-yellow-500" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-1 font-medium text-foreground">Возможные причины блокировки:</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>Нарушение правил использования сервиса</li>
|
||||
<li>Спам или нежелательная активность</li>
|
||||
<li>Множественные жалобы от других пользователей</li>
|
||||
<li>Технические проблемы с аккаунтом</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={handleSignOut} variant="outline">
|
||||
Выйти из аккаунта
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@ -22,18 +22,7 @@ export default function Auth() {
|
||||
signIn('telegram', {
|
||||
callbackUrl: '/profile',
|
||||
redirect: false,
|
||||
telegramId: user?.id?.toString(),
|
||||
}).then((result) => {
|
||||
if (
|
||||
result?.error &&
|
||||
(result?.error?.includes('CredentialsSignin') ||
|
||||
result?.error?.includes('UNREGISTERED'))
|
||||
) {
|
||||
// Пользователь не зарегистрирован
|
||||
redirect('/unregistered');
|
||||
} else if (result?.ok) {
|
||||
redirect('/profile');
|
||||
}
|
||||
telegramId: user?.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,6 +10,4 @@ export default function Page() {
|
||||
|
||||
redirect(isTG ? '/telegram' : '/browser');
|
||||
});
|
||||
|
||||
return 'Redirecting...';
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { TelegramProvider } from '@/providers/telegram';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
export default async function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return <TelegramProvider>{children}</TelegramProvider>;
|
||||
}
|
||||
|
||||
@ -1,79 +1,40 @@
|
||||
/* eslint-disable promise/prefer-await-to-then */
|
||||
'use client';
|
||||
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react';
|
||||
import { signIn, type SignInResponse, useSession } from 'next-auth/react';
|
||||
import { signIn, useSession } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Auth() {
|
||||
useTelegramTheme();
|
||||
useAuth();
|
||||
|
||||
useTelegramAuth();
|
||||
|
||||
return <LoadingSpinner />;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для авторизации пользователя через NextAuth
|
||||
*/
|
||||
function useTelegramAuth() {
|
||||
function useAuth() {
|
||||
const initDataUser = useSignal(initData.user);
|
||||
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignInResult = useCallback(
|
||||
(result: SignInResponse | undefined) => {
|
||||
if (!result) return;
|
||||
|
||||
if (
|
||||
result.error &&
|
||||
(result.error.includes('CredentialsSignin') || result.error.includes('UNREGISTERED'))
|
||||
) {
|
||||
router.replace('/unregistered');
|
||||
} else if (result.ok) {
|
||||
router.replace('/profile');
|
||||
}
|
||||
},
|
||||
[router],
|
||||
);
|
||||
const { status } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
const telegramId = initDataUser?.id;
|
||||
if (!telegramId) return;
|
||||
if (!initDataUser?.id) return;
|
||||
|
||||
if (status === 'authenticated') {
|
||||
// Если telegramId есть в сессии — редирект
|
||||
if (session?.user?.telegramId) {
|
||||
router.replace('/profile');
|
||||
} else {
|
||||
// Если telegramId отсутствует — пробуем заново signIn
|
||||
void signIn('telegram', {
|
||||
callbackUrl: '/profile',
|
||||
redirect: false,
|
||||
telegramId: telegramId.toString(),
|
||||
}).then(handleSignInResult);
|
||||
}
|
||||
|
||||
return;
|
||||
redirect('/profile');
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
void signIn('telegram', {
|
||||
signIn('telegram', {
|
||||
callbackUrl: '/profile',
|
||||
redirect: false,
|
||||
telegramId: telegramId.toString(),
|
||||
}).then(handleSignInResult);
|
||||
telegramId: initDataUser.id,
|
||||
}).then(() => redirect('/profile'));
|
||||
}
|
||||
}, [initDataUser?.id, status, session?.user?.telegramId, router, handleSignInResult]);
|
||||
}, [initDataUser?.id, status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для установки темы из Telegram Mini App
|
||||
*/
|
||||
function useTelegramTheme() {
|
||||
const isDark = isMiniAppDark();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
import { UnregisteredClient } from './unregistered-client';
|
||||
import { Container } from '@/components/layout';
|
||||
import { env } from '@/config/env';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@repo/ui/components/ui/card';
|
||||
import { Bot, MessageCircle } from 'lucide-react';
|
||||
|
||||
export default function UnregisteredPage() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20">
|
||||
<Bot className="size-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Давайте познакомимся</CardTitle>
|
||||
<CardDescription>
|
||||
Для использования приложения необходимо поделиться своим номером телефона с ботом
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<MessageCircle className="mt-0.5 size-5 text-blue-500" />
|
||||
<div className="text-sm">
|
||||
<p className="mb-1 font-medium text-foreground">Как поделиться:</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Вернитесь к Telegram боту</li>
|
||||
<li>
|
||||
Отправьте команду{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">/start</code>
|
||||
</li>
|
||||
<li>Нажмите на появившуюся кнопку "Отправить номер телефона"</li>
|
||||
<li>Закройте и откройте это приложение еще раз</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UnregisteredClient botUrl={env.BOT_URL} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import { Bot, ExternalLink } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
type UnregisteredClientProps = {
|
||||
readonly botUrl: string;
|
||||
};
|
||||
|
||||
export function UnregisteredClient({ botUrl }: UnregisteredClientProps) {
|
||||
const handleSignOut = () => {
|
||||
signOut({ callbackUrl: '/' });
|
||||
};
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={botUrl} rel="noopener noreferrer" target="_blank">
|
||||
<Bot className="mr-2 size-4" />
|
||||
Перейти к боту
|
||||
<ExternalLink className="ml-2 size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="w-full" onClick={handleRefresh} variant="outline">
|
||||
Обновить страницу
|
||||
</Button>
|
||||
<Button className="w-full" onClick={handleSignOut} variant="outline">
|
||||
Выйти из аккаунта
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { DocumentsLayout } from '@/components/documents/layout';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return <DocumentsLayout title="Публичная оферта">{children}</DocumentsLayout>;
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import { env } from '@/config/env';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Публичная оферта',
|
||||
description: 'Публичная оферта бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
|
||||
};
|
||||
|
||||
### Договор-оферта на использование сервиса «Запишись.онлайн» (@zapishis_online_bot)
|
||||
|
||||
Настоящий документ является публичной офертой в соответствии с пунктом 2 статьи 437 Гражданского кодекса Российской Федерации и представляет собой предложение индивидуального предпринимателя (самозанятого) — далее именуемого «Администрация», заключить Договор на использование Сервиса (далее – «Договор», «Оферта») с любым физическим лицом, принявшим условия настоящей Оферты (далее – «Пользователь»).
|
||||
|
||||
#### 1. Термины и определения
|
||||
|
||||
1.1. Оферта — настоящий документ, постоянно размещенный в сети Интернет по адресу <a href={env.URL_OFFER}>{env.URL_OFFER}</a>.
|
||||
|
||||
1.2. Акцепт — полное и безоговорочное принятие условий Оферты Пользователем путем оплаты доступа через встроенный платежный бот ЮKassa в Telegram.
|
||||
|
||||
1.3. Сервис — Telegram-бот и мини-приложение, позволяющее пользователям создавать и принимать заказы, управлять расписанием и взаимодействовать друг с другом без необходимости регистрации.
|
||||
|
||||
1.4. Администрация — самозанятое лицо, являющееся разработчиком и правообладателем Сервиса.
|
||||
|
||||
1.5. Пользователь — любое физическое лицо, использующее Сервис в личных или профессиональных целях.
|
||||
|
||||
1.6. Доступ — право использования функционала Сервиса на определённый оплаченный период (например, неделя, месяц, год).
|
||||
|
||||
1.7. Оплата — денежные средства, перечисленные Пользователем через платёжный бот ЮKassa в Telegram.
|
||||
|
||||
#### 2. Акцепт оферты и заключение договора
|
||||
|
||||
2.1. Акцептом настоящей Оферты считается оплата Пользователем доступа к Сервису любым доступным способом.
|
||||
|
||||
2.2. С момента совершения оплаты Пользователь считается заключившим Договор с Администрацией на условиях, изложенных в настоящей Оферте.
|
||||
|
||||
2.3. Пользователь подтверждает, что ему понятны все условия настоящей Оферты и он принимает их без ограничений.
|
||||
|
||||
#### 3. Предмет договора
|
||||
|
||||
3.1. Администрация предоставляет Пользователю неисключительное право (доступ) на использование функционала Сервиса в пределах оплаченного периода времени.
|
||||
|
||||
3.2. Сервис предоставляется в онлайн-формате через Telegram-бота без установки дополнительного программного обеспечения.
|
||||
|
||||
3.3. Пользователь получает право использовать функционал Сервиса в личных целях, в том числе для организации и планирования заказов, встреч и тренировок.
|
||||
|
||||
#### 4. Порядок оплаты и использование
|
||||
|
||||
4.1. Оплата производится через встроенные инструменты Telegram-бота с использованием платёжной системы ЮKassa.
|
||||
|
||||
4.2. Комиссия платёжной системы включена в итоговую стоимость. Администрация не взимает дополнительных платежей.
|
||||
|
||||
4.3. Доступ активируется автоматически после успешного подтверждения оплаты.
|
||||
|
||||
4.4. Пользователь может продлить доступ путём повторной оплаты. Автоматическое продление не применяется.
|
||||
|
||||
4.5. Возврат денежных средств возможен только в случае технических ошибок, по письменному обращению на адрес поддержки.
|
||||
|
||||
#### 5. Права и обязанности сторон
|
||||
|
||||
5.1. Пользователь обязуется:
|
||||
|
||||
- не использовать Сервис в противоправных целях;
|
||||
- не вмешиваться в работу Сервиса и не предпринимать действий, направленных на нарушение его функционирования;
|
||||
- предоставлять достоверную информацию при оплате и использовании Сервиса;
|
||||
- при добавлении контактов других лиц (например, клиентов, мастеров) гарантировать, что у него есть согласие этих лиц на передачу и обработку их персональных данных в рамках Сервиса;
|
||||
|
||||
5.2. Администрация обязуется:
|
||||
|
||||
- обеспечивать бесперебойную работу Сервиса, за исключением периодов технического обслуживания;
|
||||
- обрабатывать персональные данные Пользователей в соответствии с Политикой конфиденциальности;
|
||||
- принимать обращения и запросы Пользователей по вопросам работы Сервиса. 6. Ответственность сторон;
|
||||
|
||||
6.1. Сервис предоставляется «как есть». Администрация не несёт ответственности за временные сбои, потерю данных или недоступность Сервиса, возникшие по причинам, не зависящим от неё.
|
||||
|
||||
6.2. Пользователь несёт полную ответственность за корректность совершаемых платежей и действий, совершаемых через свой Telegram-аккаунт.
|
||||
|
||||
#### 7. Обработка персональных данных
|
||||
|
||||
7.1. Администрация обрабатывает персональные данные Пользователя в соответствии с Федеральным законом №152-ФЗ «О персональных данных» и Политикой конфиденциальности.
|
||||
|
||||
7.2. Использование Сервиса означает согласие Пользователя на обработку его персональных данных.
|
||||
|
||||
#### 8. Срок действия и расторжение договора
|
||||
|
||||
8.1. Договор вступает в силу с момента оплаты доступа и действует в течение оплаченного периода.
|
||||
|
||||
8.2. Пользователь может прекратить использование Сервиса в любое время без возврата оплаченных средств.
|
||||
|
||||
8.3. Администрация вправе приостановить доступ в случае нарушения Пользователем условий настоящей Оферты.
|
||||
|
||||
#### 9. Заключительные положения
|
||||
|
||||
9.1. Настоящий Договор регулируется законодательством Российской Федерации.
|
||||
|
||||
9.2. Все споры и разногласия решаются путём переговоров, а при недостижении соглашения — в судебном порядке по месту нахождения Администрации.
|
||||
|
||||
9.3. Администрация оставляет за собой право изменять условия Оферты с размещением новой редакции на сайте.
|
||||
|
||||
#### 10. Контакты
|
||||
|
||||
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.
|
||||
@ -1,6 +0,0 @@
|
||||
import { DocumentsLayout } from '@/components/documents/layout';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return <DocumentsLayout title="Политика конфиденциальности">{children}</DocumentsLayout>;
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
export const metadata = {
|
||||
title: 'Политика конфиденциальности',
|
||||
description:
|
||||
'Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
|
||||
};
|
||||
|
||||
### Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)
|
||||
|
||||
#### 1. Термины и определения
|
||||
|
||||
- **Telegram** – Telegram Messenger Inc. (платформа, на которой работает бот и мини-приложение).
|
||||
- **Платформа** – экосистема ботов и мини-приложений Telegram.
|
||||
- **Разработчик** – физическое лицо, самозанятый, владелец и оператор сервиса «Запишись.онлайн» (@zapishis_online_bot) - (далее — «Разработчик»).
|
||||
- **Сторонний сервис** – бот/мини-приложение Разработчика, предоставляемое в Платформе.
|
||||
- **Пользователь** – лицо, использующее Сторонний сервис через свою учетную запись Telegram (далее — «Вы»).
|
||||
- **Политика** – настоящий документ, регулирующий отношения между Разработчиком и Пользователем в части сбора и - обработки персональных данных.
|
||||
|
||||
#### 2. Общие положения
|
||||
|
||||
2.1. Настоящая Политика регулирует исключительно отношения между Разработчиком и Пользователем. Она не заменяет и не изменяет Политику конфиденциальности Telegram: [https://telegram.org/privacy](https://telegram.org/privacy).
|
||||
|
||||
2.2. Разработчик соблюдает применимые требования платформы Telegram к конфиденциальности и защите данных.
|
||||
|
||||
2.3. Использование Сервиса Пользователем и/или активация платного доступа означает согласие Пользователя с условиями настоящей Политики.
|
||||
|
||||
2.4. Если Вы не согласны с условиями Политики — прекратите использование Сервиса.
|
||||
|
||||
#### 3. Отказ от ответственности
|
||||
|
||||
3.1. Сторонний сервис является независимым приложением и не поддерживается, не одобряется и не аффилирован с Telegram (за исключением использования API и инфраструктуры Telegram).
|
||||
|
||||
3.2. Разработчик вправе изменять настоящую Политику — изменения вступают в силу с момента их публикации. Вы обязаны самостоятельно отслеживать обновления.
|
||||
|
||||
3.3. Используя Сервис, Вы подтверждаете, что ознакомлены и согласны с условиями использования Telegram для ботов и мини-приложений: [https://telegram.org/tos/bots](https://telegram.org/tos/bots), [https://telegram.org/tos/mini-apps](https://telegram.org/tos/mini-apps).
|
||||
|
||||
3.4. Вы гарантируете, что используете Сервис в соответствии с действующим законодательством и обладаете правом взаимодействовать с ним (например, достигли возраста, необходимого для использования услуг).
|
||||
|
||||
3.5. Вы обязуетесь предоставлять точную и актуальную информацию, если Сервис запрашивает её.
|
||||
|
||||
3.6. Любая информация, которую Вы делаете общедоступной самостоятельно (через профиль Telegram, публичные сообщения и т.п.), может стать доступна другим пользователям и не подпадает под защиту настоящей Политики в части конфиденциальности этой общедоступной информации.
|
||||
|
||||
#### 4. Сбор персональных данных
|
||||
|
||||
4.1. Telegram по умолчанию предоставляет сторонним сервисам ограниченный набор данных о Пользователе — подробнее: [https://telegram.org/privacy#6-bot-messages](https://telegram.org/privacy#6-bot-messages).
|
||||
|
||||
4.2. Сторонний сервис может дополнительно получать данные, которые Вы передаёте в чате бота или в мини-приложении (например, контакт, телефон), если Вы явно их отправляете.
|
||||
|
||||
4.3. В случае мини-приложения дополнительно могут передаваться данные в соответствии с правилами мини-приложений Telegram: [https://telegram.org/tos/mini-apps#4-privacy](https://telegram.org/tos/mini-apps#4-privacy).
|
||||
|
||||
4.4. Сторонний сервис может собирать также анонимную статистику использования (диагностика, события взаимодействия), не связываемую напрямую с персоной.
|
||||
|
||||
4.5. Пользователь может передавать данные третьих лиц (например, контактные данные клиентов или мастеров) для использования в Сервисе. При этом пользователь гарантирует, что эти лица дали согласие на обработку их персональных данных в рамках Сервиса.
|
||||
|
||||
#### 5. Какие данные мы собираем и как используем
|
||||
|
||||
5.1. Разработчик запрашивает, собирает и обрабатывает только те данные, которые необходимы для корректной работы функций Сервиса, в частности:
|
||||
|
||||
- Telegram ID и (опционально) отображаемое имя пользователя;
|
||||
- телефон, только если Вы предоставили его добровольно (например, при регистрации);
|
||||
- данные о заказах: дата/время, описание заказа, статус;
|
||||
- информация о факте покупки Pro-доступа: период доступа, тип покупки (детали платёжной транзакции обрабатывает платёжный оператор — ЮKassa);
|
||||
|
||||
5.2. Цели обработки:
|
||||
|
||||
- предоставление и поддержка работы Сервиса (создание заказов, напоминания, управление доступом);
|
||||
- подтверждение и учет оплат (взаимодействие с платёжным оператором для актуализации статуса доступа);
|
||||
- реализация реферальной программы (хранение связей «кто пригласил/кого пригласили»);
|
||||
- анализ использования и улучшение сервиса;
|
||||
- выполнение юридических обязательств (хранение информации о транзакциях и др.);
|
||||
|
||||
> **Важно:** детальные платёжные данные (реквизиты карт и т.д.) не хранятся у Разработчика — их обрабатывает платёжный оператор (ЮKassa) и Telegram-платежный бот.
|
||||
|
||||
#### 6. Передача данных третьим лицам
|
||||
|
||||
6.1. Разработчик не передаёт персональные данные третьим лицам, за исключением следующих случаев:
|
||||
|
||||
- платёжному оператору (ЮKassa) и связанным службам для обработки платежей;
|
||||
- Telegram как платформе для функционирования бота и мини-приложения;
|
||||
- в случае необходимости — исполнителям, оказывающим техническую поддержку, при условии подписания ими обязательств о конфиденциальности;
|
||||
- если передача требуется по закону (запросы уполномоченных органов и т.п.);
|
||||
|
||||
6.2. Разработчик не продаёт и не передаёт персональные данные для рекламных целей третьим лицам без Вашего отдельного согласия.
|
||||
|
||||
#### 7. Защита и хранение данных
|
||||
|
||||
7.1. Разработчик применяет разумные технические и организационные меры для защиты персональных данных (использование надежного VPS, ограничения доступа, резервное копирование и т.п.).
|
||||
|
||||
7.2. Доступ к персональным данным имеет только Разработчик (и/или доверенные исполнители технической поддержки при необходимости).
|
||||
|
||||
7.3. Данные хранятся на серверах, указанных Разработчиком. Если используются внешние сервисы/облачные хранилища — это будет указано в соответствующих местах Политики или сообщения при сборе данных.
|
||||
|
||||
#### 8. Права и обязанности сторон
|
||||
|
||||
8.1. Права Разработчика:
|
||||
|
||||
- вносить изменения в Политику с публикацией новой версии;
|
||||
- ограничивать доступ к API/сервису при подозрении в злоупотреблениях;
|
||||
- запросить подтверждение личности при необходимости обработки привилегированных запросов;
|
||||
|
||||
8.2. Обязанности Разработчика:
|
||||
|
||||
- обеспечивать доступность Политики и исполнять её условия;
|
||||
- обрабатывать законные запросы пользователей о доступе, изменении или удалении данных в разумные сроки (не позднее 30 дней, если иное не установлено законом);
|
||||
- соблюдать применимое законодательство о защите персональных данных;
|
||||
|
||||
8.3. Права Пользователя:
|
||||
|
||||
- запросить копию своих персональных данных, хранящихся у Разработчика;
|
||||
- потребовать исправления неточных данных;
|
||||
- потребовать удаления персональных данных в пределах, допустимых законом (с сохранением данных, необходимых для выполнения юридических обязательств, например, по учёту платежей);
|
||||
- отозвать согласие на обработку персональных данных, если такое согласие предоставлялось добровольно;
|
||||
- подать жалобу в уполномоченные органы по защите персональных данных, если считает, что его права нарушены;
|
||||
|
||||
8.4. Обязанности Пользователя:
|
||||
|
||||
- предоставлять точную и актуальную информацию;
|
||||
- не использовать Сервис в нарушении законодательства и условий Telegram.
|
||||
|
||||
#### 9. Реклама и использование данных для аналитики
|
||||
|
||||
9.1. На текущем этапе Разработчик не использует персональные данные для демонстрации таргетированной рекламы третьих лиц без явного согласия Пользователя.
|
||||
|
||||
9.2. Разработчик может собирать агрегированную (анонимную) статистику использования Сервиса для улучшения функционала.
|
||||
|
||||
#### 10. Изменения Политики
|
||||
|
||||
10.1. Разработчик вправе вносить изменения в настоящую Политику. Все изменения публикуются на этой странице и вступают в силу с момента публикации.
|
||||
|
||||
#### 11. Контакты
|
||||
|
||||
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.
|
||||
@ -1,4 +1,4 @@
|
||||
import { ContactsList } from '@/components/contacts';
|
||||
import { ContactsFilter, ContactsList } from '@/components/contacts';
|
||||
import { ContactsContextProvider } from '@/context/contacts';
|
||||
import { Card } from '@repo/ui/components/ui/card';
|
||||
|
||||
@ -8,7 +8,7 @@ export default function ContactsPage() {
|
||||
<Card>
|
||||
<div className="flex flex-row items-center justify-between space-x-4 p-4">
|
||||
<h1 className="font-bold">Контакты</h1>
|
||||
{/* <ContactsFilter /> */}
|
||||
<ContactsFilter />
|
||||
</div>
|
||||
<div className="p-4 pt-0">
|
||||
<ContactsList />
|
||||
|
||||
@ -1,24 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { CheckBanned, UpdateProfile } from '@/components/auth';
|
||||
import { UpdateProfile } from '@/components/auth';
|
||||
import { BottomNav } from '@/components/navigation';
|
||||
import { EmptyProvider } from '@/providers/empty';
|
||||
import { TelegramProvider } from '@/providers/telegram';
|
||||
import { isTMA } from '@telegram-apps/sdk-react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
const isTG = isTMA('simple');
|
||||
|
||||
const Provider = isTG ? TelegramProvider : EmptyProvider;
|
||||
|
||||
export default async function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return (
|
||||
<CheckBanned>
|
||||
<Provider>
|
||||
<UpdateProfile />
|
||||
<main className="grow">{children}</main>
|
||||
<BottomNav />
|
||||
</Provider>
|
||||
</CheckBanned>
|
||||
<>
|
||||
<UpdateProfile />
|
||||
<main className="grow">{children}</main>
|
||||
<BottomNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { getOrder } from '@/actions/api/orders';
|
||||
import { Container } from '@/components/layout';
|
||||
import { PageHeader } from '@/components/navigation';
|
||||
import {
|
||||
@ -8,14 +9,23 @@ import {
|
||||
OrderStatus,
|
||||
} from '@/components/orders';
|
||||
import { type OrderPageParameters } from '@/components/orders/types';
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Props = { params: Promise<OrderPageParameters> };
|
||||
|
||||
export default async function ProfilePage(props: Readonly<Props>) {
|
||||
const parameters = await props.params;
|
||||
const documentId = parameters.documentId;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryFn: () => getOrder({ documentId }),
|
||||
queryKey: ['order', documentId],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PageHeader title="Запись" />
|
||||
<Container>
|
||||
<OrderDateTime {...parameters} />
|
||||
@ -25,6 +35,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
|
||||
<div className="pb-24" />
|
||||
<OrderButtons {...parameters} />
|
||||
</Container>
|
||||
</>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Container } from '@/components/layout';
|
||||
import { PageHeader } from '@/components/navigation';
|
||||
import { OrderForm } from '@/components/orders';
|
||||
|
||||
export default function AddOrdersPage() {
|
||||
export default async function AddOrdersPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Новая запись" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { Container } from '@/components/layout';
|
||||
import { OrdersList } from '@/components/orders';
|
||||
import { ClientsOrdersList, OrdersList } from '@/components/orders';
|
||||
import { DateSelect } from '@/components/orders/orders-list/date-select';
|
||||
import { DateTimeStoreProvider } from '@/stores/datetime';
|
||||
|
||||
@ -10,6 +10,7 @@ export default function ProfilePage() {
|
||||
<DateTimeStoreProvider>
|
||||
<div />
|
||||
<DateSelect />
|
||||
<ClientsOrdersList />
|
||||
<OrdersList />
|
||||
</DateTimeStoreProvider>
|
||||
</Container>
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
import {
|
||||
getSubscription,
|
||||
getSubscriptionPrices,
|
||||
getSubscriptions,
|
||||
} from '@/actions/api/subscriptions';
|
||||
import { getSessionUser } from '@/actions/session';
|
||||
import { TryFreeButton } from '@/components/subscription';
|
||||
import { env } from '@/config/env';
|
||||
import { Enum_Subscriptionprice_Period as SubscriptionPricePeriod } from '@repo/graphql/types';
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import { formatMoney } from '@repo/utils/money';
|
||||
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function ProPage() {
|
||||
const { telegramId } = await getSessionUser();
|
||||
|
||||
const { subscriptions } = await getSubscriptions({
|
||||
filters: { customer: { telegramId: { eq: telegramId } } },
|
||||
});
|
||||
|
||||
const hasActiveSubscription = subscriptions?.length
|
||||
? ((await getSubscription({ telegramId }))?.hasActiveSubscription ?? false)
|
||||
: false;
|
||||
|
||||
const canUseTrial = !subscriptions?.length;
|
||||
|
||||
const { subscriptionPrices = [] } = await getSubscriptionPrices({
|
||||
filters: {
|
||||
active: { eq: true },
|
||||
period: { ne: SubscriptionPricePeriod.Trial },
|
||||
},
|
||||
});
|
||||
|
||||
const botUrl = new URL(env.BOT_URL);
|
||||
botUrl.searchParams.set('start', 'pro');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||
{/* Hero Section */}
|
||||
<div className="px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<div className="mb-2 flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-purple-600 to-blue-600 opacity-30 blur-xl dark:from-purple-700 dark:to-blue-700" />
|
||||
<div className="relative rounded-full bg-gradient-to-r from-purple-600 to-blue-600 p-4 dark:from-purple-700 dark:to-blue-700">
|
||||
<Crown className="size-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
|
||||
<span className="bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent dark:from-purple-700 dark:to-blue-700">
|
||||
Pro
|
||||
</span>{' '}
|
||||
Доступ
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mb-6 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
||||
{hasActiveSubscription
|
||||
? 'Ваш Pro доступ активен!'
|
||||
: 'Разблокируйте больше возможностей'}
|
||||
</p>
|
||||
|
||||
{!hasActiveSubscription && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
{canUseTrial && <TryFreeButton />}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className={`w-full border-2 text-base font-semibold sm:w-auto ${
|
||||
canUseTrial
|
||||
? 'border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
: 'border-0 bg-gradient-to-r from-purple-600 to-blue-600 text-white hover:from-purple-700 hover:to-blue-700 dark:from-purple-700 dark:to-blue-700 dark:hover:from-purple-800 dark:hover:to-blue-800'
|
||||
}`}
|
||||
size="lg"
|
||||
variant={canUseTrial ? 'outline' : 'default'}
|
||||
>
|
||||
<Link href={botUrl.toString()} rel="noopener noreferrer" target="_blank">
|
||||
Приобрести Pro доступ через бота
|
||||
<ArrowRight className="ml-2 size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Преимущества
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
|
||||
<div className="mt-1 shrink-0">
|
||||
<InfinityIcon className="size-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
Доступно неограниченное количество записей в месяц
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
|
||||
<div className="mt-1 shrink-0">
|
||||
<Star className="size-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
Профиль и аватар выделяются цветом
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subscriptionPrices?.length > 0 && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Цены
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{subscriptionPrices.map((price) => (
|
||||
<div
|
||||
className={`relative rounded-xl border bg-white/60 p-4 text-left dark:bg-slate-800/60 ${
|
||||
price?.period === SubscriptionPricePeriod.Month
|
||||
? 'border-2 border-purple-400'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
key={price?.documentId}
|
||||
>
|
||||
{price?.period === SubscriptionPricePeriod.Month && (
|
||||
<div className="absolute -top-2 right-3 rounded-full bg-purple-600 px-2 py-0.5 text-xs font-semibold text-white dark:bg-purple-500">
|
||||
Популярный
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{formatMoney(price?.amount ?? 0)}
|
||||
</div>
|
||||
{typeof price?.days === 'number' && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{price.days} дн.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{price?.description && (
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{price.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user