Compare commits
64 Commits
feature/pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ecf72656b | ||
|
|
241effd3b8 | ||
|
|
64c9134cc2 | ||
|
|
19b53db5f3 | ||
|
|
a26c0eab8a | ||
|
|
3ac86cfeb0 | ||
|
|
d895433a65 | ||
|
|
3064887ecf | ||
|
|
02e9d5c529 | ||
|
|
f45140ef04 | ||
|
|
bdcd11d97e | ||
|
|
6a0d34d37b | ||
|
|
2df80c90f6 | ||
|
|
03145534a1 | ||
|
|
6a21f5911e | ||
|
|
b88ac07ba3 | ||
|
|
b517519e7e | ||
|
|
a1af00af69 | ||
|
|
311f6c183d | ||
|
|
67cf9a8e26 | ||
|
|
6080aadc93 | ||
|
|
ee4e70f43d | ||
|
|
4d40230864 | ||
|
|
92119baa5e | ||
|
|
1e9fd66e27 | ||
|
|
aa11ecfcec | ||
|
|
8e61fbbb40 | ||
|
|
d32a7dc54e | ||
|
|
0a43d63c2c | ||
|
|
01d8bb20d5 | ||
|
|
20b2b44572 | ||
|
|
7fe1b89f5b | ||
|
|
4a98ac5d3e | ||
|
|
f8d0b7619f | ||
|
|
0b64d8086c | ||
|
|
458a06a620 | ||
|
|
c7648e8bf9 | ||
|
|
9244eaec26 | ||
|
|
d109d50120 | ||
|
|
8aaae245a7 | ||
|
|
c4b76a4755 | ||
|
|
cb4763e32b | ||
|
|
2836153887 | ||
|
|
1c669f04dd | ||
|
|
047a9b1956 | ||
|
|
d9e67bf4ba | ||
|
|
80d29af1b4 | ||
|
|
d191be03e8 | ||
|
|
900cfe2cc2 | ||
|
|
1f168df095 | ||
|
|
3e0ac818f2 | ||
|
|
29ecc47822 | ||
|
|
86f0d87c31 | ||
|
|
a9fd9808ec | ||
|
|
31adf7e7b3 | ||
|
|
ec3c2869c1 | ||
|
|
24aabae434 | ||
|
|
81e0168e44 | ||
|
|
eb0ad25c3c | ||
|
|
7c7ddcf0d5 | ||
|
|
106fdc0da5 | ||
|
|
a669e1846e | ||
|
|
8092c7fecc | ||
|
|
363fce4499 |
124
.github/workflows/deploy.yml
vendored
124
.github/workflows/deploy.yml
vendored
@ -1,9 +1,10 @@
|
|||||||
name: Build & Deploy Web & Bot
|
name: Build & Deploy Web, Bot & Cache Proxy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@ -12,11 +13,31 @@ 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
|
||||||
|
|
||||||
- name: Create fake .env file for build
|
# --- НОВОЕ: Шаг 1: dorny/paths-filter для условной сборки ---
|
||||||
|
- name: Filter changed paths
|
||||||
|
uses: dorny/paths-filter@v2
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
web:
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
bot:
|
||||||
|
- 'apps/bot/**'
|
||||||
|
- 'packages/**'
|
||||||
|
cache_proxy:
|
||||||
|
- 'apps/cache-proxy/**'
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
- name: Create .env file for build
|
||||||
run: |
|
run: |
|
||||||
echo "BOT_TOKEN=fake" > .env
|
echo "BOT_TOKEN=fake" > .env
|
||||||
echo "LOGIN_GRAPHQL=fake" >> .env
|
echo "LOGIN_GRAPHQL=fake" >> .env
|
||||||
@ -27,32 +48,53 @@ jobs:
|
|||||||
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 "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
|
||||||
@ -73,8 +115,10 @@ 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"
|
||||||
|
|
||||||
- name: Create real .env file for production
|
# --- НОВОЕ: Шаг 2: Создание основного .env БЕЗ ТЕГОВ ---
|
||||||
|
- 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
|
||||||
@ -82,13 +126,26 @@ 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 "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
|
||||||
|
|
||||||
- name: Copy .env to VPS via SCP
|
# --- НОВОЕ: Шаг 3: Создание файлов тегов (.project.env) ---
|
||||||
|
- name: Create Project Tag Env Files
|
||||||
|
run: |
|
||||||
|
# Создаем файлы, которые будут содержать только одну переменную с тегом
|
||||||
|
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" > .env.web
|
||||||
|
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" > .env.bot
|
||||||
|
echo "CACHE_PROXY_IMAGE_TAG=${{ needs.build-and-push.outputs.cache_proxy_tag }}" > .env.cache-proxy
|
||||||
|
|
||||||
|
# --- Шаг 4: Копирование .env и УСЛОВНОЕ копирование тегов ---
|
||||||
|
|
||||||
|
# Копируем основной .env всегда
|
||||||
|
- name: Copy .env to VPS via SCP (Always)
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@master
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.VPS_HOST }}
|
host: ${{ secrets.VPS_HOST }}
|
||||||
@ -98,6 +155,42 @@ 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:
|
||||||
@ -108,12 +201,27 @@ 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 }} && \
|
|
||||||
docker compose pull && \
|
# 1. Объединение ВСЕХ ENV-файлов в один основной .env
|
||||||
|
# Теги из .env.web/.env.bot переопределят любые старые/пустые значения,
|
||||||
|
# и .env станет полным и актуальным.
|
||||||
|
echo \"Merging environment files into .env...\" && \
|
||||||
|
cat .env .env.web .env.bot .env.cache-proxy > .temp_env && \
|
||||||
|
mv .temp_env .env && \
|
||||||
|
|
||||||
|
# 2. Логин
|
||||||
|
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# 3. Pull ВСЕХ сервисов (Docker Compose автоматически использует обновленный .env)
|
||||||
|
echo \"Pulling all services...\" && \
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# 4. Перезапуск
|
||||||
docker compose down && \
|
docker compose down && \
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
"
|
"
|
||||||
|
|||||||
@ -25,7 +25,7 @@ description =
|
|||||||
start =
|
start =
|
||||||
.description = Запуск бота
|
.description = Запуск бота
|
||||||
addcontact =
|
addcontact =
|
||||||
.description = Добавить контакт клиента
|
.description = Добавить контакт
|
||||||
sharebot =
|
sharebot =
|
||||||
.description = Поделиться ботом
|
.description = Поделиться ботом
|
||||||
subscribe =
|
subscribe =
|
||||||
@ -36,7 +36,7 @@ help =
|
|||||||
.description = Список команд и поддержка
|
.description = Список команд и поддержка
|
||||||
commands-list =
|
commands-list =
|
||||||
📋 Доступные команды:
|
📋 Доступные команды:
|
||||||
• /addcontact — добавить контакт клиента
|
• /addcontact — добавить контакт
|
||||||
• /sharebot — поделиться ботом
|
• /sharebot — поделиться ботом
|
||||||
• /subscribe — приобрести Pro доступ
|
• /subscribe — приобрести Pro доступ
|
||||||
• /pro — информация о вашем Pro доступе
|
• /pro — информация о вашем Pro доступе
|
||||||
@ -45,6 +45,35 @@ commands-list =
|
|||||||
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
|
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
|
||||||
support =
|
support =
|
||||||
{ -support-contact }
|
{ -support-contact }
|
||||||
|
documents =
|
||||||
|
.description = Документы
|
||||||
|
|
||||||
|
# Кнопки
|
||||||
|
btn-add-contact = 👤 Добавить контакт
|
||||||
|
btn-share-bot = 🤝 Поделиться ботом
|
||||||
|
btn-pro = 👑 Pro доступ
|
||||||
|
btn-subscribe = 👑 Приобрести Pro
|
||||||
|
btn-pro-info = ℹ️ Мой Pro доступ
|
||||||
|
btn-open-app = 📱 Открыть приложение
|
||||||
|
btn-faq = 📖 Инструкция
|
||||||
|
btn-documents = 📋 Документы
|
||||||
|
btn-back = ◀️ Назад
|
||||||
|
|
||||||
|
|
||||||
|
# Согласие
|
||||||
|
share-phone-agreement =
|
||||||
|
<i> Нажимая кнопку <b>«Отправить номер телефона»</b></i>,
|
||||||
|
<i>вы:
|
||||||
|
- соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
|
||||||
|
- подтверждаете согласие на обработку персональных данных согласно <a href='{ $privacyUrl }'>Политике конфиденциальности</a></i>
|
||||||
|
share-contact-agreement =
|
||||||
|
<i> Отправляя контакт, имя и номер телефона, вы подтверждаете, что имеете согласие этого человека на передачу его контактных данных и на их обработку в рамках нашего сервиса.
|
||||||
|
(Пункт 4.5 <a href='{ $privacyUrl }'>Политики конфиденциальности</a>)</i>
|
||||||
|
payment-agreement =
|
||||||
|
Совершая оплату, вы соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
|
||||||
|
agreement-links =
|
||||||
|
<a href='{ $offerUrl }'>Публичная оферта</a>
|
||||||
|
<a href='{ $privacyUrl }'>Политика конфиденциальности</a>
|
||||||
|
|
||||||
|
|
||||||
# Приветственные сообщения
|
# Приветственные сообщения
|
||||||
@ -55,24 +84,28 @@ msg-welcome-back = 👋 С возвращением, { $name }!
|
|||||||
|
|
||||||
|
|
||||||
# Сообщения о телефоне
|
# Сообщения о телефоне
|
||||||
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
|
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона.
|
||||||
msg-phone-saved =
|
msg-phone-saved =
|
||||||
✅ Спасибо! Мы сохранили ваш номер телефона
|
✅ Спасибо! Мы сохранили ваш номер телефона
|
||||||
Теперь вы можете открыть приложение или воспользоваться командами бота
|
Теперь вы можете открыть приложение или воспользоваться командами бота
|
||||||
msg-already-registered =
|
msg-already-registered =
|
||||||
✅ Вы уже зарегистрированы в системе
|
✅ Вы уже зарегистрированы в системе
|
||||||
|
|
||||||
Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
|
<i>Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)</i>
|
||||||
msg-invalid-phone = ❌ Некорректный номер телефона
|
msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999
|
||||||
|
|
||||||
# Сообщения о контактах
|
# Сообщения о контактах
|
||||||
msg-send-client-contact = 👤 Отправьте контакт клиента, которого вы хотите добавить.
|
msg-send-client-contact = 👤 Отправьте контакт пользователя, которого вы хотите добавить.
|
||||||
msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
|
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении
|
||||||
|
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram
|
||||||
|
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
|
||||||
|
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
|
||||||
|
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
|
||||||
msg-contact-added =
|
msg-contact-added =
|
||||||
✅ Добавили { $name } в список ваших клиентов
|
✅ Добавили { $fullname } в список ваших контактов
|
||||||
|
|
||||||
Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи
|
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи
|
||||||
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
|
msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i>
|
||||||
|
|
||||||
# Сообщения для шаринга
|
# Сообщения для шаринга
|
||||||
msg-share-bot =
|
msg-share-bot =
|
||||||
@ -83,7 +116,7 @@ msg-share-bot =
|
|||||||
# Системные сообщения
|
# Системные сообщения
|
||||||
msg-cancel = ❌ Операция отменена
|
msg-cancel = ❌ Операция отменена
|
||||||
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
|
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
|
||||||
msg-cancel-operation = Для отмены операции используйте команду /cancel
|
msg-cancel-operation = <i>Для отмены операции используйте команду /cancel</i>
|
||||||
|
|
||||||
# Ошибки
|
# Ошибки
|
||||||
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
||||||
@ -91,6 +124,8 @@ 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 = ❌ Нельзя добавить свой номер телефона как контакт
|
||||||
|
|
||||||
|
|
||||||
# Сообщения о доступе
|
# Сообщения о доступе
|
||||||
@ -99,12 +134,15 @@ msg-subscribe =
|
|||||||
• Разблокирует неограниченное количество заказов
|
• Разблокирует неограниченное количество заказов
|
||||||
msg-subscribe-success = ✅ Платеж успешно обработан!
|
msg-subscribe-success = ✅ Платеж успешно обработан!
|
||||||
msg-subscribe-error = ❌ Произошла ошибка при обработке платежа
|
msg-subscribe-error = ❌ Произошла ошибка при обработке платежа
|
||||||
|
msg-subscription-inactive = 🔴 Pro доступ неактивен
|
||||||
|
msg-subscription-active = 🟢 Ваш Pro доступ активен
|
||||||
msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date }
|
msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date }
|
||||||
msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days }
|
msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days }
|
||||||
|
msg-subscription-active-days-short = Осталось дней: { $days }
|
||||||
msg-subscription-expired =
|
msg-subscription-expired =
|
||||||
Ваш Pro доступ истек.
|
Ваш Pro доступ истек.
|
||||||
Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов
|
Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов
|
||||||
msg-subscribe-disabled = 🚫 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉
|
msg-subscribe-disabled = 🟢 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉
|
||||||
|
|
||||||
# Информация о лимитах
|
# Информация о лимитах
|
||||||
msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count }
|
msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count }
|
||||||
@ -17,6 +17,7 @@
|
|||||||
"@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",
|
||||||
@ -28,6 +29,7 @@
|
|||||||
"dayjs": "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:",
|
"radashi": "catalog:",
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
/* eslint-disable id-length */
|
/* eslint-disable id-length */
|
||||||
import { type Context } from '@/bot/context';
|
import { type Context } from '@/bot/context';
|
||||||
import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
|
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 { combine } from '@/utils/messages';
|
||||||
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
|
|
||||||
import { type Conversation } from '@grammyjs/conversations';
|
import { type Conversation } from '@grammyjs/conversations';
|
||||||
import { CustomersService } from '@repo/graphql/api/customers';
|
import { CustomersService } from '@repo/graphql/api/customers';
|
||||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||||
|
import parsePhoneNumber from 'libphonenumber-js';
|
||||||
|
|
||||||
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
|
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
|
||||||
// Все пользователи могут добавлять контакты
|
// Все пользователи могут добавлять контакты
|
||||||
@ -14,49 +17,115 @@ export async function addContact(conversation: Conversation<Context, Context>, c
|
|||||||
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerService = new CustomersService({ telegramId });
|
const registrationService = new RegistrationService();
|
||||||
const { customer } = await customerService.getCustomer({ telegramId });
|
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
return ctx.reply(
|
return ctx.reply(
|
||||||
await conversation.external(({ t }) => t('msg-need-phone')),
|
await conversation.external(({ t }) =>
|
||||||
KEYBOARD_SHARE_PHONE,
|
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 ctx.reply(
|
||||||
await conversation.external(({ t }) =>
|
await conversation.external(({ t }) =>
|
||||||
combine(t('msg-send-client-contact'), t('msg-cancel-operation')),
|
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 waitCtx = await conversation.wait();
|
const firstCtx = await conversation.wait();
|
||||||
|
|
||||||
// Проверяем, что отправлен контакт
|
let name = '';
|
||||||
if (!waitCtx.message?.contact) {
|
let surname = '';
|
||||||
return ctx.reply(await conversation.external(({ t }) => t('msg-send-contact')));
|
let phone = '';
|
||||||
}
|
|
||||||
|
|
||||||
const { contact } = waitCtx.message;
|
if (firstCtx.message?.contact) {
|
||||||
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
/**
|
||||||
const phone = normalizePhoneNumber(contact.phone_number);
|
* Отправлен контакт
|
||||||
|
*/
|
||||||
|
const { contact } = firstCtx.message;
|
||||||
|
const parsedContact = parseContact(contact);
|
||||||
|
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
|
||||||
|
|
||||||
// Проверяем валидность номера телефона
|
name = parsedContact.name;
|
||||||
if (!isValidPhoneNumber(phone)) {
|
surname = parsedContact.surname;
|
||||||
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
|
|
||||||
|
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 {
|
try {
|
||||||
// Проверяем, есть ли клиент с таким номером
|
// Проверяем, есть ли клиент с таким номером
|
||||||
const { customer: existingCustomer } = await customerService.getCustomer({ phone });
|
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
|
||||||
|
phone,
|
||||||
|
});
|
||||||
let documentId = existingCustomer?.documentId;
|
let documentId = existingCustomer?.documentId;
|
||||||
|
|
||||||
// Если клиента нет, создаём нового
|
// Если клиента нет, создаём нового
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
const registrationService = new RegistrationService();
|
const createCustomerResult = await registrationService.createCustomer({
|
||||||
const createCustomerResult = await registrationService.createCustomer({ name, phone });
|
data: { name, phone, surname },
|
||||||
|
});
|
||||||
|
|
||||||
documentId = createCustomerResult?.createCustomer?.documentId;
|
documentId = createCustomerResult?.createCustomer?.documentId;
|
||||||
if (!documentId) throw new Error('Клиент не создан');
|
if (!documentId) throw new Error('Клиент не создан');
|
||||||
@ -64,19 +133,24 @@ export async function addContact(conversation: Conversation<Context, Context>, c
|
|||||||
|
|
||||||
// Добавляем текущего пользователя к приглашенному
|
// Добавляем текущего пользователя к приглашенному
|
||||||
const invitedBy = [customer.documentId];
|
const invitedBy = [customer.documentId];
|
||||||
|
const customerService = new CustomersService({ telegramId });
|
||||||
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
|
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
|
||||||
|
|
||||||
// Отправляем подтверждения и инструкции
|
// Отправляем подтверждения и инструкции
|
||||||
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
|
await ctx.reply(
|
||||||
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')));
|
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);
|
await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
await conversation.external(({ t }) => t('err-with-details', { error: String(error) })),
|
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);
|
return conversation.halt();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { formatMoney } from '@/utils/format';
|
|||||||
import { combine } from '@/utils/messages';
|
import { combine } from '@/utils/messages';
|
||||||
import { type Conversation } from '@grammyjs/conversations';
|
import { type Conversation } from '@grammyjs/conversations';
|
||||||
import { fmt, i } from '@grammyjs/parse-mode';
|
import { fmt, i } from '@grammyjs/parse-mode';
|
||||||
|
import { CustomersService } from '@repo/graphql/api/customers';
|
||||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||||
import * as GQL from '@repo/graphql/types';
|
import * as GQL from '@repo/graphql/types';
|
||||||
import { InlineKeyboard } from 'grammy';
|
import { InlineKeyboard } from 'grammy';
|
||||||
@ -22,7 +23,6 @@ export async function subscription(conversation: Conversation<Context, Context>,
|
|||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
remainingDays,
|
remainingDays,
|
||||||
subscription: currentSubscription,
|
subscription: currentSubscription,
|
||||||
usedTrialSubscription,
|
|
||||||
} = await subscriptionsService.getSubscription({
|
} = await subscriptionsService.getSubscription({
|
||||||
telegramId,
|
telegramId,
|
||||||
});
|
});
|
||||||
@ -33,7 +33,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
|
|||||||
eq: true,
|
eq: true,
|
||||||
},
|
},
|
||||||
period: {
|
period: {
|
||||||
ne: usedTrialSubscription ? GQL.Enum_Subscriptionprice_Period.Trial : undefined,
|
ne: GQL.Enum_Subscriptionprice_Period.Trial,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -41,7 +41,11 @@ export async function subscription(conversation: Conversation<Context, Context>,
|
|||||||
const prices = sift(subscriptionPrices);
|
const prices = sift(subscriptionPrices);
|
||||||
|
|
||||||
// строим клавиатуру с указанием даты окончания после покупки
|
// строим клавиатуру с указанием даты окончания после покупки
|
||||||
const keyboard = buildPricesKeyboard(prices, currentSubscription?.expiresAt);
|
const keyboard = buildPricesKeyboard(
|
||||||
|
prices,
|
||||||
|
currentSubscription?.expiresAt,
|
||||||
|
hasActiveSubscription,
|
||||||
|
);
|
||||||
|
|
||||||
// сообщение с выбором плана
|
// сообщение с выбором плана
|
||||||
const messageWithPrices = await ctx.reply(
|
const messageWithPrices = await ctx.reply(
|
||||||
@ -63,7 +67,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
|
|||||||
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
|
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ reply_markup: keyboard },
|
{ parse_mode: 'HTML', reply_markup: keyboard },
|
||||||
);
|
);
|
||||||
|
|
||||||
// ждём выбора
|
// ждём выбора
|
||||||
@ -92,6 +96,21 @@ export async function subscription(conversation: Conversation<Context, Context>,
|
|||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
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(
|
return ctx.replyWithInvoice(
|
||||||
'Оплата Pro доступа',
|
'Оплата Pro доступа',
|
||||||
combine(
|
combine(
|
||||||
@ -107,6 +126,28 @@ export async function subscription(conversation: Conversation<Context, Context>,
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
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,
|
provider_token: env.BOT_PROVIDER_TOKEN,
|
||||||
start_parameter: 'get_access',
|
start_parameter: 'get_access',
|
||||||
},
|
},
|
||||||
@ -124,6 +165,7 @@ function addDays(date: Date, days: number) {
|
|||||||
function buildPricesKeyboard(
|
function buildPricesKeyboard(
|
||||||
prices: GQL.SubscriptionPriceFieldsFragment[],
|
prices: GQL.SubscriptionPriceFieldsFragment[],
|
||||||
currentExpiresAt?: string,
|
currentExpiresAt?: string,
|
||||||
|
hasActiveSubscription = false,
|
||||||
) {
|
) {
|
||||||
const keyboard = new InlineKeyboard();
|
const keyboard = new InlineKeyboard();
|
||||||
const baseTime = currentExpiresAt
|
const baseTime = currentExpiresAt
|
||||||
@ -139,7 +181,7 @@ function buildPricesKeyboard(
|
|||||||
keyboard.row({
|
keyboard.row({
|
||||||
callback_data: price.period,
|
callback_data: price.period,
|
||||||
pay: true,
|
pay: true,
|
||||||
text: `Продлить до ${targetDateRu} (${formatMoney(price.amount)})`,
|
text: `${hasActiveSubscription ? 'Продлить' : 'Доступ'} до ${targetDateRu} (${formatMoney(price.amount)})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { handleAddContact } from '../handlers/add-contact';
|
||||||
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 { Composer } from 'grammy';
|
import { Composer } from 'grammy';
|
||||||
@ -6,8 +7,6 @@ const composer = new Composer<Context>();
|
|||||||
|
|
||||||
const feature = composer.chatType('private');
|
const feature = composer.chatType('private');
|
||||||
|
|
||||||
feature.command('addcontact', logHandle('command-add-contact'), async (ctx) => {
|
feature.command('addcontact', logHandle('command-add-contact'), handleAddContact);
|
||||||
await ctx.conversation.enter('addContact');
|
|
||||||
});
|
|
||||||
|
|
||||||
export { composer as addContact };
|
export { composer as addContact };
|
||||||
|
|||||||
12
apps/bot/src/bot/features/documents.ts
Normal file
12
apps/bot/src/bot/features/documents.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { handleDocuments } from '../handlers/documents';
|
||||||
|
import { type Context } from '@/bot/context';
|
||||||
|
import { logHandle } from '@/bot/helpers/logging';
|
||||||
|
import { Composer } from 'grammy';
|
||||||
|
|
||||||
|
const composer = new Composer<Context>();
|
||||||
|
|
||||||
|
const feature = composer.chatType('private');
|
||||||
|
|
||||||
|
feature.command('documents', logHandle('command-documents'), handleDocuments);
|
||||||
|
|
||||||
|
export { composer as documents };
|
||||||
@ -1,7 +1,6 @@
|
|||||||
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 } from '@/config/keyboards';
|
import { mainMenu } 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>();
|
||||||
@ -9,10 +8,7 @@ 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(combine(ctx.t('commands-list'), ctx.t('support')), {
|
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
|
||||||
...KEYBOARD_REMOVE,
|
|
||||||
parse_mode: 'HTML',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { composer as help };
|
export { composer as help };
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export * from './add-contact';
|
export * from './add-contact';
|
||||||
|
export * from './documents';
|
||||||
export * from './help';
|
export * from './help';
|
||||||
export * from './pro';
|
export * from './pro';
|
||||||
export * from './registration';
|
export * from './registration';
|
||||||
|
|||||||
@ -1,39 +1,11 @@
|
|||||||
|
import { handlePro } from '../handlers/pro';
|
||||||
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 { combine } from '@/utils/messages';
|
|
||||||
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
|
||||||
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('pro', logHandle('command-pro'), async (ctx) => {
|
feature.command('pro', logHandle('command-pro'), handlePro);
|
||||||
const telegramId = ctx.from.id;
|
|
||||||
const subscriptionsService = new SubscriptionsService({ telegramId });
|
|
||||||
|
|
||||||
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
|
||||||
const proEnabled = subscriptionSetting?.proEnabled;
|
|
||||||
|
|
||||||
if (!proEnabled) return ctx.reply(ctx.t('msg-subscribe-disabled'));
|
|
||||||
|
|
||||||
const { hasActiveSubscription, remainingDays, remainingOrdersCount } =
|
|
||||||
await subscriptionsService.getSubscription({ telegramId });
|
|
||||||
|
|
||||||
if (hasActiveSubscription && remainingDays > 0) {
|
|
||||||
return ctx.reply(
|
|
||||||
combine(
|
|
||||||
ctx.t('msg-subscription-active-days', { days: remainingDays }),
|
|
||||||
remainingDays === 0 ? ctx.t('msg-subscription-expired') : '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.reply(
|
|
||||||
combine(
|
|
||||||
ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }),
|
|
||||||
remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { composer as pro };
|
export { composer as pro };
|
||||||
|
|||||||
@ -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 } from '@/config/keyboards';
|
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards';
|
||||||
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
|
import { parseContact } from '@/utils/contact';
|
||||||
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,11 +14,13 @@ 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 = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
const { name, surname } = parseContact(contact);
|
||||||
|
|
||||||
// Проверяем, не зарегистрирован ли уже пользователь
|
// Проверяем, не зарегистрирован ли уже пользователь
|
||||||
const customerService = new CustomersService({ telegramId });
|
const registrationService = new RegistrationService();
|
||||||
const { customer: existingCustomer } = await customerService.getCustomer({ telegramId });
|
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
|
||||||
|
telegramId,
|
||||||
|
});
|
||||||
|
|
||||||
if (existingCustomer) {
|
if (existingCustomer) {
|
||||||
return ctx.reply(ctx.t('msg-already-registered'), {
|
return ctx.reply(ctx.t('msg-already-registered'), {
|
||||||
@ -33,31 +35,32 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Нормализация и валидация номера
|
// Нормализация и валидация номера
|
||||||
const phone = normalizePhoneNumber(contact.phone_number);
|
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
|
||||||
if (!isValidPhoneNumber(phone)) {
|
if (!parsedPhone?.isValid() || !parsedPhone?.number) {
|
||||||
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.getCustomer({ phone });
|
const { customer } = await registrationService._NOCACHE_GetCustomer({
|
||||||
|
phone: parsedPhone.number,
|
||||||
|
});
|
||||||
|
|
||||||
if (customer && !customer.telegramId) {
|
if (customer && !customer.telegramId) {
|
||||||
// Пользователь добавлен ранее мастером — обновляем данные
|
// Пользователь добавлен ранее мастером — обновляем данные
|
||||||
await registrationService.updateCustomer({
|
await registrationService.updateCustomer({
|
||||||
data: { active: true, name, telegramId },
|
data: { active: true, name, surname, telegramId },
|
||||||
documentId: customer.documentId,
|
documentId: customer.documentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.reply(ctx.t('msg-phone-saved') + '\n\n' + ctx.t('commands-list'), {
|
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE });
|
||||||
...KEYBOARD_REMOVE,
|
|
||||||
parse_mode: 'HTML',
|
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Новый пользователь — создаём и активируем
|
// Новый пользователь — создаём и активируем
|
||||||
const response = await registrationService.createCustomer({ name, phone, telegramId });
|
const response = await registrationService.createCustomer({
|
||||||
|
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'));
|
||||||
@ -67,10 +70,9 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
|
|||||||
documentId,
|
documentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.reply(ctx.t('msg-phone-saved') + '\n\n' + ctx.t('commands-list'), {
|
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE });
|
||||||
...KEYBOARD_REMOVE,
|
|
||||||
parse_mode: 'HTML',
|
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
|
||||||
});
|
|
||||||
} 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) }));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
|
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'), async (ctx) => {
|
feature.command('sharebot', logHandle('command-share-bot'), handleShareBot);
|
||||||
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 };
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { handleSubscribe } from '../handlers/subscription';
|
||||||
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 { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
@ -8,25 +9,13 @@ const composer = new Composer<Context>();
|
|||||||
|
|
||||||
// Telegram требует отвечать на pre_checkout_query
|
// Telegram требует отвечать на pre_checkout_query
|
||||||
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
|
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
|
||||||
console.log('🚀 ~ ctx:', ctx);
|
|
||||||
await ctx.answerPreCheckoutQuery(true);
|
await ctx.answerPreCheckoutQuery(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const feature = composer.chatType('private');
|
const feature = composer.chatType('private');
|
||||||
|
|
||||||
// команда для входа в flow подписки
|
// команда для входа в flow подписки
|
||||||
feature.command('subscribe', logHandle('command-subscribe'), async (ctx) => {
|
feature.command('subscribe', logHandle('command-subscribe'), handleSubscribe);
|
||||||
const telegramId = ctx.from.id;
|
|
||||||
const subscriptionsService = new SubscriptionsService({ telegramId });
|
|
||||||
|
|
||||||
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
|
||||||
|
|
||||||
const proEnabled = subscriptionSetting?.proEnabled;
|
|
||||||
|
|
||||||
if (!proEnabled) return ctx.reply(ctx.t('msg-subscribe-disabled'));
|
|
||||||
|
|
||||||
return ctx.conversation.enter('subscription');
|
|
||||||
});
|
|
||||||
|
|
||||||
// успешная оплата
|
// успешная оплата
|
||||||
feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
|
feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
|
||||||
@ -39,7 +28,12 @@ feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) =
|
|||||||
|
|
||||||
const payload = JSON.parse(rawPayload);
|
const payload = JSON.parse(rawPayload);
|
||||||
|
|
||||||
const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(payload);
|
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-subscribe-success'));
|
||||||
await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate }));
|
await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate }));
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
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_PHONE } from '@/config/keyboards';
|
import { env } from '@/config/env';
|
||||||
|
import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards';
|
||||||
import { combine } from '@/utils/messages';
|
import { combine } from '@/utils/messages';
|
||||||
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>();
|
||||||
@ -12,22 +13,30 @@ 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 customerService = new CustomersService({ telegramId });
|
const registrationService = new RegistrationService();
|
||||||
const { customer } = await customerService.getCustomer({ telegramId });
|
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
|
||||||
|
|
||||||
if (customer) {
|
if (customer) {
|
||||||
// Пользователь уже зарегистрирован — приветствуем
|
// Пользователь уже зарегистрирован — приветствуем
|
||||||
return ctx.reply(
|
return ctx.reply(ctx.t('msg-welcome-back', { name: customer.name }), {
|
||||||
combine(ctx.t('msg-welcome-back', { name: customer.name }), ctx.t('commands-list')),
|
reply_markup: mainMenu,
|
||||||
{
|
});
|
||||||
...KEYBOARD_REMOVE,
|
|
||||||
parse_mode: 'HTML',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Новый пользователь — просим поделиться номером
|
// Новый пользователь — просим поделиться номером
|
||||||
return ctx.reply(ctx.t('msg-welcome'), { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
|
return ctx.reply(
|
||||||
|
combine(
|
||||||
|
ctx.t('msg-welcome'),
|
||||||
|
ctx.t('share-phone-agreement', {
|
||||||
|
offerUrl: env.URL_OFFER,
|
||||||
|
privacyUrl: env.URL_PRIVACY,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...KEYBOARD_SHARE_PHONE,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export { composer as welcome };
|
export { composer as welcome };
|
||||||
|
|||||||
7
apps/bot/src/bot/handlers/add-contact.ts
Normal file
7
apps/bot/src/bot/handlers/add-contact.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { type Context } from '@/bot/context';
|
||||||
|
|
||||||
|
async function handler(ctx: Context) {
|
||||||
|
await ctx.conversation.enter('addContact');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler as handleAddContact };
|
||||||
18
apps/bot/src/bot/handlers/documents.ts
Normal file
18
apps/bot/src/bot/handlers/documents.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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 };
|
||||||
5
apps/bot/src/bot/handlers/index.ts
Normal file
5
apps/bot/src/bot/handlers/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './add-contact';
|
||||||
|
export * from './documents';
|
||||||
|
export * from './pro';
|
||||||
|
export * from './share-bot';
|
||||||
|
export * from './subscription';
|
||||||
41
apps/bot/src/bot/handlers/pro.ts
Normal file
41
apps/bot/src/bot/handlers/pro.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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 };
|
||||||
9
apps/bot/src/bot/handlers/share-bot.ts
Normal file
9
apps/bot/src/bot/handlers/share-bot.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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 };
|
||||||
22
apps/bot/src/bot/handlers/subscription.ts
Normal file
22
apps/bot/src/bot/handlers/subscription.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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 };
|
||||||
@ -5,9 +5,9 @@ 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 } from './settings/commands';
|
import { setCommands, setInfo } from './settings';
|
||||||
import { setInfo } from './settings/info';
|
|
||||||
import { env } from '@/config/env';
|
import { env } from '@/config/env';
|
||||||
|
import { mainMenu } from '@/config/keyboards';
|
||||||
import { getRedisInstance } from '@/utils/redis';
|
import { getRedisInstance } from '@/utils/redis';
|
||||||
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action';
|
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action';
|
||||||
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
|
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
|
||||||
@ -51,6 +51,8 @@ export function createBot({ token }: Parameters_) {
|
|||||||
bot.use(createConversation(conversation));
|
bot.use(createConversation(conversation));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bot.use(mainMenu);
|
||||||
|
|
||||||
setInfo(bot);
|
setInfo(bot);
|
||||||
setCommands(bot);
|
setCommands(bot);
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,15 @@ 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(['start', 'addcontact', 'sharebot', 'help', 'subscribe', 'pro']);
|
const commands = createCommands([
|
||||||
|
'start',
|
||||||
|
'addcontact',
|
||||||
|
'sharebot',
|
||||||
|
'help',
|
||||||
|
'subscribe',
|
||||||
|
'pro',
|
||||||
|
'documents',
|
||||||
|
]);
|
||||||
|
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
addLocalizations(command);
|
addLocalizations(command);
|
||||||
|
|||||||
@ -18,6 +18,9 @@ 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);
|
||||||
|
|||||||
@ -1,3 +1,13 @@
|
|||||||
|
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,
|
||||||
@ -30,9 +40,34 @@ export const KEYBOARD_SHARE_BOT = {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: ' Воспользоваться ботом',
|
text: ' Воспользоваться ботом',
|
||||||
url: process.env.BOT_URL as string,
|
url: env.BOT_URL + '?start=new',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
} 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
9
apps/bot/src/utils/contact.ts
Normal file
9
apps/bot/src/utils/contact.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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() || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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}`;
|
|
||||||
}
|
|
||||||
7
apps/cache-proxy/.dockerignore
Normal file
7
apps/cache-proxy/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
dist
|
||||||
|
README.md
|
||||||
13
apps/cache-proxy/.eslintrc.js
Normal file
13
apps/cache-proxy/.eslintrc.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { typescript } from '@repo/eslint-config/typescript';
|
||||||
|
|
||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
|
export default [
|
||||||
|
...typescript,
|
||||||
|
{
|
||||||
|
ignores: ['**/types/**', '*.config.*', '*.config.js', '.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'import/no-duplicates': 'off',
|
||||||
|
'import/consistent-type-specifier-style': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
56
apps/cache-proxy/.gitignore
vendored
Normal file
56
apps/cache-proxy/.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 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
|
||||||
51
apps/cache-proxy/Dockerfile
Normal file
51
apps/cache-proxy/Dockerfile
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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"]
|
||||||
73
apps/cache-proxy/README.md
Normal file
73
apps/cache-proxy/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ pnpm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ pnpm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ pnpm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ pnpm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ pnpm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ pnpm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](LICENSE).
|
||||||
9
apps/cache-proxy/nest-cli.json
Normal file
9
apps/cache-proxy/nest-cli.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"monorepo": true,
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/cache-proxy/package.json
Normal file
75
apps/cache-proxy/package.json
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/cache-proxy/src/app.module.ts
Normal file
18
apps/cache-proxy/src/app.module.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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 {}
|
||||||
3
apps/cache-proxy/src/config/constants.ts
Normal file
3
apps/cache-proxy/src/config/constants.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { seconds } from 'src/utils/time';
|
||||||
|
|
||||||
|
export const DEFAULT_CACHE_TTL = seconds().fromMinutes(5);
|
||||||
3
apps/cache-proxy/src/config/env.ts
Normal file
3
apps/cache-proxy/src/config/env.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import envSchema from './schema/env';
|
||||||
|
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
22
apps/cache-proxy/src/config/schema/env.ts
Normal file
22
apps/cache-proxy/src/config/schema/env.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
11
apps/cache-proxy/src/health/health.controller.ts
Normal file
11
apps/cache-proxy/src/health/health.controller.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class HealthController {
|
||||||
|
@Get('health')
|
||||||
|
public health() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
apps/cache-proxy/src/main.ts
Normal file
15
apps/cache-proxy/src/main.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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();
|
||||||
16
apps/cache-proxy/src/proxy/lib/config.ts
Normal file
16
apps/cache-proxy/src/proxy/lib/config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
8
apps/cache-proxy/src/proxy/lib/utils.ts
Normal file
8
apps/cache-proxy/src/proxy/lib/utils.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
138
apps/cache-proxy/src/proxy/proxy.controller.ts
Normal file
138
apps/cache-proxy/src/proxy/proxy.controller.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/cache-proxy/src/proxy/proxy.module.ts
Normal file
22
apps/cache-proxy/src/proxy/proxy.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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 {}
|
||||||
16
apps/cache-proxy/src/proxy/types.ts
Normal file
16
apps/cache-proxy/src/proxy/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
22
apps/cache-proxy/src/utils/query.ts
Normal file
22
apps/cache-proxy/src/utils/query.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
13
apps/cache-proxy/src/utils/time.ts
Normal file
13
apps/cache-proxy/src/utils/time.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
4
apps/cache-proxy/tsconfig.build.json
Normal file
4
apps/cache-proxy/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
23
apps/cache-proxy/tsconfig.json
Normal file
23
apps/cache-proxy/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@ -51,6 +51,14 @@ export async function getSubscriptionPrices(
|
|||||||
return wrapServerAction(() => service.getSubscriptionPrices(...variables));
|
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(
|
export async function getSubscriptionSettings(
|
||||||
...variables: Parameters<SubscriptionsService['getSubscriptionSettings']>
|
...variables: Parameters<SubscriptionsService['getSubscriptionSettings']>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import * as subscriptions from './server/subscriptions';
|
|||||||
import { wrapClientAction } from '@/utils/actions';
|
import { wrapClientAction } from '@/utils/actions';
|
||||||
|
|
||||||
export const getSubscription = wrapClientAction(subscriptions.getSubscription);
|
export const getSubscription = wrapClientAction(subscriptions.getSubscription);
|
||||||
|
export const getSubscriptions = wrapClientAction(subscriptions.getSubscriptions);
|
||||||
export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings);
|
export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings);
|
||||||
export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices);
|
export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices);
|
||||||
export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory);
|
export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory);
|
||||||
|
|||||||
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
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) throw new Error('Missing session');
|
if (!user?.telegramId) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,4 +10,6 @@ export default function Page() {
|
|||||||
|
|
||||||
redirect(isTG ? '/telegram' : '/browser');
|
redirect(isTG ? '/telegram' : '/browser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return 'Redirecting...';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 async function Layout({ children }: Readonly<PropsWithChildren>) {
|
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||||
return <TelegramProvider>{children}</TelegramProvider>;
|
return <TelegramProvider>{children}</TelegramProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,79 @@
|
|||||||
/* 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, useSession } from 'next-auth/react';
|
import { signIn, type SignInResponse, useSession } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { redirect } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
export default function Auth() {
|
export default function Auth() {
|
||||||
useTelegramTheme();
|
useTelegramTheme();
|
||||||
useAuth();
|
|
||||||
|
|
||||||
return null;
|
useTelegramAuth();
|
||||||
|
|
||||||
|
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(() => {
|
||||||
if (!initDataUser?.id) return;
|
const telegramId = initDataUser?.id;
|
||||||
|
if (!telegramId) return;
|
||||||
|
|
||||||
if (status === 'authenticated') {
|
if (status === 'authenticated') {
|
||||||
redirect('/profile');
|
// Если telegramId есть в сессии — редирект
|
||||||
|
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') {
|
||||||
signIn('telegram', {
|
void signIn('telegram', {
|
||||||
callbackUrl: '/profile',
|
callbackUrl: '/profile',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
telegramId: initDataUser.id.toString(),
|
telegramId: telegramId.toString(),
|
||||||
}).then((result) => {
|
}).then(handleSignInResult);
|
||||||
if (
|
|
||||||
result?.error &&
|
|
||||||
(result?.error?.includes('CredentialsSignin') || result?.error?.includes('UNREGISTERED'))
|
|
||||||
) {
|
|
||||||
// Пользователь не зарегистрирован
|
|
||||||
redirect('/unregistered');
|
|
||||||
} else if (result?.ok) {
|
|
||||||
redirect('/profile');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [initDataUser?.id, status]);
|
}, [initDataUser?.id, status, session?.user?.telegramId, router, handleSignInResult]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для установки темы из Telegram Mini App
|
||||||
|
*/
|
||||||
function useTelegramTheme() {
|
function useTelegramTheme() {
|
||||||
const isDark = isMiniAppDark();
|
const isDark = isMiniAppDark();
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|||||||
6
apps/web/app/(documents)/offer/layout.tsx
Normal file
6
apps/web/app/(documents)/offer/layout.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { DocumentsLayout } from '@/components/documents/layout';
|
||||||
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||||
|
return <DocumentsLayout title="Публичная оферта">{children}</DocumentsLayout>;
|
||||||
|
}
|
||||||
99
apps/web/app/(documents)/offer/page.mdx
Normal file
99
apps/web/app/(documents)/offer/page.mdx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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. Контакты
|
||||||
|
|
||||||
|
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.
|
||||||
6
apps/web/app/(documents)/privacy/layout.tsx
Normal file
6
apps/web/app/(documents)/privacy/layout.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { DocumentsLayout } from '@/components/documents/layout';
|
||||||
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||||
|
return <DocumentsLayout title="Политика конфиденциальности">{children}</DocumentsLayout>;
|
||||||
|
}
|
||||||
131
apps/web/app/(documents)/privacy/page.mdx
Normal file
131
apps/web/app/(documents)/privacy/page.mdx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: 'Политика конфиденциальности',
|
||||||
|
description:
|
||||||
|
'Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
|
||||||
|
};
|
||||||
|
|
||||||
|
### Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)
|
||||||
|
|
||||||
|
#### 1. Термины и определения
|
||||||
|
|
||||||
|
- **Telegram** – Telegram Messenger Inc. (платформа, на которой работает бот и мини-приложение).
|
||||||
|
- **Платформа** – экосистема ботов и мини-приложений Telegram.
|
||||||
|
- **Разработчик** – физическое лицо, самозанятый, владелец и оператор сервиса «Запишись.онлайн» (@zapishis_online_bot) - (далее — «Разработчик»).
|
||||||
|
- **Сторонний сервис** – бот/мини-приложение Разработчика, предоставляемое в Платформе.
|
||||||
|
- **Пользователь** – лицо, использующее Сторонний сервис через свою учетную запись Telegram (далее — «Вы»).
|
||||||
|
- **Политика** – настоящий документ, регулирующий отношения между Разработчиком и Пользователем в части сбора и - обработки персональных данных.
|
||||||
|
|
||||||
|
#### 2. Общие положения
|
||||||
|
|
||||||
|
2.1. Настоящая Политика регулирует исключительно отношения между Разработчиком и Пользователем. Она не заменяет и не изменяет Политику конфиденциальности Telegram: [https://telegram.org/privacy](https://telegram.org/privacy).
|
||||||
|
|
||||||
|
2.2. Разработчик соблюдает применимые требования платформы Telegram к конфиденциальности и защите данных.
|
||||||
|
|
||||||
|
2.3. Использование Сервиса Пользователем и/или активация платного доступа означает согласие Пользователя с условиями настоящей Политики.
|
||||||
|
|
||||||
|
2.4. Если Вы не согласны с условиями Политики — прекратите использование Сервиса.
|
||||||
|
|
||||||
|
#### 3. Отказ от ответственности
|
||||||
|
|
||||||
|
3.1. Сторонний сервис является независимым приложением и не поддерживается, не одобряется и не аффилирован с Telegram (за исключением использования API и инфраструктуры Telegram).
|
||||||
|
|
||||||
|
3.2. Разработчик вправе изменять настоящую Политику — изменения вступают в силу с момента их публикации. Вы обязаны самостоятельно отслеживать обновления.
|
||||||
|
|
||||||
|
3.3. Используя Сервис, Вы подтверждаете, что ознакомлены и согласны с условиями использования Telegram для ботов и мини-приложений: [https://telegram.org/tos/bots](https://telegram.org/tos/bots), [https://telegram.org/tos/mini-apps](https://telegram.org/tos/mini-apps).
|
||||||
|
|
||||||
|
3.4. Вы гарантируете, что используете Сервис в соответствии с действующим законодательством и обладаете правом взаимодействовать с ним (например, достигли возраста, необходимого для использования услуг).
|
||||||
|
|
||||||
|
3.5. Вы обязуетесь предоставлять точную и актуальную информацию, если Сервис запрашивает её.
|
||||||
|
|
||||||
|
3.6. Любая информация, которую Вы делаете общедоступной самостоятельно (через профиль Telegram, публичные сообщения и т.п.), может стать доступна другим пользователям и не подпадает под защиту настоящей Политики в части конфиденциальности этой общедоступной информации.
|
||||||
|
|
||||||
|
#### 4. Сбор персональных данных
|
||||||
|
|
||||||
|
4.1. Telegram по умолчанию предоставляет сторонним сервисам ограниченный набор данных о Пользователе — подробнее: [https://telegram.org/privacy#6-bot-messages](https://telegram.org/privacy#6-bot-messages).
|
||||||
|
|
||||||
|
4.2. Сторонний сервис может дополнительно получать данные, которые Вы передаёте в чате бота или в мини-приложении (например, контакт, телефон), если Вы явно их отправляете.
|
||||||
|
|
||||||
|
4.3. В случае мини-приложения дополнительно могут передаваться данные в соответствии с правилами мини-приложений Telegram: [https://telegram.org/tos/mini-apps#4-privacy](https://telegram.org/tos/mini-apps#4-privacy).
|
||||||
|
|
||||||
|
4.4. Сторонний сервис может собирать также анонимную статистику использования (диагностика, события взаимодействия), не связываемую напрямую с персоной.
|
||||||
|
|
||||||
|
4.5. Пользователь может передавать данные третьих лиц (например, контактные данные клиентов или мастеров) для использования в Сервисе. При этом пользователь гарантирует, что эти лица дали согласие на обработку их персональных данных в рамках Сервиса.
|
||||||
|
|
||||||
|
#### 5. Какие данные мы собираем и как используем
|
||||||
|
|
||||||
|
5.1. Разработчик запрашивает, собирает и обрабатывает только те данные, которые необходимы для корректной работы функций Сервиса, в частности:
|
||||||
|
|
||||||
|
- Telegram ID и (опционально) отображаемое имя пользователя;
|
||||||
|
- телефон, только если Вы предоставили его добровольно (например, при регистрации);
|
||||||
|
- данные о заказах: дата/время, описание заказа, статус;
|
||||||
|
- информация о факте покупки Pro-доступа: период доступа, тип покупки (детали платёжной транзакции обрабатывает платёжный оператор — ЮKassa);
|
||||||
|
|
||||||
|
5.2. Цели обработки:
|
||||||
|
|
||||||
|
- предоставление и поддержка работы Сервиса (создание заказов, напоминания, управление доступом);
|
||||||
|
- подтверждение и учет оплат (взаимодействие с платёжным оператором для актуализации статуса доступа);
|
||||||
|
- реализация реферальной программы (хранение связей «кто пригласил/кого пригласили»);
|
||||||
|
- анализ использования и улучшение сервиса;
|
||||||
|
- выполнение юридических обязательств (хранение информации о транзакциях и др.);
|
||||||
|
|
||||||
|
> **Важно:** детальные платёжные данные (реквизиты карт и т.д.) не хранятся у Разработчика — их обрабатывает платёжный оператор (ЮKassa) и Telegram-платежный бот.
|
||||||
|
|
||||||
|
#### 6. Передача данных третьим лицам
|
||||||
|
|
||||||
|
6.1. Разработчик не передаёт персональные данные третьим лицам, за исключением следующих случаев:
|
||||||
|
|
||||||
|
- платёжному оператору (ЮKassa) и связанным службам для обработки платежей;
|
||||||
|
- Telegram как платформе для функционирования бота и мини-приложения;
|
||||||
|
- в случае необходимости — исполнителям, оказывающим техническую поддержку, при условии подписания ими обязательств о конфиденциальности;
|
||||||
|
- если передача требуется по закону (запросы уполномоченных органов и т.п.);
|
||||||
|
|
||||||
|
6.2. Разработчик не продаёт и не передаёт персональные данные для рекламных целей третьим лицам без Вашего отдельного согласия.
|
||||||
|
|
||||||
|
#### 7. Защита и хранение данных
|
||||||
|
|
||||||
|
7.1. Разработчик применяет разумные технические и организационные меры для защиты персональных данных (использование надежного VPS, ограничения доступа, резервное копирование и т.п.).
|
||||||
|
|
||||||
|
7.2. Доступ к персональным данным имеет только Разработчик (и/или доверенные исполнители технической поддержки при необходимости).
|
||||||
|
|
||||||
|
7.3. Данные хранятся на серверах, указанных Разработчиком. Если используются внешние сервисы/облачные хранилища — это будет указано в соответствующих местах Политики или сообщения при сборе данных.
|
||||||
|
|
||||||
|
#### 8. Права и обязанности сторон
|
||||||
|
|
||||||
|
8.1. Права Разработчика:
|
||||||
|
|
||||||
|
- вносить изменения в Политику с публикацией новой версии;
|
||||||
|
- ограничивать доступ к API/сервису при подозрении в злоупотреблениях;
|
||||||
|
- запросить подтверждение личности при необходимости обработки привилегированных запросов;
|
||||||
|
|
||||||
|
8.2. Обязанности Разработчика:
|
||||||
|
|
||||||
|
- обеспечивать доступность Политики и исполнять её условия;
|
||||||
|
- обрабатывать законные запросы пользователей о доступе, изменении или удалении данных в разумные сроки (не позднее 30 дней, если иное не установлено законом);
|
||||||
|
- соблюдать применимое законодательство о защите персональных данных;
|
||||||
|
|
||||||
|
8.3. Права Пользователя:
|
||||||
|
|
||||||
|
- запросить копию своих персональных данных, хранящихся у Разработчика;
|
||||||
|
- потребовать исправления неточных данных;
|
||||||
|
- потребовать удаления персональных данных в пределах, допустимых законом (с сохранением данных, необходимых для выполнения юридических обязательств, например, по учёту платежей);
|
||||||
|
- отозвать согласие на обработку персональных данных, если такое согласие предоставлялось добровольно;
|
||||||
|
- подать жалобу в уполномоченные органы по защите персональных данных, если считает, что его права нарушены;
|
||||||
|
|
||||||
|
8.4. Обязанности Пользователя:
|
||||||
|
|
||||||
|
- предоставлять точную и актуальную информацию;
|
||||||
|
- не использовать Сервис в нарушении законодательства и условий Telegram.
|
||||||
|
|
||||||
|
#### 9. Реклама и использование данных для аналитики
|
||||||
|
|
||||||
|
9.1. На текущем этапе Разработчик не использует персональные данные для демонстрации таргетированной рекламы третьих лиц без явного согласия Пользователя.
|
||||||
|
|
||||||
|
9.2. Разработчик может собирать агрегированную (анонимную) статистику использования Сервиса для улучшения функционала.
|
||||||
|
|
||||||
|
#### 10. Изменения Политики
|
||||||
|
|
||||||
|
10.1. Разработчик вправе вносить изменения в настоящую Политику. Все изменения публикуются на этой странице и вступают в силу с момента публикации.
|
||||||
|
|
||||||
|
#### 11. Контакты
|
||||||
|
|
||||||
|
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.
|
||||||
@ -1,4 +1,3 @@
|
|||||||
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 {
|
||||||
@ -9,23 +8,14 @@ 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} />
|
||||||
@ -35,6 +25,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
|
|||||||
<div className="pb-24" />
|
<div className="pb-24" />
|
||||||
<OrderButtons {...parameters} />
|
<OrderButtons {...parameters} />
|
||||||
</Container>
|
</Container>
|
||||||
</HydrationBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 async function AddOrdersPage() {
|
export default function AddOrdersPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Новая запись" />
|
<PageHeader title="Новая запись" />
|
||||||
|
|||||||
@ -1,25 +1,44 @@
|
|||||||
import { getSubscription } from '@/actions/api/subscriptions';
|
import {
|
||||||
|
getSubscription,
|
||||||
|
getSubscriptionPrices,
|
||||||
|
getSubscriptions,
|
||||||
|
} from '@/actions/api/subscriptions';
|
||||||
import { getSessionUser } from '@/actions/session';
|
import { getSessionUser } from '@/actions/session';
|
||||||
import { PageHeader } from '@/components/navigation';
|
|
||||||
import { TryFreeButton } from '@/components/subscription';
|
import { TryFreeButton } from '@/components/subscription';
|
||||||
import { env } from '@/config/env';
|
import { env } from '@/config/env';
|
||||||
|
import { Enum_Subscriptionprice_Period as SubscriptionPricePeriod } from '@repo/graphql/types';
|
||||||
import { Button } from '@repo/ui/components/ui/button';
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
|
import { formatMoney } from '@repo/utils/money';
|
||||||
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
|
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default async function ProPage() {
|
export default async function ProPage() {
|
||||||
const { telegramId } = await getSessionUser();
|
const { telegramId } = await getSessionUser();
|
||||||
const { hasActiveSubscription, usedTrialSubscription } = await getSubscription({
|
|
||||||
telegramId,
|
const { subscriptions } = await getSubscriptions({
|
||||||
|
filters: { customer: { telegramId: { eq: telegramId } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const canUseTrial = !usedTrialSubscription;
|
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 (
|
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">
|
<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">
|
||||||
<PageHeader title="" />
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="px-4 py-16 sm:px-6 lg:px-8">
|
<div className="px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div className="mx-auto max-w-4xl text-center">
|
<div className="mx-auto max-w-4xl text-center">
|
||||||
<div className="mb-2 flex justify-center">
|
<div className="mb-2 flex justify-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -30,14 +49,14 @@ export default async function ProPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="mb-6 text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
|
<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">
|
<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
|
Pro
|
||||||
</span>
|
</span>{' '}
|
||||||
|
Доступ
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mx-auto mb-8 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
<p className="mx-auto mb-6 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
||||||
{hasActiveSubscription
|
{hasActiveSubscription
|
||||||
? 'Ваш Pro доступ активен!'
|
? 'Ваш Pro доступ активен!'
|
||||||
: 'Разблокируйте больше возможностей'}
|
: 'Разблокируйте больше возможностей'}
|
||||||
@ -57,7 +76,7 @@ export default async function ProPage() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant={canUseTrial ? 'outline' : 'default'}
|
variant={canUseTrial ? 'outline' : 'default'}
|
||||||
>
|
>
|
||||||
<Link href={env.BOT_URL} rel="noopener noreferrer" target="_blank">
|
<Link href={botUrl.toString()} rel="noopener noreferrer" target="_blank">
|
||||||
Приобрести Pro доступ через бота
|
Приобрести Pro доступ через бота
|
||||||
<ArrowRight className="ml-2 size-5" />
|
<ArrowRight className="ml-2 size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -66,7 +85,7 @@ export default async function ProPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
<h2 className="mb-6 text-center text-2xl font-bold text-gray-900 dark:text-white">
|
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Преимущества
|
Преимущества
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -89,6 +108,47 @@ export default async function ProPage() {
|
|||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
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 { ReadonlyServicesList } from '@/components/profile/services';
|
import { ReadonlyServicesList } from '@/components/profile/services';
|
||||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
// Тип параметров страницы
|
// Тип параметров страницы
|
||||||
type Props = { params: Promise<{ telegramId: string }> };
|
type Props = { params: Promise<{ telegramId: string }> };
|
||||||
@ -12,33 +10,18 @@ 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;
|
|
||||||
|
|
||||||
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 masterId={profile.documentId} />
|
<ReadonlyServicesList telegramId={contactTelegramId} />
|
||||||
<ProfileOrdersList telegramId={contactTelegramId} />
|
<ProfileOrdersList telegramId={contactTelegramId} />
|
||||||
|
<div className="pb-24" />
|
||||||
|
<ProfileButtons telegramId={contactTelegramId} />
|
||||||
</Container>
|
</Container>
|
||||||
</HydrationBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,13 @@
|
|||||||
import { getCustomer } from '@/actions/api/customers';
|
|
||||||
import { getSubscriptionSettings } from '@/actions/api/subscriptions';
|
|
||||||
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, SubscriptionInfoBar } 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],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { subscriptionSetting } = await queryClient.fetchQuery({
|
|
||||||
queryFn: getSubscriptionSettings,
|
|
||||||
queryKey: ['customer', telegramId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const proEnabled = subscriptionSetting?.proEnabled;
|
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<Container className="px-0">
|
||||||
<Container className="px-0">
|
<PersonCard />
|
||||||
<PersonCard />
|
<SubscriptionInfoBar />
|
||||||
{proEnabled && <SubscriptionInfoBar />}
|
<ProfileDataCard />
|
||||||
<ProfileDataCard />
|
<LinksCard />
|
||||||
<LinksCard />
|
</Container>
|
||||||
</Container>
|
|
||||||
</HydrationBoundary>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,16 @@
|
|||||||
import { getSlot } from '@/actions/api/slots';
|
|
||||||
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],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<>
|
||||||
<PageHeader title="Слот" />
|
<PageHeader title="Слот" />
|
||||||
<Container>
|
<Container>
|
||||||
<SlotDateTime {...parameters} />
|
<SlotDateTime {...parameters} />
|
||||||
@ -29,6 +19,6 @@ export default async function SlotPage(props: Readonly<Props>) {
|
|||||||
<div className="pb-24" />
|
<div className="pb-24" />
|
||||||
<SlotButtons {...parameters} />
|
<SlotButtons {...parameters} />
|
||||||
</Container>
|
</Container>
|
||||||
</HydrationBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,20 @@
|
|||||||
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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,9 @@ 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';
|
||||||
|
|
||||||
@ -17,19 +15,15 @@ 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={locale}>
|
<html lang="ru">
|
||||||
<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>
|
||||||
<I18nProvider>
|
<ThemeProvider>
|
||||||
<ThemeProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<QueryProvider>{children}</QueryProvider>
|
||||||
<QueryProvider>{children}</QueryProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</ErrorProvider>
|
</ErrorProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,25 +1,27 @@
|
|||||||
'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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useClientOnce(() => {
|
||||||
if (!hasUpdated) {
|
if (
|
||||||
|
localStorage.getItem('firstLogin') === null ||
|
||||||
|
localStorage.getItem('firstLogin') === 'true'
|
||||||
|
) {
|
||||||
updateProfile({
|
updateProfile({
|
||||||
data: {
|
data: {
|
||||||
active: true,
|
active: true,
|
||||||
photoUrl: initDataUser?.photoUrl || undefined,
|
photoUrl: initDataUser?.photoUrl || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setHasUpdated(true);
|
localStorage.setItem('firstLogin', 'false');
|
||||||
}
|
}
|
||||||
}, [hasUpdated, initDataUser?.photoUrl, updateProfile]);
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/web/components/documents/layout.tsx
Normal file
18
apps/web/components/documents/layout.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/components/documents/links.tsx
Normal file
17
apps/web/components/documents/links.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
'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';
|
||||||
@ -6,16 +7,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 isTG = isTMA('simple');
|
const hideBackButton = process.env.NODE_ENV === 'production' || 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',
|
||||||
isTG ? 'px-4' : 'px-2',
|
hideBackButton ? 'px-4' : 'px-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isTG && <BackButton />}
|
{!hideBackButton && <BackButton />}
|
||||||
{props.title}
|
{props.title}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,25 +1,29 @@
|
|||||||
'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 } = {} } = useOrderQuery({ documentId });
|
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId });
|
||||||
|
|
||||||
if (!order) return null;
|
const noContacts = !order?.slot?.master && !order?.client;
|
||||||
|
|
||||||
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">
|
||||||
{order.slot?.master && (
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!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="Мастер"
|
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="Клиент"
|
description="Клиент"
|
||||||
|
|||||||
@ -6,7 +6,16 @@ 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 } = {} } = useOrderQuery({ documentId });
|
const { data: { order } = {}, isLoading } = 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;
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ 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 { sift } from 'radashi';
|
import { sift } from 'radashi';
|
||||||
|
|
||||||
type ContactsGridProps = {
|
type ContactsGridProps = {
|
||||||
@ -25,8 +26,12 @@ type ContactsGridProps = {
|
|||||||
readonly title: string;
|
readonly title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UseContactsProps = Partial<{
|
||||||
|
showInactive: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
export function ClientsGrid() {
|
export function ClientsGrid() {
|
||||||
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
|
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts({ showInactive: true });
|
||||||
|
|
||||||
const clientId = useOrderStore((store) => store.clientId);
|
const clientId = useOrderStore((store) => store.clientId);
|
||||||
const setClientId = useOrderStore((store) => store.setClientId);
|
const setClientId = useOrderStore((store) => store.setClientId);
|
||||||
@ -103,7 +108,7 @@ export function ContactsGridBase({
|
|||||||
isCurrentUser && 'font-bold',
|
isCurrentUser && 'font-bold',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{contact.name}
|
{getCustomerFullName(contact)}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
@ -144,7 +149,7 @@ export function MastersGrid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useContacts() {
|
function useContacts({ showInactive = false }: UseContactsProps = {}) {
|
||||||
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -155,13 +160,13 @@ function useContacts() {
|
|||||||
|
|
||||||
const isLoading = isLoadingContacts || isLoadingCustomer;
|
const isLoading = isLoadingContacts || isLoadingCustomer;
|
||||||
|
|
||||||
const contacts = sift(
|
const contacts = sift(pages.flatMap((page) => page.customers));
|
||||||
pages.flatMap((page) => page.customers).filter((contact) => Boolean(contact && contact.active)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
...query,
|
...query,
|
||||||
contacts: [{ ...customer, name: 'Я' } as CustomerFieldsFragment, ...contacts],
|
contacts: [{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment].concat(
|
||||||
|
showInactive ? contacts : contacts.filter((contact) => contact.active),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
'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 } = {} } = useOrderQuery({ documentId });
|
const { data: { order } = {}, isLoading } = 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>
|
||||||
{order.services?.map(
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!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>
|
||||||
|
|||||||
@ -5,7 +5,14 @@ import { getAlert } from '@/components/shared/status';
|
|||||||
import { useOrderQuery } from '@/hooks/api/orders';
|
import { useOrderQuery } from '@/hooks/api/orders';
|
||||||
|
|
||||||
export function OrderStatus({ documentId }: Readonly<OrderComponentProps>) {
|
export function OrderStatus({ documentId }: Readonly<OrderComponentProps>) {
|
||||||
const { data: { order } = {} } = useOrderQuery({ documentId });
|
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId });
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="flex animate-pulse flex-col space-y-1">
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return order?.state && getAlert(order.state);
|
return order?.state && getAlert(order.state);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,18 @@ import Link from 'next/link';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
|
export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
|
||||||
const { data: { customer } = {} } = useCustomerQuery({ telegramId });
|
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex animate-pulse flex-col gap-4">
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-6 w-32 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
if (!customer) return null;
|
if (!customer) return null;
|
||||||
|
|
||||||
@ -33,10 +44,26 @@ export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileDataCard() {
|
export function ProfileDataCard() {
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {}, isLoading } = useCustomerQuery();
|
||||||
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
|
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
|
||||||
useProfileEdit();
|
useProfileEdit();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex animate-pulse flex-col gap-4">
|
||||||
|
<div className="h-6 w-32 rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
<div className="h-5 w-60 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
if (!customer) return null;
|
if (!customer) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -50,6 +77,13 @@ export function ProfileDataCard() {
|
|||||||
onChange={(value) => updateField('name', value)}
|
onChange={(value) => updateField('name', value)}
|
||||||
value={customer?.name ?? ''}
|
value={customer?.name ?? ''}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
id="surname"
|
||||||
|
key={`surname-${resetTrigger}`}
|
||||||
|
label="Фамилия"
|
||||||
|
onChange={(value) => updateField('surname', value)}
|
||||||
|
value={customer?.surname ?? ''}
|
||||||
|
/>
|
||||||
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
|
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
|
||||||
<CheckboxWithText
|
<CheckboxWithText
|
||||||
checked={customer.role !== 'client'}
|
checked={customer.role !== 'client'}
|
||||||
|
|||||||
@ -4,16 +4,19 @@ import { type ProfileProps } from './types';
|
|||||||
import { UserAvatar } from '@/components/shared/user-avatar';
|
import { UserAvatar } from '@/components/shared/user-avatar';
|
||||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { Card } from '@repo/ui/components/ui/card';
|
import { Card } from '@repo/ui/components/ui/card';
|
||||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
import { getCustomerFullName } from '@repo/utils/customer';
|
||||||
|
|
||||||
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
|
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
|
||||||
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<Card className="bg-transparent p-4 shadow-none">
|
||||||
<LoadingSpinner />
|
<div className="flex animate-pulse flex-col items-center space-y-2">
|
||||||
</div>
|
<div className="size-32 rounded-full bg-muted" />
|
||||||
|
<div className="h-6 w-40 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!customer) return null;
|
if (!customer) return null;
|
||||||
@ -22,7 +25,7 @@ export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
|
|||||||
<Card className="bg-transparent p-4 shadow-none">
|
<Card className="bg-transparent p-4 shadow-none">
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<div className="flex flex-col items-center space-y-2">
|
||||||
<UserAvatar {...customer} size="lg" />
|
<UserAvatar {...customer} size="lg" />
|
||||||
<h2 className="text-2xl font-bold">{customer?.name}</h2>
|
<h2 className="text-2xl font-bold">{getCustomerFullName(customer)}</h2>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
35
apps/web/components/profile/profile-buttons.tsx
Normal file
35
apps/web/components/profile/profile-buttons.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/* eslint-disable canonical/id-match */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import FloatingActionPanel from '@/components/shared/action-panel';
|
||||||
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
|
import { usePushWithData } from '@/hooks/url';
|
||||||
|
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
|
|
||||||
|
type QuickAppointmentProps = {
|
||||||
|
readonly telegramId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileButtons({ telegramId }: Readonly<QuickAppointmentProps>) {
|
||||||
|
const push = usePushWithData();
|
||||||
|
|
||||||
|
const { data: { customer: profile } = {}, isLoading: isLoadingProfile } = useCustomerQuery({
|
||||||
|
telegramId,
|
||||||
|
});
|
||||||
|
const { data: { customer: currentUser } = {}, isLoading: isLoadingCurrentUser } =
|
||||||
|
useCustomerQuery();
|
||||||
|
|
||||||
|
const isLoading = isLoadingProfile || isLoadingCurrentUser;
|
||||||
|
|
||||||
|
const handleBook = () => {
|
||||||
|
if (profile?.role === Enum_Customer_Role.Client) {
|
||||||
|
push('/orders/add', { client: profile, slot: { master: currentUser } });
|
||||||
|
} else {
|
||||||
|
push('/orders/add', { client: currentUser, slot: { master: profile } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!telegramId) return null;
|
||||||
|
|
||||||
|
return <FloatingActionPanel isLoading={isLoading} onQuickBook={handleBook} />;
|
||||||
|
}
|
||||||
@ -12,10 +12,27 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ServiceDataCard({ serviceId }: Readonly<Props>) {
|
export function ServiceDataCard({ serviceId }: Readonly<Props>) {
|
||||||
const { data: { service } = {} } = useServiceQuery({ documentId: serviceId });
|
const { data: { service } = {}, isLoading } = useServiceQuery({ documentId: serviceId });
|
||||||
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
|
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
|
||||||
useServiceEdit(serviceId);
|
useServiceEdit(serviceId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex animate-pulse flex-col gap-4">
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-10 w-full rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
<div className="h-28 w-full rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!service) return null;
|
if (!service) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
|
/* eslint-disable canonical/id-match */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { type ProfileProps } from '../types';
|
||||||
import { DataNotFound } from '@/components/shared/alert';
|
import { DataNotFound } from '@/components/shared/alert';
|
||||||
import { ServiceCard } from '@/components/shared/service-card';
|
import { ServiceCard } from '@/components/shared/service-card';
|
||||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useServicesQuery } from '@/hooks/api/services';
|
import { useServicesQuery } from '@/hooks/api/services';
|
||||||
|
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
type MasterServicesListProps = {
|
|
||||||
masterId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Компонент для отображения услуг мастера (без ссылок, только просмотр)
|
// Компонент для отображения услуг мастера (без ссылок, только просмотр)
|
||||||
export function ReadonlyServicesList({ masterId }: Readonly<MasterServicesListProps>) {
|
export function ReadonlyServicesList({ telegramId }: Readonly<ProfileProps>) {
|
||||||
const { isLoading, services } = useServices(masterId);
|
const { data: { customer } = {} } = useCustomerQuery({ telegramId });
|
||||||
|
const { isLoading, services } = useServices(telegramId);
|
||||||
|
|
||||||
|
if (customer?.role === Enum_Customer_Role.Client) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 px-4">
|
<div className="space-y-2 px-4">
|
||||||
@ -54,17 +56,17 @@ export function ServicesList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useServices(masterId?: string) {
|
function useServices(telegramId?: Readonly<ProfileProps>['telegramId']) {
|
||||||
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||||
|
|
||||||
// Используем переданный masterId или текущего пользователя
|
// Используем переданный masterId или текущего пользователя
|
||||||
const targetMasterId = masterId || customer?.documentId;
|
const targetTelegramId = telegramId || customer?.telegramId;
|
||||||
|
|
||||||
const { data: { services } = {}, isLoading: isLoadingServices } = useServicesQuery({
|
const { data: { services } = {}, isLoading: isLoadingServices } = useServicesQuery({
|
||||||
filters: {
|
filters: {
|
||||||
master: {
|
master: {
|
||||||
documentId: {
|
telegramId: {
|
||||||
eq: targetMasterId,
|
eq: targetTelegramId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,26 +2,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
|
import { useSubscriptionQuery, useSubscriptionSettingQuery } from '@/hooks/api/subscriptions';
|
||||||
import { Enum_Customer_Role } from '@repo/graphql/types';
|
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export function SubscriptionInfoBar() {
|
export function SubscriptionInfoBar() {
|
||||||
const { data, error, isLoading } = useSubscriptionQuery();
|
const { data: { subscriptionSetting } = {}, isLoading: isLoadingSubscriptionSetting } =
|
||||||
|
useSubscriptionSettingQuery();
|
||||||
|
const { data, isLoading: isLoadingSubscription } = useSubscriptionQuery();
|
||||||
|
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||||
|
|
||||||
|
if (customer?.role === Enum_Customer_Role.Client) return null;
|
||||||
|
|
||||||
|
const isLoading = isLoadingCustomer || isLoadingSubscription || isLoadingSubscriptionSetting;
|
||||||
|
|
||||||
const isActive = data?.hasActiveSubscription;
|
const isActive = data?.hasActiveSubscription;
|
||||||
const remainingOrdersCount = data?.remainingOrdersCount;
|
const remainingOrdersCount = data?.remainingOrdersCount;
|
||||||
const remainingDays = data?.remainingDays;
|
const remainingDays = data?.remainingDays;
|
||||||
const maxOrdersPerMonth = data?.maxOrdersPerMonth;
|
const maxOrdersPerMonth = data?.maxOrdersPerMonth;
|
||||||
|
|
||||||
if (customer?.role === Enum_Customer_Role.Client) return null;
|
|
||||||
|
|
||||||
if (error) return null;
|
|
||||||
|
|
||||||
const title = isActive ? 'Pro доступ активен' : 'Pro доступ неактивен';
|
const title = isActive ? 'Pro доступ активен' : 'Pro доступ неактивен';
|
||||||
|
|
||||||
let description = 'Попробуйте бесплатно';
|
let description = 'Попробуйте бесплатно';
|
||||||
@ -34,6 +36,8 @@ export function SubscriptionInfoBar() {
|
|||||||
description = `Осталось ${remainingDays} дней`;
|
description = `Осталось ${remainingDays} дней`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subscriptionSetting?.proEnabled) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href="/pro" rel="noopener noreferrer">
|
<Link href="/pro" rel="noopener noreferrer">
|
||||||
<div className={cn('px-4', isLoading && 'animate-pulse')}>
|
<div className={cn('px-4', isLoading && 'animate-pulse')}>
|
||||||
|
|||||||
@ -3,12 +3,24 @@
|
|||||||
import { type SlotComponentProps } from '../types';
|
import { type SlotComponentProps } from '../types';
|
||||||
import { SlotDate } from './slot-date';
|
import { SlotDate } from './slot-date';
|
||||||
import { SlotTime } from './slot-time';
|
import { SlotTime } from './slot-time';
|
||||||
|
import { useSlotQuery } from '@/hooks/api/slots';
|
||||||
import { ScheduleStoreProvider } from '@/stores/schedule';
|
import { ScheduleStoreProvider } from '@/stores/schedule';
|
||||||
import { withContext } from '@/utils/context';
|
import { withContext } from '@/utils/context';
|
||||||
|
|
||||||
export const SlotDateTime = withContext(ScheduleStoreProvider)(function (
|
export const SlotDateTime = withContext(ScheduleStoreProvider)(function (
|
||||||
props: Readonly<SlotComponentProps>,
|
props: Readonly<SlotComponentProps>,
|
||||||
) {
|
) {
|
||||||
|
const { isLoading } = useSlotQuery(props);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<SlotDate {...props} />
|
<SlotDate {...props} />
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from '@repo/ui/components/ui/button';
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
import { Card } from '@repo/ui/components/ui/card';
|
import { Card } from '@repo/ui/components/ui/card';
|
||||||
import { Ban, Check, Lock, RotateCcw, Save, Trash2, Undo, Unlock } from 'lucide-react';
|
import { Ban, Check, Lock, Plus, RotateCcw, Save, Trash2, Undo, Unlock } from 'lucide-react';
|
||||||
|
|
||||||
type FloatingActionPanelProps = {
|
type FloatingActionPanelProps = {
|
||||||
readonly isLoading?: boolean;
|
readonly isLoading?: boolean;
|
||||||
@ -11,6 +11,7 @@ type FloatingActionPanelProps = {
|
|||||||
readonly onComplete?: () => void;
|
readonly onComplete?: () => void;
|
||||||
readonly onConfirm?: () => void;
|
readonly onConfirm?: () => void;
|
||||||
readonly onDelete?: () => void;
|
readonly onDelete?: () => void;
|
||||||
|
readonly onQuickBook?: () => void;
|
||||||
readonly onRepeat?: () => void;
|
readonly onRepeat?: () => void;
|
||||||
readonly onReturn?: () => void;
|
readonly onReturn?: () => void;
|
||||||
readonly onSave?: () => void;
|
readonly onSave?: () => void;
|
||||||
@ -24,6 +25,7 @@ export default function FloatingActionPanel({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onQuickBook,
|
||||||
onRepeat,
|
onRepeat,
|
||||||
onReturn,
|
onReturn,
|
||||||
onSave,
|
onSave,
|
||||||
@ -36,6 +38,7 @@ export default function FloatingActionPanel({
|
|||||||
!onDelete &&
|
!onDelete &&
|
||||||
!onComplete &&
|
!onComplete &&
|
||||||
!onRepeat &&
|
!onRepeat &&
|
||||||
|
!onQuickBook &&
|
||||||
!onToggle &&
|
!onToggle &&
|
||||||
!onReturn &&
|
!onReturn &&
|
||||||
!onSave
|
!onSave
|
||||||
@ -45,6 +48,18 @@ export default function FloatingActionPanel({
|
|||||||
return (
|
return (
|
||||||
<Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6">
|
<Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6">
|
||||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
||||||
|
{/* Кнопка записать */}
|
||||||
|
{onQuickBook && (
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-2xl bg-gradient-to-r from-purple-600 to-blue-600 text-sm text-white transition-all duration-200 hover:bg-primary/90 dark:from-purple-700 dark:to-blue-700 sm:w-auto"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onQuickBook}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
<span>Быстрая запись</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* Кнопка закрыть/открыть */}
|
{/* Кнопка закрыть/открыть */}
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { UserAvatar } from './user-avatar';
|
|||||||
import type * as GQL from '@repo/graphql/types';
|
import type * as GQL from '@repo/graphql/types';
|
||||||
import { Badge } from '@repo/ui/components/ui/badge';
|
import { Badge } from '@repo/ui/components/ui/badge';
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
|
import { getCustomerFullName } from '@repo/utils/customer';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { memo } from 'react';
|
import { memo, type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
type ContactRowProps = GQL.CustomerFieldsFragment & {
|
type ContactRowProps = GQL.CustomerFieldsFragment & {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
@ -11,13 +12,30 @@ type ContactRowProps = GQL.CustomerFieldsFragment & {
|
|||||||
readonly showServices?: boolean;
|
readonly showServices?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function Wrapper({
|
||||||
|
children,
|
||||||
|
contact,
|
||||||
|
}: PropsWithChildren<{ readonly contact: GQL.CustomerFieldsFragment }>) {
|
||||||
|
const isActive = contact.active && contact.telegramId;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="block"
|
||||||
|
href={contact.active ? `/profile/${contact.telegramId}` : ''}
|
||||||
|
key={contact.telegramId}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export const ContactRow = memo(function ({ className, description, ...contact }: ContactRowProps) {
|
export const ContactRow = memo(function ({ className, description, ...contact }: ContactRowProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Wrapper contact={contact}>
|
||||||
className="block"
|
|
||||||
href={contact.active ? `/profile/${contact.telegramId}` : ''}
|
|
||||||
key={contact.telegramId}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between',
|
'flex items-center justify-between',
|
||||||
@ -28,7 +46,7 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
|
|||||||
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
|
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
|
||||||
<UserAvatar {...contact} size="sm" />
|
<UserAvatar {...contact} size="sm" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{contact.name}</p>
|
<p className="font-medium">{getCustomerFullName(contact)}</p>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
|
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
|
||||||
)}
|
)}
|
||||||
@ -36,6 +54,6 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
|
|||||||
</div>
|
</div>
|
||||||
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
|
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
|
|||||||
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
|
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
|
||||||
import { type CustomerFieldsFragment } from '@repo/graphql/types';
|
import { type CustomerFieldsFragment } from '@repo/graphql/types';
|
||||||
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 Image from 'next/image';
|
||||||
|
|
||||||
type Sizes = 'lg' | 'md' | 'sm' | 'xs';
|
type Sizes = 'lg' | 'md' | 'sm' | 'xs';
|
||||||
@ -37,7 +38,7 @@ export function UserAvatar({ className, size = 'sm', telegramId = null }: UserAv
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt={customer?.name || 'contact-avatar'}
|
alt={customer ? getCustomerFullName(customer) : 'contact-avatar'}
|
||||||
className="size-full rounded-full object-cover"
|
className="size-full rounded-full object-cover"
|
||||||
height={80}
|
height={80}
|
||||||
src={customer?.photoUrl || AvatarPlaceholder}
|
src={customer?.photoUrl || AvatarPlaceholder}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { z } from 'zod';
|
|||||||
export const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
__DEV_TELEGRAM_ID: z.string().default(''),
|
__DEV_TELEGRAM_ID: z.string().default(''),
|
||||||
BOT_URL: z.string(),
|
BOT_URL: z.string(),
|
||||||
|
SUPPORT_TELEGRAM_URL: z.string(),
|
||||||
|
URL_OFFER: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { backButton } from '@telegram-apps/sdk-react';
|
|||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
const exclude = ['/pro'];
|
||||||
|
|
||||||
export function useBackButton() {
|
export function useBackButton() {
|
||||||
const { back } = useRouter();
|
const { back } = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -31,6 +33,8 @@ export function useBackButton() {
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRootLevelPage(path: string) {
|
function isRootLevelPage(pathname: string) {
|
||||||
return path.split('/').filter(Boolean).length === 1;
|
if (exclude.includes(pathname)) return false;
|
||||||
|
|
||||||
|
return pathname.split('/').filter(Boolean).length === 1;
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/web/mdx-components.tsx
Normal file
7
apps/web/mdx-components.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { type MDXComponents } from 'mdx/types';
|
||||||
|
|
||||||
|
const components = {} satisfies MDXComponents;
|
||||||
|
|
||||||
|
export function useMDXComponents(): MDXComponents {
|
||||||
|
return components;
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { withAuth } from 'next-auth/middleware';
|
|||||||
|
|
||||||
export default withAuth({
|
export default withAuth({
|
||||||
callbacks: {
|
callbacks: {
|
||||||
authorized: ({ token }) => Boolean(token),
|
authorized: ({ token }) => Boolean(token?.telegramId),
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/',
|
signIn: '/',
|
||||||
@ -11,5 +11,7 @@ export default withAuth({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!auth|browser|telegram|unregistered|api|_next/static|_next/image|favicon.ico).*)'],
|
matcher: [
|
||||||
|
'/((?!auth|browser|telegram|unregistered|privacy|offer|api|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
import createMDX from '@next/mdx';
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin('./utils/i18n/i18n.ts');
|
const nextConfig = createMDX({
|
||||||
|
extension: /\.mdx?$/u,
|
||||||
const nextConfig = withNextIntl({
|
})({
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
mdxRs: true,
|
||||||
|
},
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@repo/ui'],
|
transpilePackages: ['@repo/ui'],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,9 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mdx-js/loader": "^3.1.1",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
|
"@next/mdx": "^15.5.5",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@repo/graphql": "workspace:*",
|
"@repo/graphql": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
@ -22,9 +25,10 @@
|
|||||||
"@tanstack/react-query": "^5.64.1",
|
"@tanstack/react-query": "^5.64.1",
|
||||||
"@telegram-apps/sdk-react": "^2.0.19",
|
"@telegram-apps/sdk-react": "^2.0.19",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
|
"@types/react-dom": "catalog:",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "catalog:",
|
"autoprefixer": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
@ -32,14 +36,13 @@
|
|||||||
"graphql": "catalog:",
|
"graphql": "catalog:",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
|
"next": "^15.5.9",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"next-intl": "^3.26.0",
|
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"next": "^15.5.0",
|
|
||||||
"postcss": "catalog:",
|
"postcss": "catalog:",
|
||||||
"radashi": "catalog:",
|
"radashi": "catalog:",
|
||||||
"react-dom": "catalog:",
|
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"react-dom": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
|
|||||||
@ -2,10 +2,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useBackButton, useClientOnce, useDidMount, useViewport } from '@/hooks/telegram';
|
import { useBackButton, useClientOnce, useDidMount, useViewport } from '@/hooks/telegram';
|
||||||
import { setLocale } from '@/utils/i18n/locale';
|
|
||||||
import { init } from '@/utils/telegram/init';
|
import { init } from '@/utils/telegram/init';
|
||||||
import { initData, useSignal } from '@telegram-apps/sdk-react';
|
import { type PropsWithChildren } from 'react';
|
||||||
import { type PropsWithChildren, useEffect } from 'react';
|
|
||||||
|
|
||||||
export function TelegramProvider(props: Readonly<PropsWithChildren>) {
|
export function TelegramProvider(props: Readonly<PropsWithChildren>) {
|
||||||
// Unfortunately, Telegram Mini Apps does not allow us to use all features of
|
// Unfortunately, Telegram Mini Apps does not allow us to use all features of
|
||||||
@ -13,7 +11,7 @@ export function TelegramProvider(props: Readonly<PropsWithChildren>) {
|
|||||||
// side.
|
// side.
|
||||||
const didMount = useDidMount();
|
const didMount = useDidMount();
|
||||||
|
|
||||||
if (!didMount) return <div>Loading</div>;
|
if (!didMount) return null;
|
||||||
|
|
||||||
return <RootInner {...props} />;
|
return <RootInner {...props} />;
|
||||||
}
|
}
|
||||||
@ -31,12 +29,5 @@ function RootInner({ children }: PropsWithChildren) {
|
|||||||
useViewport();
|
useViewport();
|
||||||
useBackButton();
|
useBackButton();
|
||||||
|
|
||||||
const initDataUser = useSignal(initData.user);
|
|
||||||
|
|
||||||
// Set the user locale.
|
|
||||||
useEffect(() => {
|
|
||||||
if (initDataUser) setLocale(initDataUser.languageCode);
|
|
||||||
}, [initDataUser]);
|
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"i18n": {
|
|
||||||
"header": "Application supports i18n",
|
|
||||||
"footer": "You can select a different language from the dropdown menu."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"i18n": {
|
|
||||||
"header": "Поддержка i18n",
|
|
||||||
"footer": "Вы можете выбрать другой язык в выпадающем меню."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
export const defaultLocale = 'ru';
|
|
||||||
|
|
||||||
export const timeZone = 'Europe/Moscow';
|
|
||||||
|
|
||||||
export const locales = [defaultLocale, 'ru'] as const;
|
|
||||||
|
|
||||||
export const localesMap = [
|
|
||||||
{ key: 'en', title: 'English' },
|
|
||||||
{ key: 'ru', title: 'Русский' },
|
|
||||||
];
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { defaultLocale, locales } from './config';
|
|
||||||
import { getLocale } from './locale';
|
|
||||||
import { type Locale } from './types';
|
|
||||||
import { getRequestConfig } from 'next-intl/server';
|
|
||||||
|
|
||||||
const requestConfig = getRequestConfig(async () => {
|
|
||||||
const locale = (await getLocale()) as Locale;
|
|
||||||
|
|
||||||
return {
|
|
||||||
locale,
|
|
||||||
messages:
|
|
||||||
locale === defaultLocale || !locales.includes(locale)
|
|
||||||
? (await import(`@/public/locales/${defaultLocale}.json`)).default
|
|
||||||
: (await import(`@/public/locales/${locale}.json`)).default,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default requestConfig;
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
// use server is required
|
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { defaultLocale } from './config';
|
|
||||||
import { type Locale } from './types';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
// In this example the locale is read from a cookie. You could alternatively
|
|
||||||
// also read it from a database, backend service, or any other source.
|
|
||||||
const COOKIE_NAME = 'NEXT_LOCALE';
|
|
||||||
|
|
||||||
const getLocale = async () => {
|
|
||||||
return (await cookies()).get(COOKIE_NAME)?.value || defaultLocale;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLocale = async (locale?: string) => {
|
|
||||||
(await cookies()).set(COOKIE_NAME, (locale as Locale) || defaultLocale);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getLocale, setLocale };
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
/* eslint-disable canonical/id-match */
|
|
||||||
import { timeZone } from './config';
|
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
|
||||||
import { getMessages } from 'next-intl/server';
|
|
||||||
import { type PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
export async function I18nProvider({ children }: Readonly<PropsWithChildren>) {
|
|
||||||
const messages = await getMessages();
|
|
||||||
return (
|
|
||||||
<NextIntlClientProvider messages={messages} timeZone={timeZone}>
|
|
||||||
{children}
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { type locales } from './config';
|
|
||||||
|
|
||||||
type Locale = (typeof locales)[number];
|
|
||||||
|
|
||||||
export type { Locale };
|
|
||||||
@ -1,4 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
|
cache-proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./apps/cache-proxy/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
restart: always
|
||||||
web:
|
web:
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@ -6,6 +15,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: ./apps/web/Dockerfile
|
dockerfile: ./apps/web/Dockerfile
|
||||||
restart: always
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- cache-proxy
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
bot:
|
bot:
|
||||||
@ -16,6 +27,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
- cache-proxy
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user