Compare commits

..

14 Commits

Author SHA1 Message Date
vchikalkin
719193f4b3 Revert "packages(apps/web): upgrade next@15.5.0"
This reverts commit 22e4f72ee6e36834672e44a25acf8a5797dcca8c.
2025-08-26 20:04:21 +03:00
vchikalkin
22e4f72ee6 packages(apps/web): upgrade next@15.5.0 2025-08-26 12:47:43 +03:00
vchikalkin
7c1f79db2a feat(ban-system): implement multi-level user ban checks across services
- Added a comprehensive ban checking system to prevent access for banned users at multiple levels, including database, API, and client-side.
- Introduced `bannedUntil` field in the customer model to manage temporary and permanent bans effectively.
- Enhanced `BaseService` and various service classes to include ban checks, ensuring that banned users cannot perform actions or access data.
- Updated error handling to provide consistent feedback for banned users across the application.
- Improved user experience with a dedicated ban check component and a user-friendly ban notification page.
2025-08-26 12:32:22 +03:00
vchikalkin
5018560f29 feat(customer): implement banned customer check and enhance customer data handling
- Added `isCustomerBanned` function to determine if a customer is banned based on the `bannedUntil` field.
- Updated the `BaseService` to throw an error if a banned customer attempts to access certain functionalities.
- Enhanced the GraphQL operations to include the `bannedUntil` field in customer queries and mutations, improving data integrity and user experience.
- Integrated the `CheckBanned` component in the layout to manage banned customer states effectively.
2025-08-25 19:15:00 +03:00
vchikalkin
bad65204c4 refactor(bot): streamline bot middleware and improve key generator function
- Removed unused session middleware and sequentialize function from the bot's error boundary.
- Simplified the key generator function for rate limiting by condensing its implementation.
- Enhanced overall code clarity and maintainability in the bot's configuration.
2025-08-25 11:31:53 +03:00
vchikalkin
f9e50972cd fix(order-form): hide next button on success & error pages 2025-08-23 15:51:56 +03:00
vchikalkin
ad589e471c refactor(orders): remove ClientsOrdersList and streamline OrdersList component
- Eliminated the ClientsOrdersList component to simplify the orders page structure.
- Updated OrdersList to handle both client and master views, enhancing code reusability.
- Improved order fetching logic and UI rendering for better performance and user experience.
2025-08-23 14:40:09 +03:00
vchikalkin
73dd674815 fix(orders): update default sorting order for orders
- Changed the default sorting order for orders from 'datetime_start:asc' to 'datetime_start:desc' to ensure the most recent orders are displayed first, improving the user experience in order management.
2025-08-22 13:17:32 +03:00
vchikalkin
2067beb05a feat(help): enhance help command with support information
- Updated the help command to include a support message in the Russian localization, providing users with a contact point for inquiries.
- Improved the command response by combining the list of available commands with the new support information, enhancing user experience.
2025-08-22 12:49:40 +03:00
vchikalkin
d985b18f25 feat(locales): update Russian localization with additional contact information
- Enhanced the short description in the Russian localization file to include a contact note for user inquiries, improving user support accessibility.
2025-08-22 12:39:19 +03:00
vchikalkin
412c033972 feat(bot): add unhandled command message and integrate unhandled feature
- Introduced a new message for unhandled commands in Russian localization to improve user feedback.
- Integrated the unhandled feature into the bot's middleware for better command handling.
2025-08-21 18:53:29 +03:00
vchikalkin
b56992a995 fix(registration): improve error handling for customer creation
- Updated error handling in the registration feature to return a generic error message when documentId is not present, enhancing user experience by providing clearer feedback.
2025-08-21 18:48:41 +03:00
vchikalkin
89b0f0badf feat(bot): integrate Redis and update bot configuration
- Added Redis service to both docker-compose files for local development and production environments.
- Updated bot configuration to utilize the Grammy framework, replacing Telegraf.
- Implemented graceful shutdown for the bot, ensuring proper resource management.
- Refactored bot commands and removed deprecated message handling logic.
- Enhanced environment variable management for Redis connection settings.
- Updated dependencies in package.json to include new Grammy-related packages.
2025-08-21 18:24:30 +03:00
vchikalkin
a220d0a369 feat(profile): implement local hooks for profile and service data editing
- Added `useProfileEdit` and `useServiceEdit` hooks to manage pending changes and save functionality for profile and service data cards.
- Updated `ProfileDataCard` and `ServiceDataCard` components to utilize these hooks, enhancing user experience with save and cancel options.
- Introduced buttons for saving and canceling changes, improving the overall interactivity of the forms.
- Refactored input handling to use `updateField` for better state management.
2025-08-21 14:04:28 +03:00
174 changed files with 2617 additions and 10418 deletions

View File

@ -1,10 +1,9 @@
name: Build & Deploy Web, Bot & Cache Proxy name: Build & Deploy Web & Bot
on: on:
push: push:
branches: branches:
- main - main
workflow_dispatch:
jobs: jobs:
build-and-push: build-and-push:
@ -13,31 +12,11 @@ jobs:
outputs: outputs:
web_tag: ${{ steps.vars.outputs.web_tag }} web_tag: ${{ steps.vars.outputs.web_tag }}
bot_tag: ${{ steps.vars.outputs.bot_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: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
# --- НОВОЕ: Шаг 1: dorny/paths-filter для условной сборки --- - name: Create fake .env file for build
- 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
run: | run: |
echo "BOT_TOKEN=fake" > .env echo "BOT_TOKEN=fake" > .env
echo "LOGIN_GRAPHQL=fake" >> .env echo "LOGIN_GRAPHQL=fake" >> .env
@ -46,55 +25,33 @@ jobs:
echo "EMAIL_GRAPHQL=fake@example.com" >> .env echo "EMAIL_GRAPHQL=fake@example.com" >> .env
echo "NEXTAUTH_SECRET=fakesecret" >> .env echo "NEXTAUTH_SECRET=fakesecret" >> .env
echo "BOT_URL=http://localhost:3000" >> .env echo "BOT_URL=http://localhost:3000" >> .env
echo "REDIS_PASSWORD=fake" >> .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 - name: Set image tags
id: vars id: vars
run: | run: |
echo "web_tag=web-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT echo "web_tag=web-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "bot_tag=bot-${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 - name: Login to Docker Hub
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
# --- ИЗМЕНЕНО: Условное выполнение Build/Push ---
- name: Build web image - name: Build web image
if: steps.filter.outputs.web == 'true'
run: | 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:${{ steps.vars.outputs.web_tag }} -f ./apps/web/Dockerfile .
- name: Push web image to Docker Hub - name: Push web image to Docker Hub
if: steps.filter.outputs.web == 'true'
run: | run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }}
- name: Build bot image - name: Build bot image
if: steps.filter.outputs.bot == 'true'
run: | 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:${{ steps.vars.outputs.bot_tag }} -f ./apps/bot/Dockerfile .
- name: Push bot image to Docker Hub - name: Push bot image to Docker Hub
if: steps.filter.outputs.bot == 'true'
run: | run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} 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 }}
# -------------------------------------------------
deploy: deploy:
name: Deploy to VPS name: Deploy to VPS
needs: build-and-push needs: build-and-push
@ -115,10 +72,8 @@ jobs:
run: | 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" 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 real .env file for production
- name: Create .env file for deploy
run: | run: |
# Включаем все секреты, КРОМЕ тегов
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
@ -126,26 +81,12 @@ jobs:
echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env
echo "BOT_URL=${{ secrets.BOT_URL }}" >> .env echo "BOT_URL=${{ secrets.BOT_URL }}" >> .env
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" >> .env
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" >> .env
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .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: Copy .env to VPS via SCP
- 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)
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
host: ${{ secrets.VPS_HOST }} host: ${{ secrets.VPS_HOST }}
@ -155,42 +96,6 @@ jobs:
source: '.env' source: '.env'
target: '/home/${{ secrets.VPS_USER }}/zapishis/' 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 - name: Copy docker-compose.yml to VPS via SCP
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
@ -201,27 +106,12 @@ jobs:
source: 'docker-compose.yml' source: 'docker-compose.yml'
target: '/home/${{ secrets.VPS_USER }}/zapishis/' target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# --- ФИНАЛЬНЫЙ ДЕПЛОЙ ---
- name: Login and deploy on VPS - name: Login and deploy on VPS
run: | run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} " ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
cd /home/${{ secrets.VPS_USER }}/zapishis && \ cd /home/${{ secrets.VPS_USER }}/zapishis && \
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
# 1. Объединение ВСЕХ ENV-файлов в один основной .env docker compose pull && \
# Теги из .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 compose down && \ docker compose down && \
docker compose up -d docker compose up -d
" "

View File

@ -11,7 +11,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache libc6-compat && \ RUN apk add --no-cache libc6-compat && \
corepack enable && \ corepack enable && \
pnpm install turbo@2.3.2 dotenv-cli --global pnpm install turbo dotenv-cli --global
FROM base AS pruner FROM base AS pruner
ARG PROJECT ARG PROJECT

View File

@ -1,13 +1,11 @@
# Общие
-support-contact = По всем вопросам и обратной связи: @v_dev_support
# Описание бота # Описание бота
short-description = short-description =
Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅 Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅
{ -support-contact } По всем вопросам и обратной связи: @vchikalkin
description = description =
📲 Запишись.онлайн — это встроенное в Telegram приложение + бот для мастеров и тренеров в вашем смартфоне. 📲 Запишись.онлайн — это бесплатное Telegram-приложение для мастеров и тренеров в вашем смартфоне.
Возможности: Возможности:
• 📅 Ведение графика и запись клиентов • 📅 Ведение графика и запись клиентов
@ -19,130 +17,89 @@ description =
✨ Всё, что нужно — ваш смартфон. ✨ Всё, что нужно — ваш смартфон.
{ -support-contact } По всем вопросам и обратной связи: @vchikalkin
# Команды # Команды
start = start =
.description = Запуск бота .description = Запуск бота
addcontact = addcontact =
.description = Добавить контакт .description = Добавить контакт клиента
becomemaster =
.description = Стать мастером
sharebot = sharebot =
.description = Поделиться ботом .description = Поделиться ботом
subscribe =
.description = Приобрести Pro доступ
pro =
.description = Информация о вашем Pro доступе
help = help =
.description = Список команд и поддержка .description = Список команд и поддержка
commands-list = commands-list =
📋 Доступные команды: 📋 Доступные команды:
• /addcontact — добавить контакт • /addcontact — добавить контакт клиента
• /becomemaster — стать мастером
• /sharebot — поделиться ботом • /sharebot — поделиться ботом
• /subscribe — приобрести Pro доступ
• /pro — информация о вашем Pro доступе
• /help — список команд • /help — список команд
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
support = support =
{ -support-contact } По всем вопросам и обратной связи: @vchikalkin
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 =
👋 Добро пожаловать! 👋 Добро пожаловать!
Пожалуйста, поделитесь своим номером телефона для регистрации Пожалуйста, поделитесь своим номером телефона для регистрации
msg-welcome-back = 👋 С возвращением, { $name }! msg-welcome-back = 👋 С возвращением, { $name }!
# Сообщения о статусе мастера
msg-not-master =
⛔️ Только мастер может добавлять контакты
Стать мастером можно на странице профиля в приложении или с помощью команды /becomemaster
msg-already-master = 🎉 Вы уже являетесь мастером!
msg-become-master = 🥳 Поздравляем! Теперь вы мастер
# Сообщения о телефоне # Сообщения о телефоне
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона. msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
msg-phone-saved = msg-phone-saved =
✅ Спасибо! Мы сохранили ваш номер телефона ✅ Спасибо! Мы сохранили ваш номер телефона
Теперь вы можете открыть приложение или воспользоваться командами бота Теперь вы можете открыть приложение или воспользоваться командами бота
msg-already-registered = msg-already-registered =
✅ Вы уже зарегистрированы в системе ✅ Вы уже зарегистрированы в системе
<i>Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)</i> Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999
msg-invalid-phone = ❌ Некорректный номер телефона
# Сообщения о контактах # Сообщения о контактах
msg-send-client-contact = 👤 Отправьте контакт пользователя, которого вы хотите добавить. msg-send-client-contact =
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении 👤 Отправьте контакт клиента, которого вы хотите добавить.
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram Для отмены операции используйте команду /cancel
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
msg-contact-added =
✅ Добавили { $fullname } в список ваших контактов
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i>
msg-contact-added =
✅ Добавили { $name } в список ваших клиентов
Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
# Сообщения для шаринга # Сообщения для шаринга
msg-share-bot = msg-share-bot =
📅 Воспользуйтесь этим ботом для записи к вашему мастеру! 📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
Нажмите кнопку ниже, чтобы начать Нажмите кнопку ниже, чтобы начать
# Системные сообщения # Системные сообщения
msg-cancel = ❌ Операция отменена msg-cancel = ❌ Операция отменена
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
msg-cancel-operation = <i>Для отмены операции используйте команду /cancel</i>
# Ошибки # Ошибки
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
err-banned = 🚫 Ваш аккаунт заблокирован err-banned = 🚫 Ваш аккаунт заблокирован
err-with-details = ❌ Произошла ошибка err-with-details = ❌ Произошла ошибка
{ $error } { $error }
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного 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 }

View File

@ -17,7 +17,6 @@
"@grammyjs/conversations": "^2.1.0", "@grammyjs/conversations": "^2.1.0",
"@grammyjs/hydrate": "^1.6.0", "@grammyjs/hydrate": "^1.6.0",
"@grammyjs/i18n": "^1.1.2", "@grammyjs/i18n": "^1.1.2",
"@grammyjs/menu": "^1.3.1",
"@grammyjs/parse-mode": "^2.2.0", "@grammyjs/parse-mode": "^2.2.0",
"@grammyjs/ratelimiter": "^1.2.1", "@grammyjs/ratelimiter": "^1.2.1",
"@grammyjs/runner": "^2.0.3", "@grammyjs/runner": "^2.0.3",
@ -26,13 +25,10 @@
"@repo/graphql": "workspace:*", "@repo/graphql": "workspace:*",
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "catalog:", "@types/node": "catalog:",
"dayjs": "catalog:",
"grammy": "^1.38.1", "grammy": "^1.38.1",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"libphonenumber-js": "^1.12.24",
"pino": "^9.9.0", "pino": "^9.9.0",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"radashi": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "catalog:", "typescript": "catalog:",
"zod": "catalog:" "zod": "catalog:"

View File

@ -1,3 +1,4 @@
import { type logger } from '@/utils/logger';
import { type AutoChatActionFlavor } from '@grammyjs/auto-chat-action'; import { type AutoChatActionFlavor } from '@grammyjs/auto-chat-action';
import { type CommandsFlavor } from '@grammyjs/commands'; import { type CommandsFlavor } from '@grammyjs/commands';
import { type ConversationFlavor } from '@grammyjs/conversations'; import { type ConversationFlavor } from '@grammyjs/conversations';
@ -7,7 +8,13 @@ import { type Context as DefaultContext, type SessionFlavor } from 'grammy';
export type Context = ConversationFlavor< export type Context = ConversationFlavor<
HydrateFlavor< HydrateFlavor<
AutoChatActionFlavor & CommandsFlavor & DefaultContext & I18nFlavor & SessionFlavor<SessionData> AutoChatActionFlavor &
CommandsFlavor &
DefaultContext &
I18nFlavor &
SessionFlavor<SessionData> & {
logger: typeof logger;
}
> >
>; >;

View File

@ -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();
}

View File

@ -1,2 +0,0 @@
export * from './add-contact';
export * from './subscription';

View File

@ -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')));
}

View File

@ -1,12 +1,101 @@
import { handleAddContact } from '../handlers/add-contact'; /* eslint-disable id-length */
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { isCustomerMaster } from '@/utils/customer';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { type Conversation, createConversation } from '@grammyjs/conversations';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('addcontact', logHandle('command-add-contact'), handleAddContact); 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 customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return ctx.reply(
await conversation.external(({ t }) => t('msg-need-phone')),
KEYBOARD_SHARE_PHONE,
);
}
if (!isCustomerMaster(customer)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-not-master')));
}
// Просим отправить контакт клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact')));
// Ждем любое сообщение от пользователя
const waitCtx = await conversation.wait();
// Проверяем команду отмены
if (waitCtx.message?.text === '/cancel') {
return ctx.reply(await conversation.external(({ t }) => t('msg-cancel')));
}
// Проверяем, что отправлен контакт
if (!waitCtx.message?.contact) {
return ctx.reply(await conversation.external(({ t }) => t('msg-send-contact')));
}
const { contact } = waitCtx.message;
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const phone = normalizePhoneNumber(contact.phone_number);
// Проверяем валидность номера телефона
if (!isValidPhoneNumber(phone)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
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 ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')));
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) })),
);
} finally {
await ctx.reply(await conversation.external(({ t }) => t('commands-list')), KEYBOARD_REMOVE);
}
return ctx.reply(await conversation.external(({ t }) => t('err-generic')), KEYBOARD_REMOVE);
}
feature.use(createConversation(addContact));
feature.command('addcontact', logHandle('command-add-contact'), async (ctx) => {
await ctx.conversation.enter('addContact');
});
export { composer as addContact }; export { composer as addContact };

View File

@ -0,0 +1,42 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { isCustomerMaster } from '@/utils/customer';
import { CustomersService } from '@repo/graphql/api/customers';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('becomemaster', logHandle('command-become-master'), async (ctx) => {
const telegramId = ctx.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return ctx.reply(ctx.t('msg-need-phone'), { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
}
if (isCustomerMaster(customer)) {
return ctx.reply(ctx.t('msg-already-master'), { parse_mode: 'HTML' });
}
// Обновляем роль клиента на мастер
const response = await customerService
.updateCustomer({
data: { role: Enum_Customer_Role.Master },
})
.catch((error) => {
ctx.reply(ctx.t('err-with-details', { error: String(error) }), { parse_mode: 'HTML' });
});
if (response) {
return ctx.reply(ctx.t('msg-become-master'), { parse_mode: 'HTML' });
}
return ctx.reply(ctx.t('err-generic'), { parse_mode: 'HTML' });
});
export { composer as becomeMaster };

View File

@ -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 };

View File

@ -1,6 +1,7 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { mainMenu } from '@/config/keyboards'; import { KEYBOARD_REMOVE } from '@/config/keyboards';
import { combine } from '@/utils/messages';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -8,7 +9,10 @@ const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('help', logHandle('command-help'), async (ctx) => { feature.command('help', logHandle('command-help'), async (ctx) => {
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); return ctx.reply(combine(ctx.t('commands-list'), ctx.t('support')), {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
});
}); });
export { composer as help }; export { composer as help };

View File

@ -1,8 +1,6 @@
export * from './add-contact'; export * from './add-contact';
export * from './documents'; export * from './become-master';
export * from './help'; export * from './help';
export * from './pro';
export * from './registration'; export * from './registration';
export * from './share-bot'; export * from './share-bot';
export * from './subscription';
export * from './welcome'; export * from './welcome';

View File

@ -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 };

View File

@ -1,10 +1,10 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards'; import { KEYBOARD_REMOVE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact'; import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration'; import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
import parsePhoneNumber from 'libphonenumber-js';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -14,13 +14,11 @@ const feature = composer.chatType('private');
feature.on(':contact', logHandle('contact-registration'), async (ctx) => { feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
const telegramId = ctx.from.id; const telegramId = ctx.from.id;
const { contact } = ctx.message; const { contact } = ctx.message;
const { name, surname } = parseContact(contact); const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
// Проверяем, не зарегистрирован ли уже пользователь // Проверяем, не зарегистрирован ли уже пользователь
const registrationService = new RegistrationService(); const customerService = new CustomersService({ telegramId });
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({ const { customer: existingCustomer } = await customerService.getCustomer({ telegramId });
telegramId,
});
if (existingCustomer) { if (existingCustomer) {
return ctx.reply(ctx.t('msg-already-registered'), { return ctx.reply(ctx.t('msg-already-registered'), {
@ -35,32 +33,31 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
} }
// Нормализация и валидация номера // Нормализация и валидация номера
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU'); const phone = normalizePhoneNumber(contact.phone_number);
if (!parsedPhone?.isValid() || !parsedPhone?.number) { if (!isValidPhoneNumber(phone)) {
return ctx.reply(ctx.t('msg-invalid-phone')); return ctx.reply(ctx.t('msg-invalid-phone'));
} }
const registrationService = new RegistrationService();
try { try {
const { customer } = await registrationService._NOCACHE_GetCustomer({ const { customer } = await registrationService.getCustomer({ phone });
phone: parsedPhone.number,
});
if (customer && !customer.telegramId) { if (customer && !customer.telegramId) {
// Пользователь добавлен ранее мастером — обновляем данные // Пользователь добавлен ранее мастером — обновляем данные
await registrationService.updateCustomer({ await registrationService.updateCustomer({
data: { active: true, name, surname, telegramId }, data: { active: true, name, telegramId },
documentId: customer.documentId, documentId: customer.documentId,
}); });
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE }); return ctx.reply(ctx.t('msg-phone-saved') + '\n\n' + ctx.t('commands-list'), {
...KEYBOARD_REMOVE,
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); parse_mode: 'HTML',
});
} }
// Новый пользователь — создаём и активируем // Новый пользователь — создаём и активируем
const response = await registrationService.createCustomer({ const response = await registrationService.createCustomer({ name, phone, telegramId });
data: { name, phone: parsedPhone.number, surname, telegramId },
});
const documentId = response?.createCustomer?.documentId; const documentId = response?.createCustomer?.documentId;
if (!documentId) return ctx.reply(ctx.t('err-generic')); if (!documentId) return ctx.reply(ctx.t('err-generic'));
@ -70,9 +67,10 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
documentId, documentId,
}); });
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE }); return ctx.reply(ctx.t('msg-phone-saved') + '\n\n' + ctx.t('commands-list'), {
...KEYBOARD_REMOVE,
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); parse_mode: 'HTML',
});
} catch (error) { } catch (error) {
return ctx.reply(ctx.t('err-with-details', { error: String(error) })); return ctx.reply(ctx.t('err-with-details', { error: String(error) }));
} }

View File

@ -1,12 +1,15 @@
import { handleShareBot } from '../handlers/share-bot';
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_SHARE_BOT } from '@/config/keyboards';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('sharebot', logHandle('command-share-bot'), handleShareBot); feature.command('sharebot', logHandle('command-share-bot'), async (ctx) => {
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 { composer as shareBot }; export { composer as shareBot };

View File

@ -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 };

View File

@ -1,9 +1,8 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { env } from '@/config/env'; import { KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards';
import { combine } from '@/utils/messages'; import { combine } from '@/utils/messages';
import { RegistrationService } from '@repo/graphql/api/registration'; import { CustomersService } from '@repo/graphql/api/customers';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -13,30 +12,22 @@ const feature = composer.chatType('private');
feature.command('start', logHandle('command-start'), async (ctx) => { feature.command('start', logHandle('command-start'), async (ctx) => {
const telegramId = ctx.from.id; const telegramId = ctx.from.id;
const registrationService = new RegistrationService(); const customerService = new CustomersService({ telegramId });
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId });
if (customer) { if (customer) {
// Пользователь уже зарегистрирован — приветствуем // Пользователь уже зарегистрирован — приветствуем
return ctx.reply(ctx.t('msg-welcome-back', { name: customer.name }), { return ctx.reply(
reply_markup: mainMenu, combine(ctx.t('msg-welcome-back', { name: customer.name }), ctx.t('commands-list')),
}); {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
},
);
} }
// Новый пользователь — просим поделиться номером // Новый пользователь — просим поделиться номером
return ctx.reply( return ctx.reply(ctx.t('msg-welcome'), { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
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 }; export { composer as welcome };

View File

@ -1,7 +0,0 @@
import { type Context } from '@/bot/context';
async function handler(ctx: Context) {
await ctx.conversation.enter('addContact');
}
export { handler as handleAddContact };

View File

@ -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 };

View File

@ -1,7 +1,6 @@
import { type Context } from '../context'; import { type Context } from '../context';
import { getUpdateInfo } from '../helpers/logging'; import { getUpdateInfo } from '../helpers/logging';
import { KEYBOARD_REMOVE } from '@/config/keyboards'; import { KEYBOARD_REMOVE } from '@/config/keyboards';
import { logger } from '@/utils/logger';
import { ERRORS } from '@repo/graphql/constants/errors'; import { ERRORS } from '@repo/graphql/constants/errors';
import { type ErrorHandler } from 'grammy'; import { type ErrorHandler } from 'grammy';
@ -12,7 +11,7 @@ export const errorHandler: ErrorHandler<Context> = async (error) => {
await ctx.reply(ctx.t(text), { ...KEYBOARD_REMOVE, parse_mode: 'HTML' }); await ctx.reply(ctx.t(text), { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
logger.error({ ctx.logger.error({
err: error.error, err: error.error,
update: getUpdateInfo(ctx), update: getUpdateInfo(ctx),
}); });

View File

@ -1,5 +0,0 @@
export * from './add-contact';
export * from './documents';
export * from './pro';
export * from './share-bot';
export * from './subscription';

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { type Context } from '../context'; import { type Context } from '../context';
import { logger } from '@/utils/logger';
import { type Update } from '@grammyjs/types'; import { type Update } from '@grammyjs/types';
import { type Middleware } from 'grammy'; import { type Middleware } from 'grammy';
@ -12,7 +11,7 @@ export function getUpdateInfo(context: Context): Omit<Update, 'update_id'> {
export function logHandle(id: string): Middleware<Context> { export function logHandle(id: string): Middleware<Context> {
return (context, next) => { return (context, next) => {
logger.info({ context.logger.info({
msg: `Handle "${id}"`, msg: `Handle "${id}"`,
...(id.startsWith('unhandled') ? { update: getUpdateInfo(context) } : {}), ...(id.startsWith('unhandled') ? { update: getUpdateInfo(context) } : {}),
}); });

View File

@ -3,7 +3,7 @@ import { I18n } from '@grammyjs/i18n';
import path from 'node:path'; import path from 'node:path';
export const i18n = new I18n<Context>({ export const i18n = new I18n<Context>({
defaultLocale: 'ru', defaultLocale: 'en',
directory: path.resolve(process.cwd(), 'locales'), directory: path.resolve(process.cwd(), 'locales'),
fluentBundleOptions: { fluentBundleOptions: {
useIsolating: false, useIsolating: false,

View File

@ -1,16 +1,16 @@
import { type Context } from './context'; import { type Context } from './context';
import * as conversations from './conversations';
import * as features from './features'; import * as features from './features';
import { unhandledFeature } from './features/unhandled'; import { unhandledFeature } from './features/unhandled';
import { errorHandler } from './handlers/errors'; import { errorHandler } from './handlers/errors';
import { i18n } from './i18n'; import { i18n } from './i18n';
import * as middlewares from './middlewares'; import * as middlewares from './middlewares';
import { setCommands, setInfo } from './settings'; import { setCommands } from './settings/commands';
import { setInfo } from './settings/info';
import { env } from '@/config/env'; import { env } from '@/config/env';
import { mainMenu } from '@/config/keyboards'; import { logger } from '@/utils/logger';
import { getRedisInstance } from '@/utils/redis'; import { getRedisInstance } from '@/utils/redis';
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action'; import { autoChatAction } from '@grammyjs/auto-chat-action';
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations'; import { conversations } from '@grammyjs/conversations';
import { hydrate } from '@grammyjs/hydrate'; import { hydrate } from '@grammyjs/hydrate';
import { limit } from '@grammyjs/ratelimiter'; import { limit } from '@grammyjs/ratelimiter';
import { Bot } from 'grammy'; import { Bot } from 'grammy';
@ -38,20 +38,14 @@ export function createBot({ token }: Parameters_) {
}), }),
); );
bot.use(autoChatAction(bot.api)); bot.use(async (context, next) => {
context.logger = logger.child({
bot.use(chatAction('typing')); update_id: context.update.update_id,
});
bot.use(grammyConversations()).command('cancel', async (ctx) => { await next();
await ctx.conversation.exitAll();
await ctx.reply(ctx.t('msg-cancel'));
}); });
for (const conversation of Object.values(conversations)) { bot.use(conversations());
bot.use(createConversation(conversation));
}
bot.use(mainMenu);
setInfo(bot); setInfo(bot);
setCommands(bot); setCommands(bot);
@ -59,6 +53,7 @@ export function createBot({ token }: Parameters_) {
const protectedBot = bot.errorBoundary(errorHandler); const protectedBot = bot.errorBoundary(errorHandler);
protectedBot.use(middlewares.updateLogger()); protectedBot.use(middlewares.updateLogger());
protectedBot.use(autoChatAction(bot.api));
protectedBot.use(hydrate()); protectedBot.use(hydrate());
for (const feature of Object.values(features)) { for (const feature of Object.values(features)) {

View File

@ -1,13 +1,12 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { getUpdateInfo } from '@/bot/helpers/logging'; import { getUpdateInfo } from '@/bot/helpers/logging';
import { logger } from '@/utils/logger';
import { type Middleware } from 'grammy'; import { type Middleware } from 'grammy';
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
export function updateLogger(): Middleware<Context> { export function updateLogger(): Middleware<Context> {
return async (ctx, next) => { return async (ctx, next) => {
ctx.api.config.use((previous, method, payload, signal) => { ctx.api.config.use((previous, method, payload, signal) => {
logger.debug({ ctx.logger.debug({
method, method,
msg: 'Bot API call', msg: 'Bot API call',
payload, payload,
@ -16,7 +15,7 @@ export function updateLogger(): Middleware<Context> {
return previous(method, payload, signal); return previous(method, payload, signal);
}); });
logger.debug({ ctx.logger.debug({
msg: 'Update received', msg: 'Update received',
update: getUpdateInfo(ctx), update: getUpdateInfo(ctx),
}); });
@ -26,7 +25,7 @@ export function updateLogger(): Middleware<Context> {
return next(); return next();
} finally { } finally {
const endTime = performance.now(); const endTime = performance.now();
logger.debug({ ctx.logger.debug({
elapsed: endTime - startTime, elapsed: endTime - startTime,
msg: 'Update processed', msg: 'Update processed',
}); });

View File

@ -5,15 +5,7 @@ import { type LanguageCode } from '@grammyjs/types';
import { type Api, type Bot, type RawApi } from 'grammy'; import { type Api, type Bot, type RawApi } from 'grammy';
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) { export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
const commands = createCommands([ const commands = createCommands(['start', 'addcontact', 'becomemaster', 'sharebot', 'help']);
'start',
'addcontact',
'sharebot',
'help',
'subscribe',
'pro',
'documents',
]);
for (const command of commands) { for (const command of commands) {
addLocalizations(command); addLocalizations(command);

View File

@ -1,7 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
export const envSchema = z.object({ export const envSchema = z.object({
BOT_PROVIDER_TOKEN: z.string(),
BOT_TOKEN: z.string(), BOT_TOKEN: z.string(),
BOT_URL: z.string(), BOT_URL: z.string(),
RATE_LIMIT: z RATE_LIMIT: z
@ -18,9 +17,6 @@ export const envSchema = z.object({
.string() .string()
.transform((value) => Number.parseInt(value, 10)) .transform((value) => Number.parseInt(value, 10))
.default('6379'), .default('6379'),
URL_FAQ: z.string(),
URL_OFFER: z.string(),
URL_PRIVACY: z.string(),
}); });
export const env = envSchema.parse(process.env); export const env = envSchema.parse(process.env);

View File

@ -1,13 +1,3 @@
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 { import {
type InlineKeyboardMarkup, type InlineKeyboardMarkup,
type ReplyKeyboardMarkup, type ReplyKeyboardMarkup,
@ -40,34 +30,9 @@ export const KEYBOARD_SHARE_BOT = {
[ [
{ {
text: ' Воспользоваться ботом', text: ' Воспользоваться ботом',
url: env.BOT_URL + '?start=new', url: process.env.BOT_URL as string,
}, },
], ],
], ],
} as InlineKeyboardMarkup, } 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();
},
);

View File

@ -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() || '',
};
}

View 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;
}

View File

@ -1,4 +0,0 @@
export const formatMoney = Intl.NumberFormat('ru-RU', {
currency: 'RUB',
style: 'currency',
}).format;

View File

@ -1,7 +1,7 @@
/* eslint-disable turbo/no-undeclared-env-vars */ /* eslint-disable turbo/no-undeclared-env-vars */
import pino from 'pino'; import pino from 'pino';
const logger = pino({ export const logger = pino({
transport: { transport: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
options: { options: {
@ -11,9 +11,3 @@ const logger = pino({
target: 'pino-pretty', target: 'pino-pretty',
}, },
}); });
logger.info = logger.info.bind(logger);
logger.debug = logger.debug.bind(logger);
logger.error = logger.error.bind(logger);
export { logger };

View File

@ -1,3 +1,3 @@
export function combine(...messages: Array<string | undefined>) { export function combine(...messages: string[]) {
return messages.filter(Boolean).join('\n\n'); return messages.join('\n\n');
} }

View 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}`;
}

View File

@ -7,7 +7,7 @@ export default defineConfig({
external: ['telegraf', 'zod'], external: ['telegraf', 'zod'],
format: 'cjs', format: 'cjs',
loader: { '.json': 'copy' }, loader: { '.json': 'copy' },
minify: false, minify: true,
noExternal: ['@repo'], noExternal: ['@repo'],
outDir: './dist', outDir: './dist',
sourcemap: false, sourcemap: false,

View File

@ -1,7 +0,0 @@
.git
Dockerfile
.dockerignore
node_modules
*.log
dist
README.md

View File

@ -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',
},
},
];

View File

@ -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

View File

@ -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"]

View File

@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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).

View File

@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"monorepo": true,
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -1,3 +0,0 @@
import { seconds } from 'src/utils/time';
export const DEFAULT_CACHE_TTL = seconds().fromMinutes(5);

View File

@ -1,3 +0,0 @@
import envSchema from './schema/env';
export const env = envSchema.parse(process.env);

View File

@ -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;

View File

@ -1,11 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('api')
export class HealthController {
@Get('health')
public health() {
return { status: 'ok' };
}
}

View File

@ -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();

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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 {}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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;
},
};
}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -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"]
}

View File

@ -11,7 +11,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache libc6-compat openssl && \ RUN apk add --no-cache libc6-compat openssl && \
corepack enable && \ corepack enable && \
pnpm install turbo@2.3.2 dotenv-cli --global pnpm install turbo dotenv-cli --global
FROM base AS pruner FROM base AS pruner
ARG PROJECT ARG PROJECT

View File

@ -1,9 +1,8 @@
import * as customers from './server/customers'; import * as customers from './server/customers';
import { wrapClientAction } from '@/utils/actions'; import { wrapClientAction } from '@/utils/actions';
export const addInvitedBy = wrapClientAction(customers.addInvitedBy); export const addMasters = wrapClientAction(customers.addMasters);
export const getInvited = wrapClientAction(customers.getInvited); export const getClients = wrapClientAction(customers.getClients);
export const getCustomer = wrapClientAction(customers.getCustomer); export const getCustomer = wrapClientAction(customers.getCustomer);
export const getCustomers = wrapClientAction(customers.getCustomers); export const getMasters = wrapClientAction(customers.getMasters);
export const getInvitedBy = wrapClientAction(customers.getInvitedBy);
export const updateCustomer = wrapClientAction(customers.updateCustomer); export const updateCustomer = wrapClientAction(customers.updateCustomer);

View File

@ -6,10 +6,16 @@ import { CustomersService } from '@repo/graphql/api/customers';
const getService = useService(CustomersService); const getService = useService(CustomersService);
export async function addInvitedBy(...variables: Parameters<CustomersService['addInvitedBy']>) { export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
const service = await getService(); 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']>) { 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)); 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(); const service = await getService();
return wrapServerAction(() => service.getCustomers(...variables)); return wrapServerAction(() => service.getMasters(...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));
} }
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) { export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {

View File

@ -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));
}

View File

@ -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);

View File

@ -2,15 +2,12 @@
import { authOptions } from '@/config/auth'; import { authOptions } from '@/config/auth';
import { getServerSession } from 'next-auth/next'; import { getServerSession } from 'next-auth/next';
import { redirect } from 'next/navigation';
export async function getSessionUser() { export async function getSessionUser() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const user = session?.user; const user = session?.user;
if (!user?.telegramId) { if (!user?.telegramId) throw new Error('Missing session');
return redirect('/');
}
return user; return user;
} }

View File

@ -22,18 +22,7 @@ export default function Auth() {
signIn('telegram', { signIn('telegram', {
callbackUrl: '/profile', callbackUrl: '/profile',
redirect: false, redirect: false,
telegramId: user?.id?.toString(), telegramId: user?.id,
}).then((result) => {
if (
result?.error &&
(result?.error?.includes('CredentialsSignin') ||
result?.error?.includes('UNREGISTERED'))
) {
// Пользователь не зарегистрирован
redirect('/unregistered');
} else if (result?.ok) {
redirect('/profile');
}
}); });
}); });
} }

View File

@ -10,6 +10,4 @@ export default function Page() {
redirect(isTG ? '/telegram' : '/browser'); redirect(isTG ? '/telegram' : '/browser');
}); });
return 'Redirecting...';
} }

View File

@ -1,6 +1,6 @@
import { TelegramProvider } from '@/providers/telegram'; import { TelegramProvider } from '@/providers/telegram';
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) { export default async function Layout({ children }: Readonly<PropsWithChildren>) {
return <TelegramProvider>{children}</TelegramProvider>; return <TelegramProvider>{children}</TelegramProvider>;
} }

View File

@ -1,79 +1,40 @@
/* eslint-disable promise/prefer-await-to-then */ /* eslint-disable promise/prefer-await-to-then */
'use client'; 'use client';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react'; 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 { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation'; import { redirect } from 'next/navigation';
import { useCallback, useEffect } from 'react'; import { useEffect } from 'react';
export default function Auth() { export default function Auth() {
useTelegramTheme(); useTelegramTheme();
useAuth();
useTelegramAuth(); return null;
return <LoadingSpinner />;
} }
/** function useAuth() {
* Хук для авторизации пользователя через NextAuth
*/
function useTelegramAuth() {
const initDataUser = useSignal(initData.user); const initDataUser = useSignal(initData.user);
const { status } = useSession();
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],
);
useEffect(() => { useEffect(() => {
const telegramId = initDataUser?.id; if (!initDataUser?.id) return;
if (!telegramId) return;
if (status === 'authenticated') { if (status === 'authenticated') {
// Если telegramId есть в сессии — редирект redirect('/profile');
if (session?.user?.telegramId) {
router.replace('/profile');
} else {
// Если telegramId отсутствует — пробуем заново signIn
void signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: telegramId.toString(),
}).then(handleSignInResult);
}
return;
} }
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
void signIn('telegram', { signIn('telegram', {
callbackUrl: '/profile', callbackUrl: '/profile',
redirect: false, redirect: false,
telegramId: telegramId.toString(), telegramId: initDataUser.id,
}).then(handleSignInResult); }).then(() => redirect('/profile'));
} }
}, [initDataUser?.id, status, session?.user?.telegramId, router, handleSignInResult]); }, [initDataUser?.id, status]);
} }
/**
* Хук для установки темы из Telegram Mini App
*/
function useTelegramTheme() { function useTelegramTheme() {
const isDark = isMiniAppDark(); const isDark = isMiniAppDark();
const { setTheme } = useTheme(); const { setTheme } = useTheme();

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>;
}

View File

@ -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. Контакты
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -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>;
}

View File

@ -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. Контакты
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -1,4 +1,4 @@
import { ContactsList } from '@/components/contacts'; import { ContactsFilter, ContactsList } from '@/components/contacts';
import { ContactsContextProvider } from '@/context/contacts'; import { ContactsContextProvider } from '@/context/contacts';
import { Card } from '@repo/ui/components/ui/card'; import { Card } from '@repo/ui/components/ui/card';
@ -8,7 +8,7 @@ export default function ContactsPage() {
<Card> <Card>
<div className="flex flex-row items-center justify-between space-x-4 p-4"> <div className="flex flex-row items-center justify-between space-x-4 p-4">
<h1 className="font-bold">Контакты</h1> <h1 className="font-bold">Контакты</h1>
{/* <ContactsFilter /> */} <ContactsFilter />
</div> </div>
<div className="p-4 pt-0"> <div className="p-4 pt-0">
<ContactsList /> <ContactsList />

View File

@ -1,3 +1,4 @@
import { getOrder } from '@/actions/api/orders';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { import {
@ -8,14 +9,23 @@ import {
OrderStatus, OrderStatus,
} from '@/components/orders'; } from '@/components/orders';
import { type OrderPageParameters } from '@/components/orders/types'; import { type OrderPageParameters } from '@/components/orders/types';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<OrderPageParameters> }; type Props = { params: Promise<OrderPageParameters> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params; const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Запись" /> <PageHeader title="Запись" />
<Container> <Container>
<OrderDateTime {...parameters} /> <OrderDateTime {...parameters} />
@ -25,6 +35,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
<div className="pb-24" /> <div className="pb-24" />
<OrderButtons {...parameters} /> <OrderButtons {...parameters} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -2,7 +2,7 @@ import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { OrderForm } from '@/components/orders'; import { OrderForm } from '@/components/orders';
export default function AddOrdersPage() { export default async function AddOrdersPage() {
return ( return (
<> <>
<PageHeader title="Новая запись" /> <PageHeader title="Новая запись" />

View File

@ -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>
);
}

View File

@ -1,8 +1,11 @@
import { getCustomer } from '@/actions/api/customers';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile'; import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
import { ProfileButtons } from '@/components/profile/profile-buttons'; import { BookButton } from '@/components/shared/book-button';
import { ReadonlyServicesList } from '@/components/profile/services'; import { isCustomerMaster } from '@repo/utils/customer';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
// Тип параметров страницы // Тип параметров страницы
type Props = { params: Promise<{ telegramId: string }> }; type Props = { params: Promise<{ telegramId: string }> };
@ -10,18 +13,44 @@ type Props = { params: Promise<{ telegramId: string }> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const { telegramId } = await props.params; const { telegramId } = await props.params;
const contactTelegramId = Number(telegramId); const contactTelegramId = Number(telegramId);
const queryClient = new QueryClient();
// Получаем профиль контакта
const { customer: profile } = await queryClient.fetchQuery({
queryFn: () => getCustomer({ telegramId: contactTelegramId }),
queryKey: ['customer', contactTelegramId],
});
// Получаем текущего пользователя
const sessionUser = await getSessionUser();
const { customer: currentUser } = await queryClient.fetchQuery({
queryFn: () => getCustomer({ telegramId: sessionUser.telegramId }),
queryKey: ['customer', sessionUser.telegramId],
});
// Проверка наличия данных
if (!profile || !currentUser) return null;
// Определяем роли и id
const isMaster = isCustomerMaster(currentUser);
const masterId = isMaster ? currentUser.documentId : profile.documentId;
const clientId = isMaster ? profile.documentId : currentUser.documentId;
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Профиль контакта" /> <PageHeader title="Профиль контакта" />
<Container className="px-0"> <Container className="px-0">
<PersonCard telegramId={contactTelegramId} /> <PersonCard telegramId={contactTelegramId} />
<ContactDataCard telegramId={contactTelegramId} /> <ContactDataCard telegramId={contactTelegramId} />
<ReadonlyServicesList telegramId={contactTelegramId} />
<ProfileOrdersList telegramId={contactTelegramId} /> <ProfileOrdersList telegramId={contactTelegramId} />
<div className="pb-24" /> {masterId && clientId && (
<ProfileButtons telegramId={contactTelegramId} /> <BookButton
clientId={clientId}
label={isMaster ? 'Записать' : 'Записаться'}
masterId={masterId}
/>
)}
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -1,13 +1,26 @@
import { getCustomer } from '@/actions/api/customers';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { LinksCard, PersonCard, ProfileDataCard, SubscriptionInfoBar } from '@/components/profile'; import { LinksCard, PersonCard, ProfileDataCard } from '@/components/profile';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function ProfilePage() {
const queryClient = new QueryClient();
const { telegramId } = await getSessionUser();
await queryClient.prefetchQuery({
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
export default function ProfilePage() {
return ( return (
<Container className="px-0"> <HydrationBoundary state={dehydrate(queryClient)}>
<PersonCard /> <Container className="px-0">
<SubscriptionInfoBar /> <PersonCard />
<ProfileDataCard /> <ProfileDataCard />
<LinksCard /> <LinksCard />
</Container> </Container>
</HydrationBoundary>
); );
} }

View File

@ -1,24 +1,45 @@
import { getCustomer } from '@/actions/api/customers';
import { getSlot } from '@/actions/api/slots';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule'; import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
import { type SlotPageParameters } from '@/components/schedule/types'; import { type SlotPageParameters } from '@/components/schedule/types';
import { BookButton } from '@/components/shared/book-button'; import { BookButton } from '@/components/shared/book-button';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<SlotPageParameters> }; type Props = { params: Promise<SlotPageParameters> };
export default async function SlotPage(props: Readonly<Props>) { export default async function SlotPage(props: Readonly<Props>) {
const parameters = await props.params; const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getSlot({ documentId }),
queryKey: ['slot', documentId],
});
// Получаем текущего пользователя
const sessionUser = await getSessionUser();
const { customer: currentUser } = await queryClient.fetchQuery({
queryFn: () => getCustomer({ telegramId: sessionUser.telegramId }),
queryKey: ['customer', sessionUser.telegramId],
});
const masterId = currentUser?.documentId;
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Слот" /> <PageHeader title="Слот" />
<Container> <Container>
<SlotDateTime {...parameters} /> <SlotDateTime {...parameters} />
<SlotOrdersList {...parameters} /> <SlotOrdersList {...parameters} />
<BookButton label="Создать запись" /> {masterId && <BookButton label="Создать запись" masterId={masterId} />}
<div className="pb-24" /> <div className="pb-24" />
<SlotButtons {...parameters} /> <SlotButtons {...parameters} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -1,20 +1,28 @@
import { getService } from '@/actions/api/services';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { ServiceButtons, ServiceDataCard } from '@/components/profile/services'; import { ServiceButtons, ServiceDataCard } from '@/components/profile/services';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
// Тип параметров страницы // Тип параметров страницы
type Props = { params: Promise<{ serviceId: string }> }; type Props = { params: Promise<{ serviceId: string }> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const { serviceId } = await props.params; const { serviceId } = await props.params;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getService({ documentId: serviceId }),
queryKey: ['service', serviceId],
});
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Услуга" /> <PageHeader title="Услуга" />
<Container className="px-0"> <Container className="px-0">
<ServiceDataCard serviceId={serviceId} /> <ServiceDataCard serviceId={serviceId} />
<ServiceButtons serviceId={serviceId} /> <ServiceButtons serviceId={serviceId} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -1,5 +0,0 @@
import { NextResponse } from 'next/server';
export function GET() {
return new NextResponse('OK');
}

View File

@ -2,9 +2,11 @@ import { AuthProvider } from '@/providers/auth';
import { ErrorProvider } from '@/providers/error'; import { ErrorProvider } from '@/providers/error';
import { QueryProvider } from '@/providers/query'; import { QueryProvider } from '@/providers/query';
import { ThemeProvider } from '@/providers/theme-provider'; import { ThemeProvider } from '@/providers/theme-provider';
import { I18nProvider } from '@/utils/i18n/provider';
import '@repo/ui/globals.css'; import '@repo/ui/globals.css';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { type Metadata } from 'next'; import { type Metadata } from 'next';
import { getLocale } from 'next-intl/server';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
@ -15,15 +17,19 @@ export const metadata: Metadata = {
}; };
export default async function RootLayout({ children }: Readonly<PropsWithChildren>) { export default async function RootLayout({ children }: Readonly<PropsWithChildren>) {
const locale = await getLocale();
return ( return (
<html lang="ru"> <html lang={locale}>
<body className={cn(inter.className, 'flex min-h-screen flex-col bg-app-background')}> <body className={cn(inter.className, 'flex min-h-screen flex-col bg-app-background')}>
<ErrorProvider> <ErrorProvider>
<ThemeProvider> <I18nProvider>
<AuthProvider> <ThemeProvider>
<QueryProvider>{children}</QueryProvider> <AuthProvider>
</AuthProvider> <QueryProvider>{children}</QueryProvider>
</ThemeProvider> </AuthProvider>
</ThemeProvider>
</I18nProvider>
</ErrorProvider> </ErrorProvider>
</body> </body>
</html> </html>

View File

@ -1,27 +1,25 @@
'use client'; 'use client';
import { useCustomerMutation } from '@/hooks/api/customers'; import { useCustomerMutation } from '@/hooks/api/customers';
import { useClientOnce } from '@/hooks/telegram';
import { initData, useSignal } from '@telegram-apps/sdk-react'; import { initData, useSignal } from '@telegram-apps/sdk-react';
import { useEffect, useState } from 'react';
export function UpdateProfile() { export function UpdateProfile() {
const initDataUser = useSignal(initData.user); const initDataUser = useSignal(initData.user);
const { mutate: updateProfile } = useCustomerMutation(); const { mutate: updateProfile } = useCustomerMutation();
const [hasUpdated, setHasUpdated] = useState(false);
useClientOnce(() => { useEffect(() => {
if ( if (!hasUpdated) {
localStorage.getItem('firstLogin') === null ||
localStorage.getItem('firstLogin') === 'true'
) {
updateProfile({ updateProfile({
data: { data: {
active: true, active: true,
photoUrl: initDataUser?.photoUrl || undefined, photoUrl: initDataUser?.photoUrl || undefined,
}, },
}); });
localStorage.setItem('firstLogin', 'false'); setHasUpdated(true);
} }
}); }, [hasUpdated, initDataUser?.photoUrl, updateProfile]);
return null; return null;
} }

View File

@ -2,39 +2,21 @@
import { DataNotFound } from '../shared/alert'; import { DataNotFound } from '../shared/alert';
import { ContactRow } from '../shared/contact-row'; import { ContactRow } from '../shared/contact-row';
import { useContactsInfiniteQuery } from '@/hooks/api/customers'; import { useCustomerContacts } from '@/hooks/api/contacts';
import { Button } from '@repo/ui/components/ui/button';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function ContactsList() { export function ContactsList() {
const { const { contacts, isLoading } = useCustomerContacts();
data: { pages } = {},
fetchNextPage,
hasNextPage,
isLoading,
} = useContactsInfiniteQuery();
const contacts = pages?.flatMap((page) => page.customers); if (isLoading) return <LoadingSpinner />;
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
return ( return (
<div className="flex flex-col space-y-2"> <div className="space-y-2">
{isLoading && <LoadingSpinner />} {contacts.map((contact) => (
{!isLoading && !contacts?.length ? <DataNotFound title="Контакты не найдены" /> : null} <ContactRow key={contact.documentId} {...contact} />
{contacts?.map( ))}
(contact) =>
contact && (
<ContactRow
description={contact.services.map((service) => service?.name).join(', ')}
key={contact.documentId}
{...contact}
/>
),
)}
{hasNextPage && (
<Button onClick={() => fetchNextPage()} variant="ghost">
Загрузить еще
</Button>
)}
</div> </div>
); );
} }

View File

@ -13,8 +13,8 @@ import { use } from 'react';
const filterLabels: Record<FilterType, string> = { const filterLabels: Record<FilterType, string> = {
all: 'Все', all: 'Все',
invited: 'Приглашенные', clients: 'Клиенты',
invitedBy: 'Пригласили вас', masters: 'Мастера',
}; };
export function ContactsFilter() { export function ContactsFilter() {
@ -29,13 +29,9 @@ export function ContactsFilter() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setFilter('all')}>{filterLabels['all']}</DropdownMenuItem> <DropdownMenuItem onClick={() => setFilter('all')}>Все</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('invited')}> <DropdownMenuItem onClick={() => setFilter('clients')}>Клиенты</DropdownMenuItem>
{filterLabels['invited']} <DropdownMenuItem onClick={() => setFilter('masters')}>Мастера</DropdownMenuItem>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('invitedBy')}>
{filterLabels['invitedBy']}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -1,18 +0,0 @@
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { type PropsWithChildren } from 'react';
export function DocumentsLayout({
children,
title,
}: Readonly<PropsWithChildren> & { readonly title: string }) {
return (
<>
<PageHeader title={title} />
<Container className="prose prose-neutral md:prose-lg dark:prose-invert max-w-none">
{children}
</Container>
<div className="h-10" />
</>
);
}

View File

@ -1,17 +0,0 @@
import { env } from '@/config/env';
export function OfferLink() {
return (
<a href={env.URL_OFFER} rel="noreferrer" target="_blank">
{env.URL_OFFER}
</a>
);
}
export function SupportLink() {
return (
<a href={env.SUPPORT_TELEGRAM_URL} rel="noreferrer" target="_blank">
{env.SUPPORT_TELEGRAM_URL}
</a>
);
}

View File

@ -4,16 +4,12 @@ import { NavButton } from './nav-button';
import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react'; import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
const hideOn = ['/pro'];
export function BottomNav() { export function BottomNav() {
const pathname = usePathname(); const pathname = usePathname();
const isFirstLevel = pathname.split('/').length <= 2; const isFirstLevel = pathname.split('/').length <= 2;
if (!isFirstLevel) return null; if (!isFirstLevel) return null;
if (hideOn.includes(pathname)) return null;
return ( return (
<nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background"> <nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background">
<div className="grid grid-cols-5"> <div className="grid grid-cols-5">

View File

@ -1,5 +1,4 @@
'use client'; 'use client';
import { BackButton } from './back-button'; import { BackButton } from './back-button';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { isTMA } from '@telegram-apps/sdk-react'; import { isTMA } from '@telegram-apps/sdk-react';
@ -7,16 +6,16 @@ import { isTMA } from '@telegram-apps/sdk-react';
type Props = { title: string | undefined }; type Props = { title: string | undefined };
export function PageHeader(props: Readonly<Props>) { export function PageHeader(props: Readonly<Props>) {
const hideBackButton = process.env.NODE_ENV === 'production' || isTMA('simple'); const isTG = isTMA('simple');
return ( return (
<div <div
className={cn( className={cn(
'sticky top-0 z-50 flex h-12 items-center rounded-b-lg bg-transparent font-bold tracking-wide backdrop-blur-md', 'sticky top-0 z-50 flex h-12 items-center rounded-b-lg bg-transparent font-bold tracking-wide backdrop-blur-md',
hideBackButton ? 'px-4' : 'px-2', isTG ? 'px-4' : 'px-2',
)} )}
> >
{!hideBackButton && <BackButton />} {!isTG && <BackButton />}
{props.title} {props.title}
</div> </div>
); );

View File

@ -1,10 +1,9 @@
/* eslint-disable complexity */
/* eslint-disable canonical/id-match */ /* eslint-disable canonical/id-match */
'use client'; 'use client';
import FloatingActionPanel from '../shared/action-panel'; import FloatingActionPanel from '../shared/action-panel';
import { type OrderComponentProps } from './types'; import { type OrderComponentProps } from './types';
import { useCustomerQuery } from '@/hooks/api/customers'; import { useIsMaster } from '@/hooks/api/customers';
import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders'; import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders';
import { usePushWithData } from '@/hooks/url'; import { usePushWithData } from '@/hooks/url';
import { Enum_Order_State } from '@repo/graphql/types'; import { Enum_Order_State } from '@repo/graphql/types';
@ -13,16 +12,13 @@ import { isBeforeNow } from '@repo/utils/datetime-format';
export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) { export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const push = usePushWithData(); const push = usePushWithData();
const { data: { customer } = {} } = useCustomerQuery(); const isMaster = useIsMaster();
const { data: { order } = {} } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
const { isPending, mutate: updateOrder } = useOrderMutation({ documentId }); const { isPending, mutate: updateSlot } = useOrderMutation({ documentId });
if (!order || !customer) return null; if (!order) return null;
// Проверяем роль относительно конкретного заказа
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
const isOrderClient = order.client?.documentId === customer.documentId;
const isCreated = order?.state === Enum_Order_State.Created; const isCreated = order?.state === Enum_Order_State.Created;
const isApproved = order?.state === Enum_Order_State.Approved; const isApproved = order?.state === Enum_Order_State.Approved;
@ -31,22 +27,22 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const isCancelled = order?.state === Enum_Order_State.Cancelled; const isCancelled = order?.state === Enum_Order_State.Cancelled;
function handleApprove() { function handleApprove() {
if (isOrderMaster) { if (isMaster) {
updateOrder({ data: { state: Enum_Order_State.Approved } }); updateSlot({ data: { state: Enum_Order_State.Approved } });
} }
} }
function handleCancel() { function handleCancel() {
if (isOrderMaster) { if (isMaster) {
updateOrder({ data: { state: Enum_Order_State.Cancelled } }); updateSlot({ data: { state: Enum_Order_State.Cancelled } });
} else if (isOrderClient) { } else {
updateOrder({ data: { state: Enum_Order_State.Cancelling } }); updateSlot({ data: { state: Enum_Order_State.Cancelling } });
} }
} }
function handleOnComplete() { function handleOnComplete() {
if (isOrderMaster) { if (isMaster) {
updateOrder({ data: { state: Enum_Order_State.Completed } }); updateSlot({ data: { state: Enum_Order_State.Completed } });
} }
} }
@ -56,11 +52,11 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start); const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start);
const canCancel = !isOrderStale && (isCreated || (isOrderMaster && isCancelling) || isApproved); const canCancel = !isOrderStale && (isCreated || (isMaster && isCancelling) || isApproved);
const canComplete = isOrderMaster && isApproved; const canComplete = isMaster && isApproved;
const canConfirm = !isOrderStale && isOrderMaster && isCreated; const canConfirm = !isOrderStale && isMaster && isCreated;
const canRepeat = isCancelled || isCompleted; const canRepeat = isCancelled || isCompleted;
const canReturn = !isOrderStale && isOrderMaster && isCancelled; const canReturn = !isOrderStale && isMaster && isCancelled;
return ( return (
<FloatingActionPanel <FloatingActionPanel

View File

@ -1,32 +1,26 @@
'use client'; 'use client';
import { DataNotFound } from '../shared/alert';
import { ContactRow } from '../shared/contact-row'; import { ContactRow } from '../shared/contact-row';
import { type OrderComponentProps } from './types'; import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders'; import { useOrderQuery } from '@/hooks/api/orders';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) { export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
const noContacts = !order?.slot?.master && !order?.client; if (!order) return null;
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<h1 className="font-bold">Участники</h1> <h1 className="font-bold">Контакты</h1>
<div className="space-y-2"> <div className="space-y-2">
{isLoading && <LoadingSpinner />} {order.slot?.master && (
{!isLoading && noContacts ? <DataNotFound title="Пользователи не найдены" /> : null}
{order?.slot?.master && (
<ContactRow <ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5" className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
description="Мастер"
{...order.slot?.master} {...order.slot?.master}
/> />
)} )}
{order?.client && ( {order.client && (
<ContactRow <ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5" className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
description="Клиент"
{...order.client} {...order.client}
/> />
)} )}

View File

@ -6,16 +6,7 @@ import { useOrderQuery } from '@/hooks/api/orders';
import { formatDate } from '@repo/utils/datetime-format'; import { formatDate } from '@repo/utils/datetime-format';
export function OrderDateTime({ documentId }: Readonly<OrderComponentProps>) { export function OrderDateTime({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
if (isLoading) {
return (
<div className="flex animate-pulse flex-col space-y-1">
<div className="h-5 w-28 rounded bg-muted" />
<div className="h-9 w-48 rounded bg-muted" />
</div>
);
}
if (!order) return null; if (!order) return null;

View File

@ -1,172 +1,129 @@
/* eslint-disable canonical/id-match */
'use client'; 'use client';
import { DataNotFound } from '@/components/shared/alert';
import { UserAvatar } from '@/components/shared/user-avatar';
import { CardSectionHeader } from '@/components/ui'; import { CardSectionHeader } from '@/components/ui';
import { useContactsInfiniteQuery, useCustomerQuery } from '@/hooks/api/customers'; import { ContactsContextProvider } from '@/context/contacts';
import { useCustomerContacts } from '@/hooks/api/contacts';
// eslint-disable-next-line import/extensions
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { useOrderStore } from '@/stores/order'; import { useOrderStore } from '@/stores/order';
import { type CustomerFieldsFragment, Enum_Customer_Role } from '@repo/graphql/types'; import { withContext } from '@/utils/context';
import { Button } from '@repo/ui/components/ui/button'; import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { Card } from '@repo/ui/components/ui/card'; import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label'; import { Label } from '@repo/ui/components/ui/label';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer'; import Image from 'next/image';
import { sift } from 'radashi'; import { useEffect } from 'react';
type ContactsGridProps = { type ContactsGridProps = {
readonly contacts: CustomerFieldsFragment[]; readonly contacts: CustomerFieldsFragment[];
readonly hasNextPage?: boolean;
readonly isLoading?: boolean;
readonly onClick: () => void;
readonly onFetchNextPage?: () => void;
readonly onSelect: (contactId: null | string) => void; readonly onSelect: (contactId: null | string) => void;
readonly selected?: null | string; readonly selected?: null | string;
readonly title: string; readonly title: string;
}; };
type UseContactsProps = Partial<{ export function ContactsGridBase({ contacts, onSelect, selected, title }: ContactsGridProps) {
showInactive: boolean;
}>;
export function ClientsGrid() {
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts({ showInactive: true });
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
const masterId = useOrderStore((store) => store.masterId);
return (
<ContactsGridBase
contacts={contacts.filter((contact) => contact.documentId !== masterId)}
hasNextPage={Boolean(hasNextPage)}
isLoading={isLoading}
onClick={() => {
if (clientId) setClientId(null);
}}
onFetchNextPage={fetchNextPage}
onSelect={(contactId) => setClientId(contactId)}
selected={clientId}
title="Выбор клиента"
/>
);
}
export function ContactsGridBase({
contacts,
hasNextPage,
isLoading,
onClick,
onFetchNextPage,
onSelect,
selected,
title,
}: ContactsGridProps) {
const { data: { customer } = {} } = useCustomerQuery();
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<CardSectionHeader title={title} /> <CardSectionHeader title={title} />
{isLoading && <LoadingSpinner />}
{!isLoading && (!contacts || contacts.length === 0) ? (
<DataNotFound title="Контакты не найдены" />
) : null}
<div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto"> <div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto">
{!isLoading && {contacts.map((contact) => {
contacts?.map((contact) => { if (!contact) return null;
if (!contact) return null;
const isCurrentUser = contact.documentId === customer?.documentId; const isCurrentUser = contact?.name === 'Я';
return ( return (
<Label <Label
className="flex cursor-pointer flex-col items-center" className="flex cursor-pointer flex-col items-center"
key={contact.documentId} key={contact?.documentId}
>
<input
checked={selected === contact?.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact?.documentId)}
type="radio"
value={contact?.documentId}
/>
<div
className={cn(
'w-20 h-20 rounded-full border-2 transition-all duration-75',
selected === contact?.documentId ? 'border-primary' : 'border-transparent',
)}
> >
<input
checked={selected === contact.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact.documentId)}
onClick={onClick}
type="radio"
value={contact.documentId}
/>
<div <div
className={cn( className={cn(
'rounded-full border-2 transition-all duration-75', 'size-full rounded-full p-1',
selected === contact.documentId ? 'border-primary' : 'border-transparent', isCurrentUser
? 'bg-gradient-to-r from-purple-500 to-pink-500'
: 'bg-transparent',
)} )}
> >
<UserAvatar {...contact} size="md" /> <Image
alt={contact?.name}
className="size-full rounded-full object-cover"
height={80}
src={contact?.photoUrl || AvatarPlaceholder}
width={80}
/>
</div> </div>
<span </div>
className={cn( <span
'mt-2 max-w-20 break-words text-center text-sm font-medium', className={cn(
isCurrentUser && 'font-bold', 'mt-2 max-w-20 break-words text-center text-sm font-medium',
)} isCurrentUser && 'font-bold',
> )}
{getCustomerFullName(contact)} >
</span> {contact?.name}
</Label> </span>
); </Label>
})} );
})}
</div> </div>
{hasNextPage && onFetchNextPage && (
<Button onClick={onFetchNextPage} variant="ghost">
Загрузить еще
</Button>
)}
</div> </div>
</Card> </Card>
); );
} }
export function MastersGrid() { export const MastersGrid = withContext(ContactsContextProvider)(function () {
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts(); const { contacts, isLoading, setFilter } = useCustomerContacts();
const masterId = useOrderStore((store) => store.masterId); const masterId = useOrderStore((store) => store.masterId);
const setMasterId = useOrderStore((store) => store.setMasterId); const setMasterId = useOrderStore((store) => store.setMasterId);
const clientId = useOrderStore((store) => store.clientId);
useEffect(() => {
setFilter('masters');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
return ( return (
<ContactsGridBase <ContactsGridBase
contacts={contacts.filter( contacts={contacts}
(contact) => contact.documentId !== clientId && contact.role !== Enum_Customer_Role.Client,
)}
hasNextPage={Boolean(hasNextPage)}
isLoading={isLoading}
onClick={() => {
if (masterId) setMasterId(null);
}}
onFetchNextPage={fetchNextPage}
onSelect={(contactId) => setMasterId(contactId)} onSelect={(contactId) => setMasterId(contactId)}
selected={masterId} selected={masterId}
title="Выбор мастера" title="Мастера"
/> />
); );
} });
function useContacts({ showInactive = false }: UseContactsProps = {}) { export const ClientsGrid = withContext(ContactsContextProvider)(function () {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery(); const { contacts, isLoading, setFilter } = useCustomerContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
const { useEffect(() => {
data: { pages } = { pages: [] }, setFilter('clients');
isLoading: isLoadingContacts, }, [setFilter]);
...query
} = useContactsInfiniteQuery();
const isLoading = isLoadingContacts || isLoadingCustomer; if (isLoading) return <LoadingSpinner />;
const contacts = sift(pages.flatMap((page) => page.customers)); return (
<ContactsGridBase
return { contacts={contacts}
isLoading, onSelect={(contactId) => setClientId(contactId)}
...query, selected={clientId}
contacts: [{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment].concat( title="Клиенты"
showInactive ? contacts : contacts.filter((contact) => contact.active), />
), );
}; });
}

View File

@ -6,13 +6,12 @@ import { ServiceCard } from '@/components/shared/service-card';
import { useServicesQuery } from '@/hooks/api/services'; import { useServicesQuery } from '@/hooks/api/services';
import { useOrderStore } from '@/stores/order'; import { useOrderStore } from '@/stores/order';
import { type ServiceFieldsFragment } from '@repo/graphql/types'; import { type ServiceFieldsFragment } from '@repo/graphql/types';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
export function ServicesSelect() { export function ServicesSelect() {
const masterId = useOrderStore((store) => store.masterId); const masterId = useOrderStore((store) => store.masterId);
const { data: { services } = {}, isLoading } = useServicesQuery({ const { data: { services } = {} } = useServicesQuery({
filters: { filters: {
active: { active: {
eq: true, eq: true,
@ -25,8 +24,6 @@ export function ServicesSelect() {
}, },
}); });
if (isLoading) return <LoadingSpinner />;
if (!services?.length) return <DataNotFound title="Услуги не найдены" />; if (!services?.length) return <DataNotFound title="Услуги не найдены" />;
return ( return (

View File

@ -1,20 +1,18 @@
'use client'; 'use client';
import { DataNotFound } from '../shared/alert';
import { ServiceCard } from '../shared/service-card'; import { ServiceCard } from '../shared/service-card';
import { type OrderComponentProps } from './types'; import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders'; import { useOrderQuery } from '@/hooks/api/orders';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function OrderServices({ documentId }: Readonly<OrderComponentProps>) { export function OrderServices({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
if (!order) return null;
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<h1 className="font-bold">Услуги</h1> <h1 className="font-bold">Услуги</h1>
{isLoading && <LoadingSpinner />} {order.services?.map(
{!isLoading && !order?.services?.length ? <DataNotFound title="Услуги не найдены" /> : null}
{order?.services?.map(
(service) => service && <ServiceCard key={service.documentId} {...service} />, (service) => service && <ServiceCard key={service.documentId} {...service} />,
)} )}
</div> </div>

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