Compare commits

...

35 Commits

Author SHA1 Message Date
vchikalkin
7ecf72656b chore: update type definitions and react dependencies to latest versions 2025-12-30 16:50:24 +03:00
vchikalkin
241effd3b8 fix build 2025-10-27 14:05:20 +03:00
vchikalkin
64c9134cc2 feat: add URL_FAQ environment variable and update bot localization
- Introduced a new environment variable URL_FAQ for FAQ links.
- Updated Russian localization to include a button for FAQ access.
- Modified the main menu to include a URL button for the FAQ section.
2025-10-27 13:43:22 +03:00
vchikalkin
19b53db5f3 refactor: rename OFFER_URL and PRIVACY_URL to URL_OFFER and URL_PRIVACY
- Updated environment variable names for consistency across the application.
- Modified references in the deployment workflow, bot conversations, and web components to use the new variable names.
2025-10-27 13:26:00 +03:00
vchikalkin
a26c0eab8a hotfix: getRemainingOrdersCount: add master to filter 2025-10-16 17:28:59 +03:00
vchikalkin
3ac86cfeb0 mdx: whitespace fix 2025-10-14 20:06:33 +03:00
vchikalkin
d895433a65 chore: update deploy.yml to enhance .env file creation process
- Renamed steps for clarity in the deployment workflow.
- Updated the creation of the .env files to use secrets for sensitive URLs instead of hardcoded values, improving security and flexibility.
2025-10-14 20:01:12 +03:00
vchikalkin
3064887ecf Revert "chore: update deploy.yml to create .env files with improved variable handling"
This reverts commit bdcd11d97e59affe3d16a65e81b142dc486b889e.
2025-10-14 19:59:11 +03:00
vchikalkin
02e9d5c529 refactor: update contact information in offer and privacy documents
- Simplified contact information sections in both the offer and privacy policy documents.
- Removed specific Telegram link references and replaced them with a general note directing users to the bot description for contact details.
2025-10-14 16:42:46 +03:00
vchikalkin
f45140ef04 remove comment 2025-10-14 16:30:13 +03:00
vchikalkin
bdcd11d97e chore: update deploy.yml to create .env files with improved variable handling
- Renamed steps for clarity in the deployment workflow.
- Updated the creation of the fake and real .env files to include necessary environment variables and secrets.
- Removed hardcoded URLs and replaced them with references to secrets for better security and flexibility.
2025-10-14 16:28:01 +03:00
vchikalkin
6a0d34d37b refactor: update MDX link handling and improve document formatting
- Removed the custom link component in MDX and replaced it with standard anchor tags for external links.
- Updated offer and privacy policy documents to use environment variables for dynamic URLs instead of custom components.
- Improved formatting for better readability in the offer and privacy policy sections.
2025-10-14 16:16:30 +03:00
Vlad Chikalkin
2df80c90f6
Feature/mdx (#128)
* convert /documents/privacy to .mdx

* fix: update h2 styling in MDX components

- Changed h2 font weight from bold to semibold for improved visual hierarchy in rendered content.

* fix build

* feat: implement public offer document and layout

- Added a new layout component for the public offer document.
- Created the public offer page in MDX format, detailing terms and conditions for service usage.
- Removed the old offer page in TSX format.
- Updated links for offer and support to a new shared component for better maintainability.
- Integrated Tailwind CSS typography plugin for improved text styling.

* fix: correct formatting in privacy policy terms section

- Adjusted the formatting of terms and definitions in the privacy policy to ensure consistent presentation and clarity.
- Removed unnecessary hyphenation in the definition of "Разработчик" and "Политика" for improved readability.
2025-10-14 15:43:51 +03:00
vchikalkin
03145534a1 feat: update queryTTL for customer-related queries
- Added 12-hour TTL for GetCustomer and GetSubscriptionSettings queries to improve caching strategy.
- Ensured other queries remain unchanged with their TTL set to false.
2025-10-12 17:12:08 +03:00
vchikalkin
6a21f5911e feat: add libphonenumber-js for phone number parsing and validation
- Integrated libphonenumber-js to handle phone number parsing and validation in the addContact and registration features.
- Removed deprecated phone validation and normalization functions from utils.
- Updated contact handling logic to ensure valid phone numbers are processed correctly.
2025-10-12 13:41:48 +03:00
vchikalkin
b88ac07ba3 refactor: simplify profile button logic and remove unused components
- Consolidated booking logic into a single handleBook function based on customer role.
- Removed unused Drawer and Button components to streamline the ProfileButtons component.
- Added loading state handling for improved user experience.
2025-10-12 09:03:30 +03:00
vchikalkin
b517519e7e feat: conditionally render services list based on customer role
- Added a check to prevent rendering the ReadonlyServicesList component for users with the 'Client' role, ensuring a tailored experience based on user permissions.
2025-10-11 14:21:37 +03:00
vchikalkin
a1af00af69 feat: allow order with inactive contacts 2025-10-11 14:09:18 +03:00
vchikalkin
311f6c183d fix: prevent subscription info bar from rendering for client role users
- Added a condition to return null for the SubscriptionInfoBar component if the customer role is 'Client', ensuring that clients do not see the subscription information.
2025-10-11 13:37:37 +03:00
vchikalkin
67cf9a8e26 feat: add subscription_price to subscription history
- Included subscription_price in the subscription history object to enhance tracking of trial subscriptions.
2025-10-11 13:36:54 +03:00
vchikalkin
6080aadc93 chore: add workflow_dispatch trigger to deploy.yml for manual deployment 2025-10-09 18:19:57 +03:00
vchikalkin
ee4e70f43d feat: integrate customer data into subscription invoice
- Added CustomersService to retrieve customer information based on telegramId.
- Enhanced invoice provider data to include customer phone and item details for improved payment processing.
- Updated the subscription function to utilize the new customer data in the invoice generation.
2025-10-09 18:04:38 +03:00
vchikalkin
4d40230864 fix: back button mounts after first app launch 2025-10-09 12:01:06 +03:00
vchikalkin
92119baa5e GQL: rename CheckCustomerExists -> _NOCACHE_GetCustomer 2025-10-09 11:26:28 +03:00
vchikalkin
1e9fd66e27 Refactor customer retrieval in addContact function to use RegistrationService
- Replaced the use of CustomersService.getCustomer with RegistrationService.checkCustomerExists for improved consistency in customer existence checks.
- Introduced a new instance of CustomersService for adding invited users, maintaining functionality while enhancing code clarity.
2025-10-09 09:44:43 +03:00
vchikalkin
aa11ecfcec fix "registrationService.getCustomer is not a function" 2025-10-09 00:33:25 +03:00
vchikalkin
8e61fbbb40 Fix: Refactor customer retrieval logic to use checkCustomerExists method
- Updated the addContact and registration features to replace getCustomer calls with checkCustomerExists, improving consistency in customer existence checks.
- Modified GraphQL operations to remove deprecated getCustomer queries and introduced a new checkCustomerExists query for better clarity and efficiency.
- Adjusted queryTTL configuration in cache-proxy to reflect the removal of unused endpoints.
2025-10-09 00:20:37 +03:00
vchikalkin
d32a7dc54e fix registration error 2025-10-08 22:43:24 +03:00
vchikalkin
0a43d63c2c bot: msg-contact-forward: use parse_mode HTML 2025-10-08 22:04:15 +03:00
vchikalkin
01d8bb20d5 Enhance GitHub Actions workflow for conditional builds and environment file management
- Introduced path filtering using `dorny/paths-filter` to conditionally build and push Docker images for web, bot, and cache-proxy projects based on changes.
- Added outputs to track which projects were built and modified the build and push steps to execute conditionally.
- Created separate environment files for each project tag and implemented logic to merge these files into a main `.env` file during deployment.
- Improved the deployment process by conditionally copying environment files to the VPS based on whether the corresponding project was built.
2025-10-08 18:13:09 +03:00
vchikalkin
20b2b44572 Update queryTTL configuration in cache-proxy to include new endpoints and set default values to false
- Added new entries for `GetCustomers`, `GetInvited`, `GetInvitedBy`, `GetOrders`, `GetServices`, `GetSlots`, and `GetSubscriptionHistory` with default values set to false.
- Removed the `Login` entry from the previous configuration, ensuring clarity in the query time-to-live settings.
2025-10-08 12:14:51 +03:00
vchikalkin
7fe1b89f5b Update PageHeader component to conditionally hide BackButton based on environment and TMA status
- Modified the logic in the PageHeader component to hide the BackButton when in production or when the TMA status is 'simple', improving user experience by reducing unnecessary navigation options.
2025-10-07 19:31:40 +03:00
vchikalkin
4a98ac5d3e Update middleware matcher to exclude 'offer' from routing
- Modified the middleware configuration to exclude the 'offer' path from the matcher, ensuring that requests to this route are handled appropriately.
2025-10-07 19:26:36 +03:00
vchikalkin
f8d0b7619f fix build 2025-10-07 19:17:31 +03:00
Vlad Chikalkin
0b64d8086c
Feature/documents pages (#118)
* apps/web: add generic privacy & offer pages

* Update environment variables and enhance offer and privacy pages

- Added `OFFER_URL` and `SUPPORT_TELEGRAM_URL` to environment variable configuration for better flexibility.
- Updated the offer page to dynamically link to the offer URL and improved contact information presentation with a direct link to the support Telegram.
- Revised the privacy policy page to reflect the service name and updated contact details, ensuring clarity and consistency in communication.

* move offer & privacy -> (documents) sub directory

* Update offer and privacy pages to include user consent for third-party data sharing

- Added a clause in the offer page requiring users to ensure consent from third parties when adding their contact information.
- Updated the privacy policy to clarify that users can share third-party data, emphasizing the need for consent for data processing within the service.

* Add privacy agreement and update environment variables

- Introduced `PRIVACY_URL` to the environment configuration for dynamic linking.
- Updated localization files to include a user consent agreement for sharing phone numbers, linking to the offer and privacy URLs.
- Enhanced welcome and contact addition messages to incorporate the new consent clause, improving user clarity on data handling.

* Refactor phone agreement localization and enhance user consent messaging

- Updated the Russian localization file to streamline the phone sharing agreement, improving clarity on user consent for data processing.
- Modified the contact addition and welcome messages to incorporate the new agreement format, ensuring users are informed about their consent to share personal data.

* Enhance Russian localization and update message formatting

- Added a new payment agreement clause in the Russian localization file, clarifying user consent for payments.
- Updated message formatting in the contact addition and subscription processes to support HTML parsing, improving message presentation and user experience.
- Incorporated the new payment agreement into the subscription flow, ensuring users are informed about their consent to the terms.

* Refactor Russian localization for contact agreements and enhance user consent messaging

- Updated the Russian localization file to separate and clarify the phone sharing and contact sharing agreements, improving user understanding of consent requirements.
- Modified the contact addition and welcome messages to utilize the new agreement format, ensuring users are informed about their consent to share personal data in a more structured manner.

* Enhance Russian localization and add document handling features

- Updated the Russian localization file to include new entries for privacy policy and public offer documents, improving user access to important information.
- Added 'documents' command to the bot's command list, allowing users to easily access document-related features.
- Integrated document handling in the main menu and handlers, enhancing user experience and navigation within the bot.
2025-10-07 19:13:41 +03:00
55 changed files with 2312 additions and 913 deletions

View File

@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- main - main
workflow_dispatch:
jobs: jobs:
build-and-push: build-and-push:
@ -13,11 +14,30 @@ jobs:
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 }} 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
@ -28,6 +48,10 @@ 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
@ -39,29 +63,37 @@ jobs:
- 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 - name: Build cache-proxy image
if: steps.filter.outputs.cache_proxy == 'true'
run: | run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }} -f ./apps/cache-proxy/Dockerfile . 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 - name: Push cache-proxy image to Docker Hub
if: steps.filter.outputs.cache_proxy == 'true'
run: | run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }} docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }}
# -------------------------------------------------
deploy: deploy:
name: Deploy to VPS name: Deploy to VPS
@ -83,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
@ -92,14 +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 "CACHE_PROXY_IMAGE_TAG=${{ needs.build-and-push.outputs.cache_proxy_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 }}
@ -109,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:
@ -119,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
" "

View File

@ -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,8 @@ commands-list =
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
support = support =
{ -support-contact } { -support-contact }
documents =
.description = Документы
# Кнопки # Кнопки
btn-add-contact = 👤 Добавить контакт btn-add-contact = 👤 Добавить контакт
@ -53,9 +55,27 @@ btn-pro = 👑 Pro доступ
btn-subscribe = 👑 Приобрести Pro btn-subscribe = 👑 Приобрести Pro
btn-pro-info = Мой Pro доступ btn-pro-info = Мой Pro доступ
btn-open-app = 📱 Открыть приложение btn-open-app = 📱 Открыть приложение
btn-faq = 📖 Инструкция
btn-documents = 📋 Документы
btn-back = ◀️ Назад btn-back = ◀️ Назад
# Согласие
share-phone-agreement =
<i> Нажимая кнопку <b>«Отправить номер телефона»</b></i>,
<i>вы:
- соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
- подтверждаете согласие на обработку персональных данных согласно <a href='{ $privacyUrl }'>Политике конфиденциальности</a></i>
share-contact-agreement =
<i> Отправляя контакт, имя и номер телефона, вы подтверждаете, что имеете согласие этого человека на передачу его контактных данных и на их обработку в рамках нашего сервиса.
(Пункт 4.5 <a href='{ $privacyUrl }'>Политики конфиденциальности</a>)</i>
payment-agreement =
Совершая оплату, вы соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
agreement-links =
<a href='{ $offerUrl }'>Публичная оферта</a>
<a href='{ $privacyUrl }'>Политика конфиденциальности</a>
# Приветственные сообщения # Приветственные сообщения
msg-welcome = msg-welcome =
👋 Добро пожаловать! 👋 Добро пожаловать!
@ -64,14 +84,14 @@ 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 = ❌ Некорректный номер телефона. Пример: +79999999999 msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999
# Сообщения о контактах # Сообщения о контактах
@ -85,7 +105,7 @@ msg-contact-added =
✅ Добавили { $fullname } в список ваших контактов ✅ Добавили { $fullname } в список ваших контактов
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️ msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i>
# Сообщения для шаринга # Сообщения для шаринга
msg-share-bot = msg-share-bot =
@ -96,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 = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд

View File

@ -29,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:",

View File

@ -1,13 +1,14 @@
/* eslint-disable sonarjs/cognitive-complexity */ /* 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 { env } from '@/config/env';
import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards'; import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact'; 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) {
// Все пользователи могут добавлять контакты // Все пользователи могут добавлять контакты
@ -16,21 +17,39 @@ 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-or-phone'), 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',
},
); );
// Ждём первое сообщение: контакт или текст с номером // Ждём первое сообщение: контакт или текст с номером
@ -41,23 +60,36 @@ export async function addContact(conversation: Conversation<Context, Context>, c
let phone = ''; let phone = '';
if (firstCtx.message?.contact) { if (firstCtx.message?.contact) {
/**
* Отправлен контакт
*/
const { contact } = firstCtx.message; const { contact } = firstCtx.message;
const parsedContact = parseContact(contact); const parsedContact = parseContact(contact);
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
name = parsedContact.name; name = parsedContact.name;
surname = parsedContact.surname; surname = parsedContact.surname;
phone = normalizePhoneNumber(parsedContact.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) { } else if (firstCtx.message?.text) {
const typedPhone = normalizePhoneNumber(firstCtx.message.text); /**
if (!isValidPhoneNumber(typedPhone)) { * Номер в тексте сообщения
*/
const parsedPhone = parsePhoneNumber(firstCtx.message.text, 'RU');
if (!parsedPhone?.isValid() || !parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone'))); return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
} }
// Нельзя добавлять свой собственный номер телефона // Нельзя добавлять свой собственный номер телефона
if (customer.phone && normalizePhoneNumber(customer.phone) === typedPhone) { if (customer.phone && customer.phone === parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('err-cannot-add-self'))); return ctx.reply(await conversation.external(({ t }) => t('err-cannot-add-self')));
} }
phone = typedPhone; phone = parsedPhone.number;
// Просим ввести имя клиента // Просим ввести имя клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-name'))); await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-name')));
@ -82,19 +114,15 @@ export async function addContact(conversation: Conversation<Context, Context>, c
return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone'))); return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone')));
} }
// Проверяем валидность номера телефона
if (!isValidPhoneNumber(phone)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-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({
data: { name, phone, surname }, data: { name, phone, surname },
}); });
@ -105,6 +133,7 @@ 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 });
// Отправляем подтверждения и инструкции // Отправляем подтверждения и инструкции
@ -113,7 +142,9 @@ export async function addContact(conversation: Conversation<Context, Context>, c
t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }), t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }),
), ),
); );
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward'))); 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(

View File

@ -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';
@ -66,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 },
); );
// ждём выбора // ждём выбора
@ -95,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(
@ -110,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',
}, },

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

View File

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

View File

@ -2,10 +2,9 @@ import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards'; import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards';
import { parseContact } from '@/utils/contact'; import { parseContact } from '@/utils/contact';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration'; import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
import parsePhoneNumber from 'libphonenumber-js';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -18,8 +17,10 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
const { name, surname } = parseContact(contact); 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'), {
@ -34,15 +35,15 @@ 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) {
// Пользователь добавлен ранее мастером — обновляем данные // Пользователь добавлен ранее мастером — обновляем данные
@ -58,7 +59,7 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
// Новый пользователь — создаём и активируем // Новый пользователь — создаём и активируем
const response = await registrationService.createCustomer({ const response = await registrationService.createCustomer({
data: { name, phone, surname, telegramId }, data: { name, phone: parsedPhone.number, surname, telegramId },
}); });
const documentId = response?.createCustomer?.documentId; const documentId = response?.createCustomer?.documentId;

View File

@ -1,6 +1,8 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { env } from '@/config/env';
import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards'; import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards';
import { combine } from '@/utils/messages';
import { RegistrationService } from '@repo/graphql/api/registration'; import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
@ -12,7 +14,7 @@ feature.command('start', logHandle('command-start'), async (ctx) => {
const telegramId = ctx.from.id; const telegramId = ctx.from.id;
const registrationService = new RegistrationService(); const registrationService = new RegistrationService();
const { customer } = await registrationService.getCustomer({ telegramId }); const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
if (customer) { if (customer) {
// Пользователь уже зарегистрирован — приветствуем // Пользователь уже зарегистрирован — приветствуем
@ -22,7 +24,19 @@ feature.command('start', logHandle('command-start'), async (ctx) => {
} }
// Новый пользователь — просим поделиться номером // Новый пользователь — просим поделиться номером
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 };

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,12 @@
import { env } from './env'; import { env } from './env';
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { handleAddContact, handlePro, handleShareBot, handleSubscribe } from '@/bot/handlers'; import {
handleAddContact,
handleDocuments,
handlePro,
handleShareBot,
handleSubscribe,
} from '@/bot/handlers';
import { Menu } from '@grammyjs/menu'; import { Menu } from '@grammyjs/menu';
import { import {
type InlineKeyboardMarkup, type InlineKeyboardMarkup,
@ -50,6 +56,13 @@ export const mainMenu = new Menu<Context>('main-menu', { autoAnswer: true })
.row() .row()
.text((ctx) => ctx.t('btn-share-bot'), handleShareBot) .text((ctx) => ctx.t('btn-share-bot'), handleShareBot)
.row() .row()
.text((ctx) => ctx.t('btn-documents'), handleDocuments)
.row()
.url(
(ctx) => ctx.t('btn-faq'),
() => env.URL_FAQ,
)
.row()
.url( .url(
(ctx) => ctx.t('btn-open-app'), (ctx) => ctx.t('btn-open-app'),
() => { () => {

View File

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

View File

@ -1,13 +1,16 @@
import { seconds } from 'src/utils/time'; import { seconds } from 'src/utils/time';
export const queryTTL: Record<string, number | false> = { export const queryTTL: Record<string, number | false> = {
Login: false, GetCustomer: seconds().fromHours(12),
GetCustomer: seconds().fromHours(24), GetCustomers: false,
GetOrder: seconds().fromHours(24), GetInvited: false,
GetService: seconds().fromHours(24), GetInvitedBy: false,
GetSlot: seconds().fromHours(24), GetOrders: false,
GetServices: false,
GetSlots: false,
GetSlotsOrders: false, GetSlotsOrders: false,
GetSubscriptionPrices: seconds().fromHours(24), GetSubscriptionHistory: false,
GetSubscriptions: seconds().fromHours(24), GetSubscriptions: false,
GetSubscriptionSettings: seconds().fromHours(1), GetSubscriptionSettings: seconds().fromHours(12),
Login: false,
}; };

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

View File

@ -15,8 +15,8 @@ import {
import type { Cache } from 'cache-manager'; import type { Cache } from 'cache-manager';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { env } from 'src/config/env'; import { env } from 'src/config/env';
import { queryTTL } from './lib/config';
import { extractDocumentId, getQueryType } from 'src/utils/query'; import { extractDocumentId, getQueryType } from 'src/utils/query';
import { getQueryTTL } from './lib/utils';
type RedisStore = Omit<Cache, 'set'> & { type RedisStore = Omit<Cache, 'set'> & {
set: (key: string, value: unknown, { ttl }: { ttl: number }) => Promise<void>; set: (key: string, value: unknown, { ttl }: { ttl: number }) => Promise<void>;
@ -78,9 +78,9 @@ export class ProxyController {
} }
} }
const ttl = queryTTL[operationName]; const ttl = getQueryTTL(operationName);
if (queryType.action === 'query' && data && ttl !== false) if (queryType.action === 'query' && data && ttl !== false)
await this.cacheManager.set(key, data, { ttl: ttl || env.CACHE_TTL }); await this.cacheManager.set(key, data, { ttl });
return reply.send(data); return reply.send(data);
} }

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

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

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

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

View File

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

View 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" />
</>
);
}

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

View File

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

View File

@ -26,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);
@ -145,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 {
@ -156,16 +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: [ contacts: [{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment].concat(
{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment, showInactive ? contacts : contacts.filter((contact) => contact.active),
...contacts, ),
],
}; };
} }

View File

@ -1,94 +1,35 @@
/* eslint-disable canonical/id-match */
'use client'; 'use client';
import FloatingActionPanel from '@/components/shared/action-panel'; import FloatingActionPanel from '@/components/shared/action-panel';
import { useCustomerQuery } from '@/hooks/api/customers'; import { useCustomerQuery } from '@/hooks/api/customers';
import { usePushWithData } from '@/hooks/url'; import { usePushWithData } from '@/hooks/url';
import { Button } from '@repo/ui/components/ui/button'; import { Enum_Customer_Role } from '@repo/graphql/types';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@repo/ui/components/ui/drawer';
import { useState } from 'react';
type QuickAppointmentProps = { type QuickAppointmentProps = {
readonly telegramId: number; readonly telegramId: number;
}; };
export function ProfileButtons({ telegramId }: Readonly<QuickAppointmentProps>) { export function ProfileButtons({ telegramId }: Readonly<QuickAppointmentProps>) {
const [isPanelOpen, setIsPanelOpen] = useState(false);
const push = usePushWithData(); const push = usePushWithData();
const { data: { customer: profile } = {} } = useCustomerQuery({ telegramId }); const { data: { customer: profile } = {}, isLoading: isLoadingProfile } = useCustomerQuery({
const { data: { customer: currentUser } = {} } = useCustomerQuery(); telegramId,
});
const { data: { customer: currentUser } = {}, isLoading: isLoadingCurrentUser } =
useCustomerQuery();
const handleBookAsClient = () => { const isLoading = isLoadingProfile || isLoadingCurrentUser;
push('/orders/add', {
client: currentUser,
slot: { master: profile },
});
};
const handleBookAsMaster = () => { const handleBook = () => {
push('/orders/add', { if (profile?.role === Enum_Customer_Role.Client) {
client: profile, push('/orders/add', { client: profile, slot: { master: currentUser } });
slot: { master: currentUser }, } else {
}); push('/orders/add', { client: currentUser, slot: { master: profile } });
}
}; };
if (!telegramId) return null; if (!telegramId) return null;
return ( return <FloatingActionPanel isLoading={isLoading} onQuickBook={handleBook} />;
<>
<FloatingActionPanel onQuickBook={() => setIsPanelOpen(true)} />
<Drawer onOpenChange={setIsPanelOpen} open={isPanelOpen}>
<DrawerTrigger asChild>
<div />
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Быстрая запись</DrawerTitle>
<DrawerDescription>Выберите действие</DrawerDescription>
</DrawerHeader>
<div className="p-4 pt-0">
<div className="flex flex-col gap-3">
<DrawerClose asChild>
<Button
className="w-full text-sm"
disabled={!profile || !currentUser}
onClick={handleBookAsClient}
size="sm"
>
Записаться к мастеру {profile?.name}
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button
className="w-full text-sm"
disabled={!profile || !currentUser}
onClick={handleBookAsMaster}
size="sm"
variant="secondary"
>
Записать клиента к себе
</Button>
</DrawerClose>
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Отмена</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</>
);
} }

View File

@ -1,3 +1,4 @@
/* eslint-disable canonical/id-match */
'use client'; 'use client';
import { type ProfileProps } from '../types'; import { type ProfileProps } from '../types';
@ -5,13 +6,17 @@ 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';
// Компонент для отображения услуг мастера (без ссылок, только просмотр) // Компонент для отображения услуг мастера (без ссылок, только просмотр)
export function ReadonlyServicesList({ telegramId }: Readonly<ProfileProps>) { export function ReadonlyServicesList({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {} } = useCustomerQuery({ telegramId });
const { isLoading, services } = useServices(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">
<h1 className="font-bold">Услуги</h1> <h1 className="font-bold">Услуги</h1>

View File

@ -15,6 +15,8 @@ export function SubscriptionInfoBar() {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery(); const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
if (customer?.role === Enum_Customer_Role.Client) return null;
const isLoading = isLoadingCustomer || isLoadingSubscription || isLoadingSubscriptionSetting; const isLoading = isLoadingCustomer || isLoadingSubscription || isLoadingSubscriptionSetting;
const isActive = data?.hasActiveSubscription; const isActive = data?.hasActiveSubscription;
@ -36,8 +38,6 @@ export function SubscriptionInfoBar() {
if (!subscriptionSetting?.proEnabled) return null; if (!subscriptionSetting?.proEnabled) return null;
if (customer?.role === Enum_Customer_Role.Client) 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')}>

View File

@ -4,7 +4,7 @@ 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 { 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;
@ -12,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',
@ -37,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>
); );
}); });

View File

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

View File

@ -34,7 +34,7 @@ export function useBackButton() {
} }
function isRootLevelPage(pathname: string) { function isRootLevelPage(pathname: string) {
if (exclude.some((path) => pathname.includes(path))) return false; if (exclude.includes(pathname)) return false;
return pathname.split('/').filter(Boolean).length === 1; return pathname.split('/').filter(Boolean).length === 1;
} }

View File

@ -0,0 +1,7 @@
import { type MDXComponents } from 'mdx/types';
const components = {} satisfies MDXComponents;
export function useMDXComponents(): MDXComponents {
return components;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
{
"i18n": {
"header": "Application supports i18n",
"footer": "You can select a different language from the dropdown menu."
}
}

View File

@ -1,6 +0,0 @@
{
"i18n": {
"header": "Поддержка i18n",
"footer": "Вы можете выбрать другой язык в выпадающем меню."
}
}

View File

@ -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: 'Русский' },
];

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
import { type locales } from './config';
type Locale = (typeof locales)[number];
export type { Locale };

View File

@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-commented-code */
/* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import { ERRORS as SHARED_ERRORS } from '../constants/errors'; import { ERRORS as SHARED_ERRORS } from '../constants/errors';
@ -283,10 +284,10 @@ export class OrdersService extends BaseService {
if (!clientEntity) throw new Error(ERRORS.NOT_FOUND_CLIENT); if (!clientEntity) throw new Error(ERRORS.NOT_FOUND_CLIENT);
// Проверка активности клиента // // Проверка активности клиента
if (!clientEntity?.active) { // if (!clientEntity?.active) {
throw new Error(ERRORS.INACTIVE_CLIENT); // throw new Error(ERRORS.INACTIVE_CLIENT);
} // }
// Получаем мастера слота // Получаем мастера слота
const slotMaster = slot.master; const slotMaster = slot.master;

View File

@ -2,11 +2,23 @@ import { getClientWithToken } from '../apollo/client';
import { ERRORS } from '../constants/errors'; import { ERRORS } from '../constants/errors';
import * as GQL from '../types'; import * as GQL from '../types';
import { type VariablesOf } from '@graphql-typed-document-node/core'; import { type VariablesOf } from '@graphql-typed-document-node/core';
import { isCustomerBanned } from '@repo/utils/customer';
const DEFAULT_CUSTOMER_ROLE = GQL.Enum_Customer_Role.Client; const DEFAULT_CUSTOMER_ROLE = GQL.Enum_Customer_Role.Client;
export class RegistrationService { export class RegistrationService {
async _NOCACHE_GetCustomer(variables: VariablesOf<typeof GQL._Nocache_GetCustomerDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL._Nocache_GetCustomerDocument,
variables,
});
const customer = result.data.customers.at(0);
return { customer };
}
async createCustomer(variables: VariablesOf<typeof GQL.CreateCustomerDocument>) { async createCustomer(variables: VariablesOf<typeof GQL.CreateCustomerDocument>) {
const { mutate } = await getClientWithToken(); const { mutate } = await getClientWithToken();
@ -27,34 +39,8 @@ export class RegistrationService {
return mutationResult.data; return mutationResult.data;
} }
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetCustomerDocument,
variables,
});
const customer = result.data.customers.at(0);
return { customer };
}
async updateCustomer(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) { async updateCustomer(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
// Проверяем бан для существующего пользователя if (variables.data.bannedUntil || variables.data.phone) {
if (variables.documentId) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetCustomerDocument,
variables: { documentId: variables.documentId },
});
const customer = result.data.customers.at(0);
if (customer && isCustomerBanned(customer)) {
throw new Error(ERRORS.NO_PERMISSION);
}
}
if (variables.data.bannedUntil) {
throw new Error(ERRORS.NO_PERMISSION); throw new Error(ERRORS.NO_PERMISSION);
} }

View File

@ -170,6 +170,7 @@ export class SubscriptionsService extends BaseService {
source: GQL.Enum_Subscriptionhistory_Source.Trial, source: GQL.Enum_Subscriptionhistory_Source.Trial,
state: GQL.Enum_Subscriptionhistory_State.Success, state: GQL.Enum_Subscriptionhistory_State.Success,
subscription: subscription.documentId, subscription: subscription.documentId,
subscription_price: trialPrice.documentId,
}, },
}); });
@ -340,6 +341,8 @@ export class SubscriptionsService extends BaseService {
const now = dayjs(); const now = dayjs();
const { customer } = await this._getUser();
const { orders } = await ordersService.getOrders({ const { orders } = await ordersService.getOrders({
filters: { filters: {
datetime_end: { datetime_end: {
@ -348,6 +351,13 @@ export class SubscriptionsService extends BaseService {
datetime_start: { datetime_start: {
gte: now.startOf('month').toISOString(), gte: now.startOf('month').toISOString(),
}, },
slot: {
master: {
documentId: {
eq: customer.documentId,
},
},
},
state: { state: {
eq: GQL.Enum_Order_State.Completed, eq: GQL.Enum_Order_State.Completed,

View File

@ -20,20 +20,20 @@ mutation CreateCustomer($data: CustomerInput!) {
} }
} }
query GetCustomer($phone: String, $telegramId: Long, $documentId: ID) { query GetCustomer($telegramId: Long, $documentId: ID) {
customers( customers(
filters: { filters: { or: [{ telegramId: { eq: $telegramId } }, { documentId: { eq: $documentId } }] }
or: [
{ phone: { eq: $phone } }
{ telegramId: { eq: $telegramId } }
{ documentId: { eq: $documentId } }
]
}
) { ) {
...CustomerFields ...CustomerFields
} }
} }
query _NOCACHE_GetCustomer($phone: String, $telegramId: Long) {
customers(filters: { or: [{ phone: { eq: $phone } }, { telegramId: { eq: $telegramId } }] }) {
...CustomerFields
}
}
mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) { mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) {
updateCustomer(documentId: $documentId, data: $data) { updateCustomer(documentId: $documentId, data: $data) {
...CustomerFields ...CustomerFields

View File

@ -747,7 +747,6 @@ export type CreateCustomerMutationVariables = Exact<{
export type CreateCustomerMutation = { __typename?: 'Mutation', createCustomer?: { __typename?: 'Customer', documentId: string } | null | undefined }; export type CreateCustomerMutation = { __typename?: 'Mutation', createCustomer?: { __typename?: 'Customer', documentId: string } | null | undefined };
export type GetCustomerQueryVariables = Exact<{ export type GetCustomerQueryVariables = Exact<{
phone?: InputMaybe<Scalars['String']['input']>;
telegramId?: InputMaybe<Scalars['Long']['input']>; telegramId?: InputMaybe<Scalars['Long']['input']>;
documentId?: InputMaybe<Scalars['ID']['input']>; documentId?: InputMaybe<Scalars['ID']['input']>;
}>; }>;
@ -755,6 +754,14 @@ export type GetCustomerQueryVariables = Exact<{
export type GetCustomerQuery = { __typename?: 'Query', customers: Array<{ __typename?: 'Customer', active?: boolean | null | undefined, bannedUntil?: string | null | undefined, documentId: string, name: string, surname?: string | null | undefined, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string } | null | undefined> } | null | undefined> }; export type GetCustomerQuery = { __typename?: 'Query', customers: Array<{ __typename?: 'Customer', active?: boolean | null | undefined, bannedUntil?: string | null | undefined, documentId: string, name: string, surname?: string | null | undefined, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string } | null | undefined> } | null | undefined> };
export type _Nocache_GetCustomerQueryVariables = Exact<{
phone?: InputMaybe<Scalars['String']['input']>;
telegramId?: InputMaybe<Scalars['Long']['input']>;
}>;
export type _Nocache_GetCustomerQuery = { __typename?: 'Query', customers: Array<{ __typename?: 'Customer', active?: boolean | null | undefined, bannedUntil?: string | null | undefined, documentId: string, name: string, surname?: string | null | undefined, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string } | null | undefined> } | null | undefined> };
export type UpdateCustomerMutationVariables = Exact<{ export type UpdateCustomerMutationVariables = Exact<{
documentId: Scalars['ID']['input']; documentId: Scalars['ID']['input'];
data: CustomerInput; data: CustomerInput;
@ -976,7 +983,8 @@ export const SubscriptionRewardFieldsFragmentDoc = {"kind":"Document","definitio
export const RegisterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Register"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"register"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"username"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jwt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]}}]} as unknown as DocumentNode<RegisterMutation, RegisterMutationVariables>; export const RegisterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Register"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"register"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"username"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jwt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]}}]} as unknown as DocumentNode<RegisterMutation, RegisterMutationVariables>;
export const LoginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Login"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"identifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jwt"}}]}}]}}]} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>; export const LoginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Login"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"identifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identifier"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jwt"}}]}}]}}]} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>;
export const CreateCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createCustomer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}}]}}]}}]} as unknown as DocumentNode<CreateCustomerMutation, CreateCustomerMutationVariables>; export const CreateCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createCustomer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}}]}}]}}]} as unknown as DocumentNode<CreateCustomerMutation, CreateCustomerMutationVariables>;
export const GetCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"phone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"phone"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"phone"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"documentId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<GetCustomerQuery, GetCustomerQueryVariables>; export const GetCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"documentId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<GetCustomerQuery, GetCustomerQueryVariables>;
export const _Nocache_GetCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"_NOCACHE_GetCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"phone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"phone"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"phone"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<_Nocache_GetCustomerQuery, _Nocache_GetCustomerQueryVariables>;
export const UpdateCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCustomer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"documentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<UpdateCustomerMutation, UpdateCustomerMutationVariables>; export const UpdateCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCustomer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"documentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<UpdateCustomerMutation, UpdateCustomerMutationVariables>;
export const GetCustomersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCustomers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerFiltersInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationArg"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<GetCustomersQuery, GetCustomersQueryVariables>; export const GetCustomersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCustomers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerFiltersInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationArg"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<GetCustomersQuery, GetCustomersQueryVariables>;
export const GetInvitedByDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetInvitedBy"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"phone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"phone"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"phone"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"documentId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<GetInvitedByQuery, GetInvitedByQueryVariables>; export const GetInvitedByDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetInvitedBy"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"phone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"phone"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"phone"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"documentId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"bannedUntil"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"surname"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<GetInvitedByQuery, GetInvitedByQueryVariables>;

View File

@ -40,6 +40,7 @@
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@tailwindcss/typography": "^0.5.19",
"@types/react": "catalog:", "@types/react": "catalog:",
"autoprefixer": "catalog:", "autoprefixer": "catalog:",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -1,3 +1,4 @@
import typography from '@tailwindcss/typography';
import { type Config } from 'tailwindcss'; import { type Config } from 'tailwindcss';
import tailwindcssAnimate from 'tailwindcss-animate'; import tailwindcssAnimate from 'tailwindcss-animate';
@ -5,12 +6,12 @@ const config = {
content: [ content: [
'./pages/**/*.{ts,tsx}', './pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}', './components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}', './app/**/*.{ts,tsx,mdx}',
'./src/**/*.{ts,tsx}', './src/**/*.{ts,tsx}',
'../../packages/ui/src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}',
], ],
darkMode: ['class'], darkMode: ['class'],
plugins: [tailwindcssAnimate], plugins: [tailwindcssAnimate, typography],
prefix: '', prefix: '',
theme: { theme: {
container: { container: {

2142
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@ packages:
catalog: catalog:
"@apollo/client": ^3.12.4 "@apollo/client": ^3.12.4
"@types/node": ^20 "@types/node": ^20
"@types/react": ^19.1.11 "@types/react": ^19.1.17
"@types/react-dom": ^19.1.8 "@types/react-dom": ^19.1.17
"@vchikalkin/eslint-config-awesome": ^2.2.2 "@vchikalkin/eslint-config-awesome": ^2.2.2
autoprefixer: ^10.4.20 autoprefixer: ^10.4.20
dayjs: ^1.11.3 dayjs: ^1.11.3
@ -19,8 +19,8 @@ catalog:
postcss: ^8.4.49 postcss: ^8.4.49
postcss-load-config: ^6.0.1 postcss-load-config: ^6.0.1
prettier: ^3.2.5 prettier: ^3.2.5
react: ^19.1.1 react: ^19.1.4
react-dom: ^19.1.1 react-dom: ^19.1.4
radashi: ^12.5.1 radashi: ^12.5.1
rimraf: ^6.0.1 rimraf: ^6.0.1
tailwindcss: ^3.4.15 tailwindcss: ^3.4.15

View File

@ -13,10 +13,14 @@
"BOT_TOKEN", "BOT_TOKEN",
"NEXTAUTH_SECRET", "NEXTAUTH_SECRET",
"BOT_URL", "BOT_URL",
"SUPPORT_TELEGRAM_URL",
"BOT_PROVIDER_TOKEN", "BOT_PROVIDER_TOKEN",
"REDIS_HOST", "REDIS_HOST",
"REDIS_PORT", "REDIS_PORT",
"REDIS_PASSWORD" "REDIS_PASSWORD",
"URL_OFFER",
"URL_PRIVACY",
"URL_FAQ"
] ]
}, },
"lint": { "lint": {