Compare commits

..

11 Commits

Author SHA1 Message Date
vchikalkin
e3e9f1cf0d Refactor subscription settings naming
- Updated the naming of `getSubscriptionSettings` to `GetSubscriptionSettings` in the cache-proxy configuration for consistency with other settings.
2025-10-06 23:20:57 +03:00
vchikalkin
17528d12c7 fix build 2025-10-06 23:16:06 +03:00
vchikalkin
7fcf55abda Enhance subscription management and configuration settings
- Added new query time-to-live settings for `GetSlotsOrders`, `GetSubscriptionPrices`, and `GetSubscriptions` to improve caching strategy.
- Implemented `hasTrialSubscription` method in `SubscriptionsService` to check for trial subscriptions based on user history, enhancing subscription management capabilities.
- Updated GraphQL operations to reflect the change from `getSubscriptionSettings` to `GetSubscriptionSettings`, ensuring consistency in naming conventions.
2025-10-06 22:21:23 +03:00
vchikalkin
b43d166b2e Update cache proxy configuration for query time-to-live settings
- Increased the time-to-live for `GetCustomer`, `GetOrder`, `GetService`, and `GetSlot` queries to 24 hours, enhancing cache efficiency and performance.
- Maintained the existing setting for `GetSubscriptions`, which remains at 12 hours.
2025-10-06 21:59:19 +03:00
vchikalkin
0039de3499 Update proxy controller and environment variable for cache-proxy service
- Changed the route prefix of the ProxyController from `/proxy` to `/api` to align with the new API structure.
- Updated the default value of the `URL_GRAPHQL_CACHED` environment variable to reflect the new route, ensuring proper integration with the GraphQL service.
2025-10-06 21:54:08 +03:00
vchikalkin
eef1338cd2 Add health check endpoint and controller to cache-proxy service
- Implemented a new HealthController in the cache-proxy application to provide a health check endpoint at `/api/health`, returning a simple status response.
- Updated the AppModule to include the HealthController, ensuring it is registered within the application.
- Configured a health check in the Docker Compose file for the cache-proxy service, allowing for automated health monitoring.
2025-10-06 21:50:03 +03:00
vchikalkin
50ef49d01f Add cache-proxy service to Docker configurations and update deployment workflow</message>
<message>
- Introduced a new cache-proxy service in both `docker-compose.dev.yml` and `docker-compose.yml`, with dependencies on Redis and integration into the web and bot services.
- Updated GitHub Actions workflow to include build and push steps for the cache-proxy image, ensuring it is deployed alongside web and bot services.
- Modified environment variable management to accommodate the cache-proxy, enhancing the overall deployment process.
- Adjusted GraphQL cached URL to point to the cache-proxy service for improved request handling.
2025-10-06 20:10:30 +03:00
vchikalkin
b8b8ca6004 Add cache-proxy application with initial setup and configuration
- Created a new cache-proxy application using NestJS, including essential files such as Dockerfile, .gitignore, and .eslintrc.js.
- Implemented core application structure with AppModule, ProxyModule, and ProxyController for handling GraphQL requests.
- Configured caching with Redis and established environment variable management using Zod for validation.
- Added utility functions for query handling and time management, enhancing the application's functionality.
- Included README.md for project documentation and setup instructions.
2025-10-06 20:01:18 +03:00
vchikalkin
9d9ba6540b Refactor Apollo Client setup to improve modularity and maintainability
- Introduced a new `createLink` function to encapsulate the link creation logic for Apollo Client.
- Updated `createApolloClient` to utilize the new `createLink` function, enhancing code organization.
- Simplified the handling of authorization headers by moving it to the link configuration.
2025-10-02 11:52:14 +03:00
vchikalkin
54f69f7c36 Refactor UpdateProfile component to use useClientOnce for initial profile update
- Replaced useEffect with useClientOnce to handle the first login profile update more efficiently.
- Removed local state management for hasUpdated, simplifying the component logic.
- Updated localStorage handling to ensure the profile update occurs only on the first login.
2025-10-01 16:52:23 +03:00
vchikalkin
8cb283d4ba Refactor GraphQL client usage in services to improve consistency
- Replaced direct calls to `getClientWithToken` with a new method `getGraphQLClient` across multiple services, ensuring a unified approach to obtaining the GraphQL client.
- Updated the `BaseService` to manage the GraphQL client instance, enhancing performance by reusing the client when available.
2025-09-30 19:02:11 +03:00
78 changed files with 905 additions and 2960 deletions

View File

@ -4,7 +4,6 @@ on:
push:
branches:
- main
workflow_dispatch:
jobs:
build-and-push:
@ -14,30 +13,11 @@ jobs:
web_tag: ${{ steps.vars.outputs.web_tag }}
bot_tag: ${{ steps.vars.outputs.bot_tag }}
cache_proxy_tag: ${{ steps.vars.outputs.cache_proxy_tag }}
# Добавляем output-ы для отслеживания, какие проекты были собраны
web_built: ${{ steps.filter.outputs.web }}
bot_built: ${{ steps.filter.outputs.bot }}
cache_proxy_built: ${{ steps.filter.outputs.cache_proxy }}
steps:
- name: Checkout code
uses: actions/checkout@v3
# --- НОВОЕ: Шаг 1: dorny/paths-filter для условной сборки ---
- name: Filter changed paths
uses: dorny/paths-filter@v2
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/**'
bot:
- 'apps/bot/**'
- 'packages/**'
cache_proxy:
- 'apps/cache-proxy/**'
# -----------------------------------------------------------
- name: Create .env file for build
- name: Create fake .env file for build
run: |
echo "BOT_TOKEN=fake" > .env
echo "LOGIN_GRAPHQL=fake" >> .env
@ -48,10 +28,6 @@ jobs:
echo "BOT_URL=http://localhost:3000" >> .env
echo "REDIS_PASSWORD=fake" >> .env
echo "BOT_PROVIDER_TOKEN=fake" >> .env
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
- name: Set image tags
id: vars
@ -63,37 +39,29 @@ jobs:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
# --- ИЗМЕНЕНО: Условное выполнение Build/Push ---
- name: Build web image
if: steps.filter.outputs.web == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} -f ./apps/web/Dockerfile .
- name: Push web image to Docker Hub
if: steps.filter.outputs.web == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }}
- name: Build bot image
if: steps.filter.outputs.bot == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} -f ./apps/bot/Dockerfile .
- name: Push bot image to Docker Hub
if: steps.filter.outputs.bot == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }}
- name: Build cache-proxy image
if: steps.filter.outputs.cache_proxy == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }} -f ./apps/cache-proxy/Dockerfile .
- name: Push cache-proxy image to Docker Hub
if: steps.filter.outputs.cache_proxy == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }}
# -------------------------------------------------
deploy:
name: Deploy to VPS
@ -115,10 +83,8 @@ jobs:
run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p /home/${{ secrets.VPS_USER }}/zapishis"
# --- НОВОЕ: Шаг 2: Создание основного .env БЕЗ ТЕГОВ ---
- name: Create .env file for deploy
- name: Create real .env file for production
run: |
# Включаем все секреты, КРОМЕ тегов
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
@ -126,26 +92,14 @@ jobs:
echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .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 "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
# --- НОВОЕ: Шаг 3: Создание файлов тегов (.project.env) ---
- name: Create Project Tag Env Files
run: |
# Создаем файлы, которые будут содержать только одну переменную с тегом
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" > .env.web
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" > .env.bot
echo "CACHE_PROXY_IMAGE_TAG=${{ needs.build-and-push.outputs.cache_proxy_tag }}" > .env.cache-proxy
# --- Шаг 4: Копирование .env и УСЛОВНОЕ копирование тегов ---
# Копируем основной .env всегда
- name: Copy .env to VPS via SCP (Always)
- name: Copy .env to VPS via SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
@ -155,42 +109,6 @@ jobs:
source: '.env'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.web ТОЛЬКО, если web был собран (обновляем тег на VPS)
- name: Copy .env.web to VPS
if: ${{ needs.build-and-push.outputs.web_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.web'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.bot ТОЛЬКО, если bot был собран
- name: Copy .env.bot to VPS
if: ${{ needs.build-and-push.outputs.bot_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.bot'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.cache-proxy ТОЛЬКО, если cache-proxy был собран
- name: Copy .env.cache-proxy to VPS
if: ${{ needs.build-and-push.outputs.cache_proxy_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.cache-proxy'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
- name: Copy docker-compose.yml to VPS via SCP
uses: appleboy/scp-action@master
with:
@ -201,27 +119,12 @@ jobs:
source: 'docker-compose.yml'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# --- ФИНАЛЬНЫЙ ДЕПЛОЙ ---
- name: Login and deploy on VPS
run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
cd /home/${{ secrets.VPS_USER }}/zapishis && \
# 1. Объединение ВСЕХ ENV-файлов в один основной .env
# Теги из .env.web/.env.bot переопределят любые старые/пустые значения,
# и .env станет полным и актуальным.
echo \"Merging environment files into .env...\" && \
cat .env .env.web .env.bot .env.cache-proxy > .temp_env && \
mv .temp_env .env && \
# 2. Логин
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
# 3. Pull ВСЕХ сервисов (Docker Compose автоматически использует обновленный .env)
echo \"Pulling all services...\" && \
docker compose pull
# 4. Перезапуск
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
docker compose pull && \
docker compose down && \
docker compose up -d
"

View File

@ -25,7 +25,7 @@ description =
start =
.description = Запуск бота
addcontact =
.description = Добавить контакт
.description = Добавить контакт пользователя
sharebot =
.description = Поделиться ботом
subscribe =
@ -36,7 +36,7 @@ help =
.description = Список команд и поддержка
commands-list =
📋 Доступные команды:
• /addcontact — добавить контакт
• /addcontact — добавить контакт пользователя
• /sharebot — поделиться ботом
• /subscribe — приобрести Pro доступ
• /pro — информация о вашем Pro доступе
@ -45,8 +45,6 @@ commands-list =
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
support =
{ -support-contact }
documents =
.description = Документы
# Кнопки
btn-add-contact = 👤 Добавить контакт
@ -55,27 +53,9 @@ btn-pro = 👑 Pro доступ
btn-subscribe = 👑 Приобрести Pro
btn-pro-info = Мой Pro доступ
btn-open-app = 📱 Открыть приложение
btn-faq = 📖 Инструкция
btn-documents = 📋 Документы
btn-back = ◀️ Назад
# Согласие
share-phone-agreement =
<i> Нажимая кнопку <b>«Отправить номер телефона»</b></i>,
<i>вы:
- соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
- подтверждаете согласие на обработку персональных данных согласно <a href='{ $privacyUrl }'>Политике конфиденциальности</a></i>
share-contact-agreement =
<i> Отправляя контакт, имя и номер телефона, вы подтверждаете, что имеете согласие этого человека на передачу его контактных данных и на их обработку в рамках нашего сервиса.
(Пункт 4.5 <a href='{ $privacyUrl }'>Политики конфиденциальности</a>)</i>
payment-agreement =
Совершая оплату, вы соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
agreement-links =
<a href='{ $offerUrl }'>Публичная оферта</a>
<a href='{ $privacyUrl }'>Политика конфиденциальности</a>
# Приветственные сообщения
msg-welcome =
👋 Добро пожаловать!
@ -84,14 +64,14 @@ msg-welcome-back = 👋 С возвращением, { $name }!
# Сообщения о телефоне
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона.
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
msg-phone-saved =
✅ Спасибо! Мы сохранили ваш номер телефона
Теперь вы можете открыть приложение или воспользоваться командами бота
msg-already-registered =
✅ Вы уже зарегистрированы в системе
<i>Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)</i>
Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999
# Сообщения о контактах
@ -99,13 +79,12 @@ msg-send-client-contact = 👤 Отправьте контакт пользов
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
msg-contact-added =
✅ Добавили { $fullname } в список ваших контактов
✅ Добавили { $name } в список ваших контактов
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи
msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i>
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
# Сообщения для шаринга
msg-share-bot =
@ -116,7 +95,7 @@ msg-share-bot =
# Системные сообщения
msg-cancel = ❌ Операция отменена
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
msg-cancel-operation = <i>Для отмены операции используйте команду /cancel</i>
msg-cancel-operation = Для отмены операции используйте команду /cancel
# Ошибки
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд

View File

@ -29,7 +29,6 @@
"dayjs": "catalog:",
"grammy": "^1.38.1",
"ioredis": "^5.7.0",
"libphonenumber-js": "^1.12.24",
"pino": "^9.9.0",
"pino-pretty": "^13.1.1",
"radashi": "catalog:",

View File

@ -1,14 +1,12 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable id-length */
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact';
import { combine } from '@/utils/messages';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { type Conversation } from '@grammyjs/conversations';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration';
import parsePhoneNumber from 'libphonenumber-js';
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
// Все пользователи могут добавлять контакты
@ -17,79 +15,45 @@ export async function addContact(conversation: Conversation<Context, Context>, c
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
}
const registrationService = new RegistrationService();
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return ctx.reply(
await conversation.external(({ t }) =>
combine(
t('msg-need-phone'),
t('share-phone-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
),
{ ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' },
await conversation.external(({ t }) => t('msg-need-phone')),
KEYBOARD_SHARE_PHONE,
);
}
// Просим отправить контакт или номер телефона
await ctx.reply(
await conversation.external(({ t }) =>
combine(
t('msg-send-client-contact-or-phone'),
t('msg-cancel-operation'),
t('share-contact-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
combine(t('msg-send-client-contact-or-phone'), t('msg-cancel-operation')),
),
{
parse_mode: 'HTML',
},
);
// Ждём первое сообщение: контакт или текст с номером
const firstCtx = await conversation.wait();
let name = '';
let surname = '';
let phone = '';
if (firstCtx.message?.contact) {
/**
* Отправлен контакт
*/
const { contact } = firstCtx.message;
const parsedContact = parseContact(contact);
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
name = parsedContact.name;
surname = parsedContact.surname;
if (!parsedPhone?.isValid() || !parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
phone = parsedPhone.number;
name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
phone = normalizePhoneNumber(contact.phone_number);
} else if (firstCtx.message?.text) {
/**
* Номер в тексте сообщения
*/
const parsedPhone = parsePhoneNumber(firstCtx.message.text, 'RU');
if (!parsedPhone?.isValid() || !parsedPhone.number) {
const typedPhone = normalizePhoneNumber(firstCtx.message.text);
if (!isValidPhoneNumber(typedPhone)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
// Нельзя добавлять свой собственный номер телефона
if (customer.phone && customer.phone === parsedPhone.number) {
if (customer.phone && normalizePhoneNumber(customer.phone) === typedPhone) {
return ctx.reply(await conversation.external(({ t }) => t('err-cannot-add-self')));
}
phone = parsedPhone.number;
phone = typedPhone;
// Просим ввести имя клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-name')));
@ -100,32 +64,24 @@ export async function addContact(conversation: Conversation<Context, Context>, c
}
name = typedName;
// Просим ввести фамилию клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-surname')));
const surnameCtx = await conversation.wait();
const typedSurname = surnameCtx.message?.text?.trim() || '';
if (!typedSurname) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-surname')));
}
surname = typedSurname;
} else {
return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone')));
}
// Проверяем валидность номера телефона
if (!isValidPhoneNumber(phone)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
try {
// Проверяем, есть ли клиент с таким номером
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
phone,
});
const { customer: existingCustomer } = await customerService.getCustomer({ phone });
let documentId = existingCustomer?.documentId;
// Если клиента нет, создаём нового
if (!documentId) {
const createCustomerResult = await registrationService.createCustomer({
data: { name, phone, surname },
});
const registrationService = new RegistrationService();
const createCustomerResult = await registrationService.createCustomer({ name, phone });
documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) throw new Error('Клиент не создан');
@ -133,18 +89,11 @@ export async function addContact(conversation: Conversation<Context, Context>, c
// Добавляем текущего пользователя к приглашенному
const invitedBy = [customer.documentId];
const customerService = new CustomersService({ telegramId });
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
// Отправляем подтверждения и инструкции
await ctx.reply(
await conversation.external(({ t }) =>
t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }),
),
);
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')), {
parse_mode: 'HTML',
});
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')));
await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT);
} catch (error) {
await ctx.reply(

View File

@ -5,7 +5,6 @@ import { formatMoney } from '@/utils/format';
import { combine } from '@/utils/messages';
import { type Conversation } from '@grammyjs/conversations';
import { fmt, i } from '@grammyjs/parse-mode';
import { CustomersService } from '@repo/graphql/api/customers';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import * as GQL from '@repo/graphql/types';
import { InlineKeyboard } from 'grammy';
@ -67,7 +66,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
}),
),
{ parse_mode: 'HTML', reply_markup: keyboard },
{ reply_markup: keyboard },
);
// ждём выбора
@ -96,21 +95,6 @@ export async function subscription(conversation: Conversation<Context, Context>,
month: '2-digit',
year: 'numeric',
});
const agreementText = await conversation.external(({ t }) => {
return t('payment-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
});
});
await ctx.reply(agreementText, {
parse_mode: 'HTML',
});
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
return ctx.replyWithInvoice(
'Оплата Pro доступа',
combine(
@ -126,28 +110,6 @@ 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,
start_parameter: 'get_access',
},

View File

@ -1,12 +0,0 @@
import { handleDocuments } from '../handlers/documents';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('documents', logHandle('command-documents'), handleDocuments);
export { composer as documents };

View File

@ -1,5 +1,4 @@
export * from './add-contact';
export * from './documents';
export * from './help';
export * from './pro';
export * from './registration';

View File

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

View File

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

View File

@ -1,18 +0,0 @@
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { KEYBOARD_REMOVE } from '@/config/keyboards';
async function handler(ctx: Context) {
await ctx.reply(
ctx.t('agreement-links', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
{
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
},
);
}
export { handler as handleDocuments };

View File

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

View File

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

View File

@ -18,9 +18,6 @@ export const envSchema = z.object({
.string()
.transform((value) => Number.parseInt(value, 10))
.default('6379'),
URL_FAQ: z.string(),
URL_OFFER: z.string(),
URL_PRIVACY: z.string(),
});
export const env = envSchema.parse(process.env);

View File

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

View File

@ -1,9 +0,0 @@
import { type Contact } from '@grammyjs/types';
export function parseContact(contact: Contact) {
return {
name: contact?.first_name?.trim() || '',
phone: contact?.phone_number?.trim() || '',
surname: contact?.last_name?.trim() || '',
};
}

View File

@ -0,0 +1,9 @@
export function isValidPhoneNumber(phone: string) {
return /^\+7\d{10}$/u.test(phone);
}
export function normalizePhoneNumber(phone: string): string {
const digitsOnly = phone.replaceAll(/\D/gu, '');
return `+${digitsOnly}`;
}

View File

@ -10,12 +10,12 @@ const envSchema = z.object({
.string()
.transform((val) => Number.parseInt(val, 10))
.default('5000'),
REDIS_HOST: z.string().default('redis'),
REDIS_PASSWORD: z.string(),
REDIS_HOST: z.string(),
REDIS_PORT: z
.string()
.transform((value) => Number.parseInt(value, 10))
.transform((val) => Number.parseInt(val, 10))
.default('6379'),
REDIS_PASSWORD: z.string(),
URL_GRAPHQL: z.string(),
});

View File

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

View File

@ -1,8 +0,0 @@
import { env } from 'src/config/env';
import { queryTTL } from './config';
export function getQueryTTL(operationName: string) {
if (operationName.includes('NOCACHE')) return false;
return queryTTL[operationName] ?? env.CACHE_TTL;
}

View File

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

View File

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

View File

@ -1,6 +0,0 @@
import { DocumentsLayout } from '@/components/documents/layout';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <DocumentsLayout title="Публичная оферта">{children}</DocumentsLayout>;
}

View File

@ -1,99 +0,0 @@
import { env } from '@/config/env';
export const metadata = {
title: 'Публичная оферта',
description: 'Публичная оферта бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
};
### Договор-оферта на использование сервиса «Запишись.онлайн» (@zapishis_online_bot)
Настоящий документ является публичной офертой в соответствии с пунктом 2 статьи 437 Гражданского кодекса Российской Федерации и представляет собой предложение индивидуального предпринимателя (самозанятого) — далее именуемого «Администрация», заключить Договор на использование Сервиса (далее «Договор», «Оферта») с любым физическим лицом, принявшим условия настоящей Оферты (далее «Пользователь»).
#### 1. Термины и определения
1.1. Оферта — настоящий документ, постоянно размещенный в сети Интернет по адресу <a href={env.URL_OFFER}>{env.URL_OFFER}</a>.
1.2. Акцепт — полное и безоговорочное принятие условий Оферты Пользователем путем оплаты доступа через встроенный платежный бот ЮKassa в Telegram.
1.3. Сервис — Telegram-бот и мини-приложение, позволяющее пользователям создавать и принимать заказы, управлять расписанием и взаимодействовать друг с другом без необходимости регистрации.
1.4. Администрация — самозанятое лицо, являющееся разработчиком и правообладателем Сервиса.
1.5. Пользователь — любое физическое лицо, использующее Сервис в личных или профессиональных целях.
1.6. Доступ — право использования функционала Сервиса на определённый оплаченный период (например, неделя, месяц, год).
1.7. Оплата — денежные средства, перечисленные Пользователем через платёжный бот ЮKassa в Telegram.
#### 2. Акцепт оферты и заключение договора
2.1. Акцептом настоящей Оферты считается оплата Пользователем доступа к Сервису любым доступным способом.
2.2. С момента совершения оплаты Пользователь считается заключившим Договор с Администрацией на условиях, изложенных в настоящей Оферте.
2.3. Пользователь подтверждает, что ему понятны все условия настоящей Оферты и он принимает их без ограничений.
#### 3. Предмет договора
3.1. Администрация предоставляет Пользователю неисключительное право (доступ) на использование функционала Сервиса в пределах оплаченного периода времени.
3.2. Сервис предоставляется в онлайн-формате через Telegram-бота без установки дополнительного программного обеспечения.
3.3. Пользователь получает право использовать функционал Сервиса в личных целях, в том числе для организации и планирования заказов, встреч и тренировок.
#### 4. Порядок оплаты и использование
4.1. Оплата производится через встроенные инструменты Telegram-бота с использованием платёжной системы ЮKassa.
4.2. Комиссия платёжной системы включена в итоговую стоимость. Администрация не взимает дополнительных платежей.
4.3. Доступ активируется автоматически после успешного подтверждения оплаты.
4.4. Пользователь может продлить доступ путём повторной оплаты. Автоматическое продление не применяется.
4.5. Возврат денежных средств возможен только в случае технических ошибок, по письменному обращению на адрес поддержки.
#### 5. Права и обязанности сторон
5.1. Пользователь обязуется:
- не использовать Сервис в противоправных целях;
- не вмешиваться в работу Сервиса и не предпринимать действий, направленных на нарушение его функционирования;
- предоставлять достоверную информацию при оплате и использовании Сервиса;
- при добавлении контактов других лиц (например, клиентов, мастеров) гарантировать, что у него есть согласие этих лиц на передачу и обработку их персональных данных в рамках Сервиса;
5.2. Администрация обязуется:
- обеспечивать бесперебойную работу Сервиса, за исключением периодов технического обслуживания;
- обрабатывать персональные данные Пользователей в соответствии с Политикой конфиденциальности;
- принимать обращения и запросы Пользователей по вопросам работы Сервиса. 6. Ответственность сторон;
6.1. Сервис предоставляется «как есть». Администрация не несёт ответственности за временные сбои, потерю данных или недоступность Сервиса, возникшие по причинам, не зависящим от неё.
6.2. Пользователь несёт полную ответственность за корректность совершаемых платежей и действий, совершаемых через свой Telegram-аккаунт.
#### 7. Обработка персональных данных
7.1. Администрация обрабатывает персональные данные Пользователя в соответствии с Федеральным законом №152-ФЗ «О персональных данных» и Политикой конфиденциальности.
7.2. Использование Сервиса означает согласие Пользователя на обработку его персональных данных.
#### 8. Срок действия и расторжение договора
8.1. Договор вступает в силу с момента оплаты доступа и действует в течение оплаченного периода.
8.2. Пользователь может прекратить использование Сервиса в любое время без возврата оплаченных средств.
8.3. Администрация вправе приостановить доступ в случае нарушения Пользователем условий настоящей Оферты.
#### 9. Заключительные положения
9.1. Настоящий Договор регулируется законодательством Российской Федерации.
9.2. Все споры и разногласия решаются путём переговоров, а при недостижении соглашения — в судебном порядке по месту нахождения Администрации.
9.3. Администрация оставляет за собой право изменять условия Оферты с размещением новой редакции на сайте.
#### 10. Контакты
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -1,6 +0,0 @@
import { DocumentsLayout } from '@/components/documents/layout';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <DocumentsLayout title="Политика конфиденциальности">{children}</DocumentsLayout>;
}

View File

@ -1,131 +0,0 @@
export const metadata = {
title: 'Политика конфиденциальности',
description:
'Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
};
### Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)
#### 1. Термины и определения
- **Telegram** Telegram Messenger Inc. (платформа, на которой работает бот и мини-приложение).
- **Платформа** экосистема ботов и мини-приложений Telegram.
- **Разработчик** физическое лицо, самозанятый, владелец и оператор сервиса «Запишись.онлайн» (@zapishis_online_bot) - (далее — «Разработчик»).
- **Сторонний сервис** бот/мини-приложение Разработчика, предоставляемое в Платформе.
- **Пользователь** лицо, использующее Сторонний сервис через свою учетную запись Telegram (далее — «Вы»).
- **Политика** настоящий документ, регулирующий отношения между Разработчиком и Пользователем в части сбора и - обработки персональных данных.
#### 2. Общие положения
2.1. Настоящая Политика регулирует исключительно отношения между Разработчиком и Пользователем. Она не заменяет и не изменяет Политику конфиденциальности Telegram: [https://telegram.org/privacy](https://telegram.org/privacy).
2.2. Разработчик соблюдает применимые требования платформы Telegram к конфиденциальности и защите данных.
2.3. Использование Сервиса Пользователем и/или активация платного доступа означает согласие Пользователя с условиями настоящей Политики.
2.4. Если Вы не согласны с условиями Политики — прекратите использование Сервиса.
#### 3. Отказ от ответственности
3.1. Сторонний сервис является независимым приложением и не поддерживается, не одобряется и не аффилирован с Telegram (за исключением использования API и инфраструктуры Telegram).
3.2. Разработчик вправе изменять настоящую Политику — изменения вступают в силу с момента их публикации. Вы обязаны самостоятельно отслеживать обновления.
3.3. Используя Сервис, Вы подтверждаете, что ознакомлены и согласны с условиями использования Telegram для ботов и мини-приложений: [https://telegram.org/tos/bots](https://telegram.org/tos/bots), [https://telegram.org/tos/mini-apps](https://telegram.org/tos/mini-apps).
3.4. Вы гарантируете, что используете Сервис в соответствии с действующим законодательством и обладаете правом взаимодействовать с ним (например, достигли возраста, необходимого для использования услуг).
3.5. Вы обязуетесь предоставлять точную и актуальную информацию, если Сервис запрашивает её.
3.6. Любая информация, которую Вы делаете общедоступной самостоятельно (через профиль Telegram, публичные сообщения и т.п.), может стать доступна другим пользователям и не подпадает под защиту настоящей Политики в части конфиденциальности этой общедоступной информации.
#### 4. Сбор персональных данных
4.1. Telegram по умолчанию предоставляет сторонним сервисам ограниченный набор данных о Пользователе — подробнее: [https://telegram.org/privacy#6-bot-messages](https://telegram.org/privacy#6-bot-messages).
4.2. Сторонний сервис может дополнительно получать данные, которые Вы передаёте в чате бота или в мини-приложении (например, контакт, телефон), если Вы явно их отправляете.
4.3. В случае мини-приложения дополнительно могут передаваться данные в соответствии с правилами мини-приложений Telegram: [https://telegram.org/tos/mini-apps#4-privacy](https://telegram.org/tos/mini-apps#4-privacy).
4.4. Сторонний сервис может собирать также анонимную статистику использования (диагностика, события взаимодействия), не связываемую напрямую с персоной.
4.5. Пользователь может передавать данные третьих лиц (например, контактные данные клиентов или мастеров) для использования в Сервисе. При этом пользователь гарантирует, что эти лица дали согласие на обработку их персональных данных в рамках Сервиса.
#### 5. Какие данные мы собираем и как используем
5.1. Разработчик запрашивает, собирает и обрабатывает только те данные, которые необходимы для корректной работы функций Сервиса, в частности:
- Telegram ID и (опционально) отображаемое имя пользователя;
- телефон, только если Вы предоставили его добровольно (например, при регистрации);
- данные о заказах: дата/время, описание заказа, статус;
- информация о факте покупки Pro-доступа: период доступа, тип покупки (детали платёжной транзакции обрабатывает платёжный оператор — ЮKassa);
5.2. Цели обработки:
- предоставление и поддержка работы Сервиса (создание заказов, напоминания, управление доступом);
- подтверждение и учет оплат (взаимодействие с платёжным оператором для актуализации статуса доступа);
- реализация реферальной программы (хранение связей «кто пригласил/кого пригласили»);
- анализ использования и улучшение сервиса;
- выполнение юридических обязательств (хранение информации о транзакциях и др.);
> **Важно:** детальные платёжные данные (реквизиты карт и т.д.) не хранятся у Разработчика — их обрабатывает платёжный оператор (ЮKassa) и Telegram-платежный бот.
#### 6. Передача данных третьим лицам
6.1. Разработчик не передаёт персональные данные третьим лицам, за исключением следующих случаев:
- платёжному оператору (ЮKassa) и связанным службам для обработки платежей;
- Telegram как платформе для функционирования бота и мини-приложения;
- в случае необходимости — исполнителям, оказывающим техническую поддержку, при условии подписания ими обязательств о конфиденциальности;
- если передача требуется по закону (запросы уполномоченных органов и т.п.);
6.2. Разработчик не продаёт и не передаёт персональные данные для рекламных целей третьим лицам без Вашего отдельного согласия.
#### 7. Защита и хранение данных
7.1. Разработчик применяет разумные технические и организационные меры для защиты персональных данных (использование надежного VPS, ограничения доступа, резервное копирование и т.п.).
7.2. Доступ к персональным данным имеет только Разработчик (и/или доверенные исполнители технической поддержки при необходимости).
7.3. Данные хранятся на серверах, указанных Разработчиком. Если используются внешние сервисы/облачные хранилища — это будет указано в соответствующих местах Политики или сообщения при сборе данных.
#### 8. Права и обязанности сторон
8.1. Права Разработчика:
- вносить изменения в Политику с публикацией новой версии;
- ограничивать доступ к API/сервису при подозрении в злоупотреблениях;
- запросить подтверждение личности при необходимости обработки привилегированных запросов;
8.2. Обязанности Разработчика:
- обеспечивать доступность Политики и исполнять её условия;
- обрабатывать законные запросы пользователей о доступе, изменении или удалении данных в разумные сроки (не позднее 30 дней, если иное не установлено законом);
- соблюдать применимое законодательство о защите персональных данных;
8.3. Права Пользователя:
- запросить копию своих персональных данных, хранящихся у Разработчика;
- потребовать исправления неточных данных;
- потребовать удаления персональных данных в пределах, допустимых законом (с сохранением данных, необходимых для выполнения юридических обязательств, например, по учёту платежей);
- отозвать согласие на обработку персональных данных, если такое согласие предоставлялось добровольно;
- подать жалобу в уполномоченные органы по защите персональных данных, если считает, что его права нарушены;
8.4. Обязанности Пользователя:
- предоставлять точную и актуальную информацию;
- не использовать Сервис в нарушении законодательства и условий Telegram.
#### 9. Реклама и использование данных для аналитики
9.1. На текущем этапе Разработчик не использует персональные данные для демонстрации таргетированной рекламы третьих лиц без явного согласия Пользователя.
9.2. Разработчик может собирать агрегированную (анонимную) статистику использования Сервиса для улучшения функционала.
#### 10. Изменения Политики
10.1. Разработчик вправе вносить изменения в настоящую Политику. Все изменения публикуются на этой странице и вступают в силу с момента публикации.
#### 11. Контакты
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
import { ProfileButtons } from '@/components/profile/profile-buttons';
import { ReadonlyServicesList } from '@/components/profile/services';
// Тип параметров страницы
@ -19,8 +18,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
<ContactDataCard telegramId={contactTelegramId} />
<ReadonlyServicesList telegramId={contactTelegramId} />
<ProfileOrdersList telegramId={contactTelegramId} />
<div className="pb-24" />
<ProfileButtons telegramId={contactTelegramId} />
</Container>
</>
);

View File

@ -1,16 +1,26 @@
import { getSlot } from '@/actions/api/slots';
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
import { type SlotPageParameters } from '@/components/schedule/types';
import { BookButton } from '@/components/shared/book-button';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<SlotPageParameters> };
export default async function SlotPage(props: Readonly<Props>) {
const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getSlot({ documentId }),
queryKey: ['slot', documentId],
});
return (
<>
<HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Слот" />
<Container>
<SlotDateTime {...parameters} />
@ -19,6 +29,6 @@ export default async function SlotPage(props: Readonly<Props>) {
<div className="pb-24" />
<SlotButtons {...parameters} />
</Container>
</>
</HydrationBoundary>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,6 @@ import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import { sift } from 'radashi';
type ContactsGridProps = {
@ -26,12 +25,8 @@ type ContactsGridProps = {
readonly title: string;
};
type UseContactsProps = Partial<{
showInactive: boolean;
}>;
export function ClientsGrid() {
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts({ showInactive: true });
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
@ -108,7 +103,7 @@ export function ContactsGridBase({
isCurrentUser && 'font-bold',
)}
>
{getCustomerFullName(contact)}
{contact.name}
</span>
</Label>
);
@ -149,7 +144,7 @@ export function MastersGrid() {
);
}
function useContacts({ showInactive = false }: UseContactsProps = {}) {
function useContacts() {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
const {
@ -160,13 +155,13 @@ function useContacts({ showInactive = false }: UseContactsProps = {}) {
const isLoading = isLoadingContacts || isLoadingCustomer;
const contacts = sift(pages.flatMap((page) => page.customers));
const contacts = sift(
pages.flatMap((page) => page.customers).filter((contact) => Boolean(contact && contact.active)),
);
return {
isLoading,
...query,
contacts: [{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment].concat(
showInactive ? contacts : contacts.filter((contact) => contact.active),
),
contacts: [{ ...customer, name: 'Я' } as CustomerFieldsFragment, ...contacts],
};
}

View File

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

View File

@ -5,14 +5,7 @@ import { getAlert } from '@/components/shared/status';
import { useOrderQuery } from '@/hooks/api/orders';
export function OrderStatus({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId });
if (isLoading)
return (
<div className="flex animate-pulse flex-col space-y-1">
<div className="h-10 w-full rounded bg-muted" />
</div>
);
const { data: { order } = {} } = useOrderQuery({ documentId });
return order?.state && getAlert(order.state);
}

View File

@ -57,8 +57,6 @@ export function ProfileDataCard() {
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-5 w-60 rounded bg-muted" />
</div>
</Card>
@ -77,13 +75,6 @@ export function ProfileDataCard() {
onChange={(value) => updateField('name', value)}
value={customer?.name ?? ''}
/>
<TextField
id="surname"
key={`surname-${resetTrigger}`}
label="Фамилия"
onChange={(value) => updateField('surname', value)}
value={customer?.surname ?? ''}
/>
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
<CheckboxWithText
checked={customer.role !== 'client'}

View File

@ -4,7 +4,6 @@ import { type ProfileProps } from './types';
import { UserAvatar } from '@/components/shared/user-avatar';
import { useCustomerQuery } from '@/hooks/api/customers';
import { Card } from '@repo/ui/components/ui/card';
import { getCustomerFullName } from '@repo/utils/customer';
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
@ -25,7 +24,7 @@ export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
<Card className="bg-transparent p-4 shadow-none">
<div className="flex flex-col items-center space-y-2">
<UserAvatar {...customer} size="lg" />
<h2 className="text-2xl font-bold">{getCustomerFullName(customer)}</h2>
<h2 className="text-2xl font-bold">{customer?.name}</h2>
</div>
</Card>
);

View File

@ -1,35 +0,0 @@
/* eslint-disable canonical/id-match */
'use client';
import FloatingActionPanel from '@/components/shared/action-panel';
import { useCustomerQuery } from '@/hooks/api/customers';
import { usePushWithData } from '@/hooks/url';
import { Enum_Customer_Role } from '@repo/graphql/types';
type QuickAppointmentProps = {
readonly telegramId: number;
};
export function ProfileButtons({ telegramId }: Readonly<QuickAppointmentProps>) {
const push = usePushWithData();
const { data: { customer: profile } = {}, isLoading: isLoadingProfile } = useCustomerQuery({
telegramId,
});
const { data: { customer: currentUser } = {}, isLoading: isLoadingCurrentUser } =
useCustomerQuery();
const isLoading = isLoadingProfile || isLoadingCurrentUser;
const handleBook = () => {
if (profile?.role === Enum_Customer_Role.Client) {
push('/orders/add', { client: profile, slot: { master: currentUser } });
} else {
push('/orders/add', { client: currentUser, slot: { master: profile } });
}
};
if (!telegramId) return null;
return <FloatingActionPanel isLoading={isLoading} onQuickBook={handleBook} />;
}

View File

@ -12,27 +12,10 @@ type Props = {
};
export function ServiceDataCard({ serviceId }: Readonly<Props>) {
const { data: { service } = {}, isLoading } = useServiceQuery({ documentId: serviceId });
const { data: { service } = {} } = useServiceQuery({ documentId: serviceId });
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
useServiceEdit(serviceId);
if (isLoading) {
return (
<Card className="p-4">
<div className="flex animate-pulse flex-col gap-4">
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-28 w-full rounded bg-muted" />
</div>
</Card>
);
}
if (!service) return null;
return (

View File

@ -1,4 +1,3 @@
/* eslint-disable canonical/id-match */
'use client';
import { type ProfileProps } from '../types';
@ -6,17 +5,13 @@ import { DataNotFound } from '@/components/shared/alert';
import { ServiceCard } from '@/components/shared/service-card';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useServicesQuery } from '@/hooks/api/services';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import Link from 'next/link';
// Компонент для отображения услуг мастера (без ссылок, только просмотр)
export function ReadonlyServicesList({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {} } = useCustomerQuery({ telegramId });
const { isLoading, services } = useServices(telegramId);
if (customer?.role === Enum_Customer_Role.Client) return null;
return (
<div className="space-y-2 px-4">
<h1 className="font-bold">Услуги</h1>

View File

@ -15,8 +15,6 @@ export function SubscriptionInfoBar() {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
if (customer?.role === Enum_Customer_Role.Client) return null;
const isLoading = isLoadingCustomer || isLoadingSubscription || isLoadingSubscriptionSetting;
const isActive = data?.hasActiveSubscription;
@ -38,6 +36,8 @@ export function SubscriptionInfoBar() {
if (!subscriptionSetting?.proEnabled) return null;
if (customer?.role === Enum_Customer_Role.Client) return null;
return (
<Link href="/pro" rel="noopener noreferrer">
<div className={cn('px-4', isLoading && 'animate-pulse')}>

View File

@ -3,24 +3,12 @@
import { type SlotComponentProps } from '../types';
import { SlotDate } from './slot-date';
import { SlotTime } from './slot-time';
import { useSlotQuery } from '@/hooks/api/slots';
import { ScheduleStoreProvider } from '@/stores/schedule';
import { withContext } from '@/utils/context';
export const SlotDateTime = withContext(ScheduleStoreProvider)(function (
props: Readonly<SlotComponentProps>,
) {
const { isLoading } = useSlotQuery(props);
if (isLoading) {
return (
<div className="flex animate-pulse flex-col space-y-1">
<div className="h-5 w-28 rounded bg-muted" />
<div className="h-9 w-48 rounded bg-muted" />
</div>
);
}
return (
<div className="flex flex-col">
<SlotDate {...props} />

View File

@ -2,7 +2,7 @@
import { Button } from '@repo/ui/components/ui/button';
import { Card } from '@repo/ui/components/ui/card';
import { Ban, Check, Lock, Plus, RotateCcw, Save, Trash2, Undo, Unlock } from 'lucide-react';
import { Ban, Check, Lock, RotateCcw, Save, Trash2, Undo, Unlock } from 'lucide-react';
type FloatingActionPanelProps = {
readonly isLoading?: boolean;
@ -11,7 +11,6 @@ type FloatingActionPanelProps = {
readonly onComplete?: () => void;
readonly onConfirm?: () => void;
readonly onDelete?: () => void;
readonly onQuickBook?: () => void;
readonly onRepeat?: () => void;
readonly onReturn?: () => void;
readonly onSave?: () => void;
@ -25,7 +24,6 @@ export default function FloatingActionPanel({
onComplete,
onConfirm,
onDelete,
onQuickBook,
onRepeat,
onReturn,
onSave,
@ -38,7 +36,6 @@ export default function FloatingActionPanel({
!onDelete &&
!onComplete &&
!onRepeat &&
!onQuickBook &&
!onToggle &&
!onReturn &&
!onSave
@ -48,18 +45,6 @@ export default function FloatingActionPanel({
return (
<Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6">
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
{/* Кнопка записать */}
{onQuickBook && (
<Button
className="w-full rounded-2xl bg-gradient-to-r from-purple-600 to-blue-600 text-sm text-white transition-all duration-200 hover:bg-primary/90 dark:from-purple-700 dark:to-blue-700 sm:w-auto"
disabled={isLoading}
onClick={onQuickBook}
size="sm"
>
<Plus className="mr-2 size-4" />
<span>Быстрая запись</span>
</Button>
)}
{/* Кнопка закрыть/открыть */}
{onToggle && (
<Button

View File

@ -2,9 +2,8 @@ import { UserAvatar } from './user-avatar';
import type * as GQL from '@repo/graphql/types';
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import Link from 'next/link';
import { memo, type PropsWithChildren } from 'react';
import { memo } from 'react';
type ContactRowProps = GQL.CustomerFieldsFragment & {
readonly className?: string;
@ -12,30 +11,13 @@ type ContactRowProps = GQL.CustomerFieldsFragment & {
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) {
return (
<Wrapper contact={contact}>
<Link
className="block"
href={contact.active ? `/profile/${contact.telegramId}` : ''}
key={contact.telegramId}
>
<div
className={cn(
'flex items-center justify-between',
@ -46,7 +28,7 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
<UserAvatar {...contact} size="sm" />
<div>
<p className="font-medium">{getCustomerFullName(contact)}</p>
<p className="font-medium">{contact.name}</p>
{description && (
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
)}
@ -54,6 +36,6 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
</div>
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
</div>
</Wrapper>
</Link>
);
});

View File

@ -6,7 +6,6 @@ import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import Image from 'next/image';
type Sizes = 'lg' | 'md' | 'sm' | 'xs';
@ -38,7 +37,7 @@ export function UserAvatar({ className, size = 'sm', telegramId = null }: UserAv
)}
>
<Image
alt={customer ? getCustomerFullName(customer) : 'contact-avatar'}
alt={customer?.name || 'contact-avatar'}
className="size-full rounded-full object-cover"
height={80}
src={customer?.photoUrl || AvatarPlaceholder}

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
export const envSchema = z.object({
__DEV_TELEGRAM_ID: z.string().default(''),
BOT_URL: z.string(),
SUPPORT_TELEGRAM_URL: z.string(),
URL_OFFER: z.string(),
});
export const env = envSchema.parse(process.env);

View File

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

View File

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

View File

@ -11,7 +11,5 @@ export default withAuth({
});
export const config = {
matcher: [
'/((?!auth|browser|telegram|unregistered|privacy|offer|api|_next/static|_next/image|favicon.ico).*)',
],
matcher: ['/((?!auth|browser|telegram|unregistered|api|_next/static|_next/image|favicon.ico).*)'],
};

View File

@ -1,16 +1,12 @@
import createMDX from '@next/mdx';
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig = createMDX({
extension: /\.mdx?$/u,
})({
const withNextIntl = createNextIntlPlugin('./utils/i18n/i18n.ts');
const nextConfig = withNextIntl({
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
mdxRs: true,
},
output: 'standalone',
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
reactStrictMode: true,
transpilePackages: ['@repo/ui'],
});

View File

@ -14,9 +14,6 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.5",
"@playwright/test": "^1.49.1",
"@repo/graphql": "workspace:*",
"@repo/typescript-config": "workspace:*",
@ -25,10 +22,9 @@
"@tanstack/react-query": "^5.64.1",
"@telegram-apps/sdk-react": "^2.0.19",
"@testing-library/react": "^16.1.0",
"@types/mdx": "^2.0.13",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "catalog:",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "catalog:",
"dayjs": "catalog:",
@ -36,13 +32,14 @@
"graphql": "catalog:",
"jsdom": "^25.0.1",
"lucide-react": "catalog:",
"next": "^15.5.9",
"next-auth": "^4.24.11",
"next-intl": "^3.26.0",
"next-themes": "^0.4.4",
"next": "^15.5.0",
"postcss": "catalog:",
"radashi": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"use-debounce": "^10.0.4",

View File

@ -2,8 +2,10 @@
'use client';
import { useBackButton, useClientOnce, useDidMount, useViewport } from '@/hooks/telegram';
import { setLocale } from '@/utils/i18n/locale';
import { init } from '@/utils/telegram/init';
import { type PropsWithChildren } from 'react';
import { initData, useSignal } from '@telegram-apps/sdk-react';
import { type PropsWithChildren, useEffect } from 'react';
export function TelegramProvider(props: Readonly<PropsWithChildren>) {
// Unfortunately, Telegram Mini Apps does not allow us to use all features of
@ -11,7 +13,7 @@ export function TelegramProvider(props: Readonly<PropsWithChildren>) {
// side.
const didMount = useDidMount();
if (!didMount) return null;
if (!didMount) return <div>Loading</div>;
return <RootInner {...props} />;
}
@ -29,5 +31,12 @@ function RootInner({ children }: PropsWithChildren) {
useViewport();
useBackButton();
const initDataUser = useSignal(initData.user);
// Set the user locale.
useEffect(() => {
if (initDataUser) setLocale(initDataUser.languageCode);
}, [initDataUser]);
return children;
}

View File

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

View File

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

View File

@ -0,0 +1,10 @@
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

@ -0,0 +1,18 @@
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

@ -0,0 +1,20 @@
// 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

@ -0,0 +1,14 @@
/* 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

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

View File

@ -9,21 +9,21 @@ services:
networks:
- app
- web
# healthcheck:
# test: ['CMD', 'wget', '-qO-', 'http://localhost:5000/api/health']
# interval: 10s
# timeout: 3s
# retries: 5
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:5000/api/health']
interval: 10s
timeout: 3s
retries: 5
web:
image: ${DOCKERHUB_USERNAME}/zapishis-web:${WEB_IMAGE_TAG}
env_file:
- .env
restart: always
# healthcheck:
# test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
# interval: 10s
# timeout: 3s
# retries: 5
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
interval: 10s
timeout: 3s
retries: 5
depends_on:
- cache-proxy
networks:

View File

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

View File

@ -2,35 +2,15 @@ import { getClientWithToken } from '../apollo/client';
import { ERRORS } from '../constants/errors';
import * as GQL from '../types';
import { type VariablesOf } from '@graphql-typed-document-node/core';
const DEFAULT_CUSTOMER_ROLE = GQL.Enum_Customer_Role.Client;
import { isCustomerBanned } from '@repo/utils/customer';
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>) {
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateCustomerDocument,
variables: {
...variables,
data: {
...variables.data,
role: DEFAULT_CUSTOMER_ROLE,
},
},
variables,
});
const error = mutationResult.errors?.at(0);
@ -39,8 +19,34 @@ export class RegistrationService {
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>) {
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);
}

View File

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

View File

@ -1,18 +1,17 @@
import { env as environment } from '../config/env';
import { getToken } from '../config/token';
import { createLink } from './link';
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
type Parameters_ = { token: null | string | undefined };
type Parameters = { token: null | string | undefined };
export function createApolloClient(parameters?: Parameters_) {
export function createApolloClient(parameters?: Parameters) {
return new ApolloClient({
cache: new InMemoryCache(),
headers: parameters?.token
? {
Authorization: `Bearer ${parameters.token}`,
}
: undefined,
uri: environment.URL_GRAPHQL_CACHED,
link: createLink({
token: parameters?.token,
uri: environment.URL_GRAPHQL_CACHED,
}),
});
}

View File

@ -0,0 +1,16 @@
import { ApolloLink, from, HttpLink } from '@apollo/client/core';
type Parameters = { token: null | string | undefined; uri: string };
export function createLink({ token, uri }: Parameters) {
const cacheLink = new ApolloLink((operation, forward) => {
return forward(operation);
});
const httpLink = new HttpLink({
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
uri,
});
return from([cacheLink, httpLink]);
}

View File

@ -3,7 +3,6 @@ fragment CustomerFields on Customer {
bannedUntil
documentId
name
surname
phone
photoUrl
role
@ -14,26 +13,26 @@ fragment CustomerFields on Customer {
}
}
mutation CreateCustomer($data: CustomerInput!) {
createCustomer(data: $data) {
mutation CreateCustomer($name: String!, $telegramId: Long, $phone: String) {
createCustomer(data: { name: $name, telegramId: $telegramId, phone: $phone, role: client }) {
documentId
}
}
query GetCustomer($telegramId: Long, $documentId: ID) {
query GetCustomer($phone: String, $telegramId: Long, $documentId: ID) {
customers(
filters: { or: [{ telegramId: { eq: $telegramId } }, { documentId: { eq: $documentId } }] }
filters: {
or: [
{ phone: { eq: $phone } }
{ telegramId: { eq: $telegramId } }
{ documentId: { eq: $documentId } }
]
}
) {
...CustomerFields
}
}
query _NOCACHE_GetCustomer($phone: String, $telegramId: Long) {
customers(filters: { or: [{ phone: { eq: $phone } }, { telegramId: { eq: $telegramId } }] }) {
...CustomerFields
}
}
mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) {
updateCustomer(documentId: $documentId, data: $data) {
...CustomerFields

File diff suppressed because one or more lines are too long

View File

@ -33,14 +33,12 @@
"dependencies": {
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/typography": "^0.5.19",
"@types/react": "catalog:",
"autoprefixer": "catalog:",
"class-variance-authority": "^0.7.1",
@ -48,16 +46,15 @@
"date-fns": "^4.1.0",
"lucide-react": "catalog:",
"next-themes": "^0.4.4",
"postcss": "catalog:",
"postcss-load-config": "catalog:",
"react": "catalog:",
"postcss": "catalog:",
"react-day-picker": "8.10.1",
"react-dom": "catalog:",
"react": "catalog:",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.5",
"tailwindcss": "catalog:",
"tailwindcss-animate": "^1.0.7",
"typescript": "catalog:",
"vaul": "^1.1.2"
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@ -1,118 +0,0 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@repo/ui/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

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

View File

@ -1,9 +1,7 @@
import type * as GQL from '../../graphql/types';
export function getCustomerFullName(customer: GQL.CustomerFieldsFragment) {
return [customer?.name?.trim(), customer?.surname?.trim()].filter(Boolean).join(' ');
}
import * as GQL from '../../graphql/types';
export function isCustomerBanned(customer: GQL.CustomerFieldsFragment): boolean {
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
}
// isCustomerMaster удален - больше не нужен при равенстве пользователей

2328
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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