Compare commits
11 Commits
main
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e9f1cf0d | ||
|
|
17528d12c7 | ||
|
|
7fcf55abda | ||
|
|
b43d166b2e | ||
|
|
0039de3499 | ||
|
|
eef1338cd2 | ||
|
|
50ef49d01f | ||
|
|
b8b8ca6004 | ||
|
|
9d9ba6540b | ||
|
|
54f69f7c36 | ||
|
|
8cb283d4ba |
113
.github/workflows/deploy.yml
vendored
113
.github/workflows/deploy.yml
vendored
@ -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
|
||||
"
|
||||
|
||||
@ -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 = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import { handleDocuments } from '../handlers/documents';
|
||||
import { type Context } from '@/bot/context';
|
||||
import { logHandle } from '@/bot/helpers/logging';
|
||||
import { Composer } from 'grammy';
|
||||
|
||||
const composer = new Composer<Context>();
|
||||
|
||||
const feature = composer.chatType('private');
|
||||
|
||||
feature.command('documents', logHandle('command-documents'), handleDocuments);
|
||||
|
||||
export { composer as documents };
|
||||
@ -1,5 +1,4 @@
|
||||
export * from './add-contact';
|
||||
export * from './documents';
|
||||
export * from './help';
|
||||
export * from './pro';
|
||||
export * from './registration';
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { type Context } from '@/bot/context';
|
||||
import { env } from '@/config/env';
|
||||
import { KEYBOARD_REMOVE } from '@/config/keyboards';
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
await ctx.reply(
|
||||
ctx.t('agreement-links', {
|
||||
offerUrl: env.URL_OFFER,
|
||||
privacyUrl: env.URL_PRIVACY,
|
||||
}),
|
||||
{
|
||||
...KEYBOARD_REMOVE,
|
||||
parse_mode: 'HTML',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { handler as handleDocuments };
|
||||
@ -1,5 +1,4 @@
|
||||
export * from './add-contact';
|
||||
export * from './documents';
|
||||
export * from './pro';
|
||||
export * from './share-bot';
|
||||
export * from './subscription';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'),
|
||||
() => {
|
||||
|
||||
@ -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() || '',
|
||||
};
|
||||
}
|
||||
9
apps/bot/src/utils/phone.ts
Normal file
9
apps/bot/src/utils/phone.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function isValidPhoneNumber(phone: string) {
|
||||
return /^\+7\d{10}$/u.test(phone);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(phone: string): string {
|
||||
const digitsOnly = phone.replaceAll(/\D/gu, '');
|
||||
|
||||
return `+${digitsOnly}`;
|
||||
}
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { TelegramProvider } from '@/providers/telegram';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
export default async function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return <TelegramProvider>{children}</TelegramProvider>;
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
import { DocumentsLayout } from '@/components/documents/layout';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return <DocumentsLayout title="Публичная оферта">{children}</DocumentsLayout>;
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import { env } from '@/config/env';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Публичная оферта',
|
||||
description: 'Публичная оферта бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
|
||||
};
|
||||
|
||||
### Договор-оферта на использование сервиса «Запишись.онлайн» (@zapishis_online_bot)
|
||||
|
||||
Настоящий документ является публичной офертой в соответствии с пунктом 2 статьи 437 Гражданского кодекса Российской Федерации и представляет собой предложение индивидуального предпринимателя (самозанятого) — далее именуемого «Администрация», заключить Договор на использование Сервиса (далее – «Договор», «Оферта») с любым физическим лицом, принявшим условия настоящей Оферты (далее – «Пользователь»).
|
||||
|
||||
#### 1. Термины и определения
|
||||
|
||||
1.1. Оферта — настоящий документ, постоянно размещенный в сети Интернет по адресу <a href={env.URL_OFFER}>{env.URL_OFFER}</a>.
|
||||
|
||||
1.2. Акцепт — полное и безоговорочное принятие условий Оферты Пользователем путем оплаты доступа через встроенный платежный бот ЮKassa в Telegram.
|
||||
|
||||
1.3. Сервис — Telegram-бот и мини-приложение, позволяющее пользователям создавать и принимать заказы, управлять расписанием и взаимодействовать друг с другом без необходимости регистрации.
|
||||
|
||||
1.4. Администрация — самозанятое лицо, являющееся разработчиком и правообладателем Сервиса.
|
||||
|
||||
1.5. Пользователь — любое физическое лицо, использующее Сервис в личных или профессиональных целях.
|
||||
|
||||
1.6. Доступ — право использования функционала Сервиса на определённый оплаченный период (например, неделя, месяц, год).
|
||||
|
||||
1.7. Оплата — денежные средства, перечисленные Пользователем через платёжный бот ЮKassa в Telegram.
|
||||
|
||||
#### 2. Акцепт оферты и заключение договора
|
||||
|
||||
2.1. Акцептом настоящей Оферты считается оплата Пользователем доступа к Сервису любым доступным способом.
|
||||
|
||||
2.2. С момента совершения оплаты Пользователь считается заключившим Договор с Администрацией на условиях, изложенных в настоящей Оферте.
|
||||
|
||||
2.3. Пользователь подтверждает, что ему понятны все условия настоящей Оферты и он принимает их без ограничений.
|
||||
|
||||
#### 3. Предмет договора
|
||||
|
||||
3.1. Администрация предоставляет Пользователю неисключительное право (доступ) на использование функционала Сервиса в пределах оплаченного периода времени.
|
||||
|
||||
3.2. Сервис предоставляется в онлайн-формате через Telegram-бота без установки дополнительного программного обеспечения.
|
||||
|
||||
3.3. Пользователь получает право использовать функционал Сервиса в личных целях, в том числе для организации и планирования заказов, встреч и тренировок.
|
||||
|
||||
#### 4. Порядок оплаты и использование
|
||||
|
||||
4.1. Оплата производится через встроенные инструменты Telegram-бота с использованием платёжной системы ЮKassa.
|
||||
|
||||
4.2. Комиссия платёжной системы включена в итоговую стоимость. Администрация не взимает дополнительных платежей.
|
||||
|
||||
4.3. Доступ активируется автоматически после успешного подтверждения оплаты.
|
||||
|
||||
4.4. Пользователь может продлить доступ путём повторной оплаты. Автоматическое продление не применяется.
|
||||
|
||||
4.5. Возврат денежных средств возможен только в случае технических ошибок, по письменному обращению на адрес поддержки.
|
||||
|
||||
#### 5. Права и обязанности сторон
|
||||
|
||||
5.1. Пользователь обязуется:
|
||||
|
||||
- не использовать Сервис в противоправных целях;
|
||||
- не вмешиваться в работу Сервиса и не предпринимать действий, направленных на нарушение его функционирования;
|
||||
- предоставлять достоверную информацию при оплате и использовании Сервиса;
|
||||
- при добавлении контактов других лиц (например, клиентов, мастеров) гарантировать, что у него есть согласие этих лиц на передачу и обработку их персональных данных в рамках Сервиса;
|
||||
|
||||
5.2. Администрация обязуется:
|
||||
|
||||
- обеспечивать бесперебойную работу Сервиса, за исключением периодов технического обслуживания;
|
||||
- обрабатывать персональные данные Пользователей в соответствии с Политикой конфиденциальности;
|
||||
- принимать обращения и запросы Пользователей по вопросам работы Сервиса. 6. Ответственность сторон;
|
||||
|
||||
6.1. Сервис предоставляется «как есть». Администрация не несёт ответственности за временные сбои, потерю данных или недоступность Сервиса, возникшие по причинам, не зависящим от неё.
|
||||
|
||||
6.2. Пользователь несёт полную ответственность за корректность совершаемых платежей и действий, совершаемых через свой Telegram-аккаунт.
|
||||
|
||||
#### 7. Обработка персональных данных
|
||||
|
||||
7.1. Администрация обрабатывает персональные данные Пользователя в соответствии с Федеральным законом №152-ФЗ «О персональных данных» и Политикой конфиденциальности.
|
||||
|
||||
7.2. Использование Сервиса означает согласие Пользователя на обработку его персональных данных.
|
||||
|
||||
#### 8. Срок действия и расторжение договора
|
||||
|
||||
8.1. Договор вступает в силу с момента оплаты доступа и действует в течение оплаченного периода.
|
||||
|
||||
8.2. Пользователь может прекратить использование Сервиса в любое время без возврата оплаченных средств.
|
||||
|
||||
8.3. Администрация вправе приостановить доступ в случае нарушения Пользователем условий настоящей Оферты.
|
||||
|
||||
#### 9. Заключительные положения
|
||||
|
||||
9.1. Настоящий Договор регулируется законодательством Российской Федерации.
|
||||
|
||||
9.2. Все споры и разногласия решаются путём переговоров, а при недостижении соглашения — в судебном порядке по месту нахождения Администрации.
|
||||
|
||||
9.3. Администрация оставляет за собой право изменять условия Оферты с размещением новой редакции на сайте.
|
||||
|
||||
#### 10. Контакты
|
||||
|
||||
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.
|
||||
@ -1,6 +0,0 @@
|
||||
import { DocumentsLayout } from '@/components/documents/layout';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function Layout({ children }: Readonly<PropsWithChildren>) {
|
||||
return <DocumentsLayout title="Политика конфиденциальности">{children}</DocumentsLayout>;
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
export const metadata = {
|
||||
title: 'Политика конфиденциальности',
|
||||
description:
|
||||
'Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
|
||||
};
|
||||
|
||||
### Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)
|
||||
|
||||
#### 1. Термины и определения
|
||||
|
||||
- **Telegram** – Telegram Messenger Inc. (платформа, на которой работает бот и мини-приложение).
|
||||
- **Платформа** – экосистема ботов и мини-приложений Telegram.
|
||||
- **Разработчик** – физическое лицо, самозанятый, владелец и оператор сервиса «Запишись.онлайн» (@zapishis_online_bot) - (далее — «Разработчик»).
|
||||
- **Сторонний сервис** – бот/мини-приложение Разработчика, предоставляемое в Платформе.
|
||||
- **Пользователь** – лицо, использующее Сторонний сервис через свою учетную запись Telegram (далее — «Вы»).
|
||||
- **Политика** – настоящий документ, регулирующий отношения между Разработчиком и Пользователем в части сбора и - обработки персональных данных.
|
||||
|
||||
#### 2. Общие положения
|
||||
|
||||
2.1. Настоящая Политика регулирует исключительно отношения между Разработчиком и Пользователем. Она не заменяет и не изменяет Политику конфиденциальности Telegram: [https://telegram.org/privacy](https://telegram.org/privacy).
|
||||
|
||||
2.2. Разработчик соблюдает применимые требования платформы Telegram к конфиденциальности и защите данных.
|
||||
|
||||
2.3. Использование Сервиса Пользователем и/или активация платного доступа означает согласие Пользователя с условиями настоящей Политики.
|
||||
|
||||
2.4. Если Вы не согласны с условиями Политики — прекратите использование Сервиса.
|
||||
|
||||
#### 3. Отказ от ответственности
|
||||
|
||||
3.1. Сторонний сервис является независимым приложением и не поддерживается, не одобряется и не аффилирован с Telegram (за исключением использования API и инфраструктуры Telegram).
|
||||
|
||||
3.2. Разработчик вправе изменять настоящую Политику — изменения вступают в силу с момента их публикации. Вы обязаны самостоятельно отслеживать обновления.
|
||||
|
||||
3.3. Используя Сервис, Вы подтверждаете, что ознакомлены и согласны с условиями использования Telegram для ботов и мини-приложений: [https://telegram.org/tos/bots](https://telegram.org/tos/bots), [https://telegram.org/tos/mini-apps](https://telegram.org/tos/mini-apps).
|
||||
|
||||
3.4. Вы гарантируете, что используете Сервис в соответствии с действующим законодательством и обладаете правом взаимодействовать с ним (например, достигли возраста, необходимого для использования услуг).
|
||||
|
||||
3.5. Вы обязуетесь предоставлять точную и актуальную информацию, если Сервис запрашивает её.
|
||||
|
||||
3.6. Любая информация, которую Вы делаете общедоступной самостоятельно (через профиль Telegram, публичные сообщения и т.п.), может стать доступна другим пользователям и не подпадает под защиту настоящей Политики в части конфиденциальности этой общедоступной информации.
|
||||
|
||||
#### 4. Сбор персональных данных
|
||||
|
||||
4.1. Telegram по умолчанию предоставляет сторонним сервисам ограниченный набор данных о Пользователе — подробнее: [https://telegram.org/privacy#6-bot-messages](https://telegram.org/privacy#6-bot-messages).
|
||||
|
||||
4.2. Сторонний сервис может дополнительно получать данные, которые Вы передаёте в чате бота или в мини-приложении (например, контакт, телефон), если Вы явно их отправляете.
|
||||
|
||||
4.3. В случае мини-приложения дополнительно могут передаваться данные в соответствии с правилами мини-приложений Telegram: [https://telegram.org/tos/mini-apps#4-privacy](https://telegram.org/tos/mini-apps#4-privacy).
|
||||
|
||||
4.4. Сторонний сервис может собирать также анонимную статистику использования (диагностика, события взаимодействия), не связываемую напрямую с персоной.
|
||||
|
||||
4.5. Пользователь может передавать данные третьих лиц (например, контактные данные клиентов или мастеров) для использования в Сервисе. При этом пользователь гарантирует, что эти лица дали согласие на обработку их персональных данных в рамках Сервиса.
|
||||
|
||||
#### 5. Какие данные мы собираем и как используем
|
||||
|
||||
5.1. Разработчик запрашивает, собирает и обрабатывает только те данные, которые необходимы для корректной работы функций Сервиса, в частности:
|
||||
|
||||
- Telegram ID и (опционально) отображаемое имя пользователя;
|
||||
- телефон, только если Вы предоставили его добровольно (например, при регистрации);
|
||||
- данные о заказах: дата/время, описание заказа, статус;
|
||||
- информация о факте покупки Pro-доступа: период доступа, тип покупки (детали платёжной транзакции обрабатывает платёжный оператор — ЮKassa);
|
||||
|
||||
5.2. Цели обработки:
|
||||
|
||||
- предоставление и поддержка работы Сервиса (создание заказов, напоминания, управление доступом);
|
||||
- подтверждение и учет оплат (взаимодействие с платёжным оператором для актуализации статуса доступа);
|
||||
- реализация реферальной программы (хранение связей «кто пригласил/кого пригласили»);
|
||||
- анализ использования и улучшение сервиса;
|
||||
- выполнение юридических обязательств (хранение информации о транзакциях и др.);
|
||||
|
||||
> **Важно:** детальные платёжные данные (реквизиты карт и т.д.) не хранятся у Разработчика — их обрабатывает платёжный оператор (ЮKassa) и Telegram-платежный бот.
|
||||
|
||||
#### 6. Передача данных третьим лицам
|
||||
|
||||
6.1. Разработчик не передаёт персональные данные третьим лицам, за исключением следующих случаев:
|
||||
|
||||
- платёжному оператору (ЮKassa) и связанным службам для обработки платежей;
|
||||
- Telegram как платформе для функционирования бота и мини-приложения;
|
||||
- в случае необходимости — исполнителям, оказывающим техническую поддержку, при условии подписания ими обязательств о конфиденциальности;
|
||||
- если передача требуется по закону (запросы уполномоченных органов и т.п.);
|
||||
|
||||
6.2. Разработчик не продаёт и не передаёт персональные данные для рекламных целей третьим лицам без Вашего отдельного согласия.
|
||||
|
||||
#### 7. Защита и хранение данных
|
||||
|
||||
7.1. Разработчик применяет разумные технические и организационные меры для защиты персональных данных (использование надежного VPS, ограничения доступа, резервное копирование и т.п.).
|
||||
|
||||
7.2. Доступ к персональным данным имеет только Разработчик (и/или доверенные исполнители технической поддержки при необходимости).
|
||||
|
||||
7.3. Данные хранятся на серверах, указанных Разработчиком. Если используются внешние сервисы/облачные хранилища — это будет указано в соответствующих местах Политики или сообщения при сборе данных.
|
||||
|
||||
#### 8. Права и обязанности сторон
|
||||
|
||||
8.1. Права Разработчика:
|
||||
|
||||
- вносить изменения в Политику с публикацией новой версии;
|
||||
- ограничивать доступ к API/сервису при подозрении в злоупотреблениях;
|
||||
- запросить подтверждение личности при необходимости обработки привилегированных запросов;
|
||||
|
||||
8.2. Обязанности Разработчика:
|
||||
|
||||
- обеспечивать доступность Политики и исполнять её условия;
|
||||
- обрабатывать законные запросы пользователей о доступе, изменении или удалении данных в разумные сроки (не позднее 30 дней, если иное не установлено законом);
|
||||
- соблюдать применимое законодательство о защите персональных данных;
|
||||
|
||||
8.3. Права Пользователя:
|
||||
|
||||
- запросить копию своих персональных данных, хранящихся у Разработчика;
|
||||
- потребовать исправления неточных данных;
|
||||
- потребовать удаления персональных данных в пределах, допустимых законом (с сохранением данных, необходимых для выполнения юридических обязательств, например, по учёту платежей);
|
||||
- отозвать согласие на обработку персональных данных, если такое согласие предоставлялось добровольно;
|
||||
- подать жалобу в уполномоченные органы по защите персональных данных, если считает, что его права нарушены;
|
||||
|
||||
8.4. Обязанности Пользователя:
|
||||
|
||||
- предоставлять точную и актуальную информацию;
|
||||
- не использовать Сервис в нарушении законодательства и условий Telegram.
|
||||
|
||||
#### 9. Реклама и использование данных для аналитики
|
||||
|
||||
9.1. На текущем этапе Разработчик не использует персональные данные для демонстрации таргетированной рекламы третьих лиц без явного согласия Пользователя.
|
||||
|
||||
9.2. Разработчик может собирать агрегированную (анонимную) статистику использования Сервиса для улучшения функционала.
|
||||
|
||||
#### 10. Изменения Политики
|
||||
|
||||
10.1. Разработчик вправе вносить изменения в настоящую Политику. Все изменения публикуются на этой странице и вступают в силу с момента публикации.
|
||||
|
||||
#### 11. Контакты
|
||||
|
||||
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.
|
||||
@ -1,3 +1,4 @@
|
||||
import { getOrder } from '@/actions/api/orders';
|
||||
import { Container } from '@/components/layout';
|
||||
import { PageHeader } from '@/components/navigation';
|
||||
import {
|
||||
@ -8,14 +9,23 @@ import {
|
||||
OrderStatus,
|
||||
} from '@/components/orders';
|
||||
import { type OrderPageParameters } from '@/components/orders/types';
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Props = { params: Promise<OrderPageParameters> };
|
||||
|
||||
export default async function ProfilePage(props: Readonly<Props>) {
|
||||
const parameters = await props.params;
|
||||
const documentId = parameters.documentId;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryFn: () => getOrder({ documentId }),
|
||||
queryKey: ['order', documentId],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PageHeader title="Запись" />
|
||||
<Container>
|
||||
<OrderDateTime {...parameters} />
|
||||
@ -25,6 +35,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
|
||||
<div className="pb-24" />
|
||||
<OrderButtons {...parameters} />
|
||||
</Container>
|
||||
</>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Container } from '@/components/layout';
|
||||
import { PageHeader } from '@/components/navigation';
|
||||
import { OrderForm } from '@/components/orders';
|
||||
|
||||
export default function AddOrdersPage() {
|
||||
export default async function AddOrdersPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Новая запись" />
|
||||
|
||||
@ -1,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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="Клиент"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')}>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import { type MDXComponents } from 'mdx/types';
|
||||
|
||||
const components = {} satisfies MDXComponents;
|
||||
|
||||
export function useMDXComponents(): MDXComponents {
|
||||
return components;
|
||||
}
|
||||
@ -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).*)'],
|
||||
};
|
||||
|
||||
@ -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'],
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
6
apps/web/public/locales/en.json
Normal file
6
apps/web/public/locales/en.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"i18n": {
|
||||
"header": "Application supports i18n",
|
||||
"footer": "You can select a different language from the dropdown menu."
|
||||
}
|
||||
}
|
||||
6
apps/web/public/locales/ru.json
Normal file
6
apps/web/public/locales/ru.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"i18n": {
|
||||
"header": "Поддержка i18n",
|
||||
"footer": "Вы можете выбрать другой язык в выпадающем меню."
|
||||
}
|
||||
}
|
||||
10
apps/web/utils/i18n/config.ts
Normal file
10
apps/web/utils/i18n/config.ts
Normal 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: 'Русский' },
|
||||
];
|
||||
18
apps/web/utils/i18n/i18n.ts
Normal file
18
apps/web/utils/i18n/i18n.ts
Normal 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;
|
||||
20
apps/web/utils/i18n/locale.ts
Normal file
20
apps/web/utils/i18n/locale.ts
Normal 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 };
|
||||
14
apps/web/utils/i18n/provider.tsx
Normal file
14
apps/web/utils/i18n/provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/utils/i18n/types.ts
Normal file
5
apps/web/utils/i18n/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { type locales } from './config';
|
||||
|
||||
type Locale = (typeof locales)[number];
|
||||
|
||||
export type { Locale };
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
16
packages/graphql/apollo/link.ts
Normal file
16
packages/graphql/apollo/link.ts
Normal 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]);
|
||||
}
|
||||
@ -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
@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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
2328
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user