Compare commits
45 Commits
main
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dac83a249 | ||
|
|
32ae140dca | ||
|
|
da12e65145 | ||
|
|
2de018b8d4 | ||
|
|
e6c823570c | ||
|
|
7862713a06 | ||
|
|
297ab1df2b | ||
|
|
0fd104048f | ||
|
|
7b0b2c7074 | ||
|
|
336f3a11fd | ||
|
|
a6d05bcf69 | ||
|
|
eab6da5e89 | ||
|
|
6228832aff | ||
|
|
17ce24ae04 | ||
|
|
c9187816a1 | ||
|
|
4139aa918d | ||
|
|
78e45718a8 | ||
|
|
d8f374d5da | ||
|
|
30bdc0447f | ||
|
|
de7cdefcd5 | ||
|
|
92035a4ff8 | ||
|
|
4e37146214 | ||
|
|
c9a4c23564 | ||
|
|
201ccaeea5 | ||
|
|
0b188ee5ed | ||
|
|
5dfef524e2 | ||
|
|
49c43296e4 | ||
|
|
3eb302a5d9 | ||
|
|
7af67b1910 | ||
|
|
04739612ca | ||
|
|
d870fa5a21 | ||
|
|
4cff4c8bbe | ||
|
|
b94937b706 | ||
|
|
db9788132d | ||
|
|
f2ad3dff17 | ||
|
|
fd3785a436 | ||
|
|
da51d45882 | ||
|
|
9903fe4233 | ||
|
|
81e223c69b | ||
|
|
63ff021916 | ||
|
|
10d47d260a | ||
|
|
ef5e509d6a | ||
|
|
4336cf5e60 | ||
|
|
812a77406c | ||
|
|
38251cd0e8 |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@ -26,6 +26,7 @@ jobs:
|
|||||||
echo "NEXTAUTH_SECRET=fakesecret" >> .env
|
echo "NEXTAUTH_SECRET=fakesecret" >> .env
|
||||||
echo "BOT_URL=http://localhost:3000" >> .env
|
echo "BOT_URL=http://localhost:3000" >> .env
|
||||||
echo "REDIS_PASSWORD=fake" >> .env
|
echo "REDIS_PASSWORD=fake" >> .env
|
||||||
|
echo "BOT_PROVIDER_TOKEN=fake" >> .env
|
||||||
|
|
||||||
- name: Set image tags
|
- name: Set image tags
|
||||||
id: vars
|
id: vars
|
||||||
@ -85,6 +86,7 @@ jobs:
|
|||||||
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" >> .env
|
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" >> .env
|
||||||
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
|
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
|
||||||
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
|
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
|
||||||
|
echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env
|
||||||
|
|
||||||
- name: Copy .env to VPS via SCP
|
- name: Copy .env to VPS via SCP
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@master
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
|
# Общие
|
||||||
|
-support-contact = ℹ️ По всем вопросам и обратной связи: @v_dev_support
|
||||||
|
|
||||||
# Описание бота
|
# Описание бота
|
||||||
short-description =
|
short-description =
|
||||||
Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅
|
Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅
|
||||||
|
|
||||||
ℹ️ По всем вопросам и обратной связи: @vchikalkin
|
{ -support-contact }
|
||||||
|
|
||||||
description =
|
description =
|
||||||
📲 Запишись.онлайн — это бесплатное Telegram-приложение для мастеров и тренеров в вашем смартфоне.
|
📲 Запишись.онлайн — это встроенное в Telegram приложение + бот для мастеров и тренеров в вашем смартфоне.
|
||||||
|
|
||||||
Возможности:
|
Возможности:
|
||||||
• 📅 Ведение графика и запись клиентов
|
• 📅 Ведение графика и запись клиентов
|
||||||
@ -17,75 +19,59 @@ description =
|
|||||||
✨ Всё, что нужно — ваш смартфон.
|
✨ Всё, что нужно — ваш смартфон.
|
||||||
|
|
||||||
|
|
||||||
ℹ️ По всем вопросам и обратной связи: @vchikalkin
|
{ -support-contact }
|
||||||
|
|
||||||
# Команды
|
# Команды
|
||||||
start =
|
start =
|
||||||
.description = Запуск бота
|
.description = Запуск бота
|
||||||
addcontact =
|
addcontact =
|
||||||
.description = Добавить контакт клиента
|
.description = Добавить контакт клиента
|
||||||
becomemaster =
|
|
||||||
.description = Стать мастером
|
|
||||||
sharebot =
|
sharebot =
|
||||||
.description = Поделиться ботом
|
.description = Поделиться ботом
|
||||||
|
subscribe =
|
||||||
|
.description = Приобрести Pro доступ
|
||||||
|
pro =
|
||||||
|
.description = Информация о вашем Pro доступе
|
||||||
help =
|
help =
|
||||||
.description = Список команд и поддержка
|
.description = Список команд и поддержка
|
||||||
|
|
||||||
commands-list =
|
commands-list =
|
||||||
📋 Доступные команды:
|
📋 Доступные команды:
|
||||||
• /addcontact — добавить контакт клиента
|
• /addcontact — добавить контакт клиента
|
||||||
• /becomemaster — стать мастером
|
|
||||||
• /sharebot — поделиться ботом
|
• /sharebot — поделиться ботом
|
||||||
|
• /subscribe — приобрести Pro доступ
|
||||||
|
• /pro — информация о вашем Pro доступе
|
||||||
• /help — список команд
|
• /help — список команд
|
||||||
|
|
||||||
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
|
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
|
||||||
|
|
||||||
support =
|
support =
|
||||||
ℹ️ По всем вопросам и обратной связи: @vchikalkin
|
{ -support-contact }
|
||||||
|
|
||||||
|
|
||||||
# Приветственные сообщения
|
# Приветственные сообщения
|
||||||
msg-welcome =
|
msg-welcome =
|
||||||
👋 Добро пожаловать!
|
👋 Добро пожаловать!
|
||||||
Пожалуйста, поделитесь своим номером телефона для регистрации
|
Пожалуйста, поделитесь своим номером телефона для регистрации
|
||||||
|
|
||||||
msg-welcome-back = 👋 С возвращением, { $name }!
|
msg-welcome-back = 👋 С возвращением, { $name }!
|
||||||
|
|
||||||
# Сообщения о статусе мастера
|
|
||||||
msg-not-master =
|
|
||||||
⛔️ Только мастер может добавлять контакты
|
|
||||||
Стать мастером можно на странице профиля в приложении или с помощью команды /becomemaster
|
|
||||||
|
|
||||||
msg-already-master = 🎉 Вы уже являетесь мастером!
|
|
||||||
|
|
||||||
msg-become-master = 🥳 Поздравляем! Теперь вы мастер
|
|
||||||
|
|
||||||
# Сообщения о телефоне
|
# Сообщения о телефоне
|
||||||
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
|
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
|
||||||
|
|
||||||
msg-phone-saved =
|
msg-phone-saved =
|
||||||
✅ Спасибо! Мы сохранили ваш номер телефона
|
✅ Спасибо! Мы сохранили ваш номер телефона
|
||||||
Теперь вы можете открыть приложение или воспользоваться командами бота
|
Теперь вы можете открыть приложение или воспользоваться командами бота
|
||||||
|
|
||||||
msg-already-registered =
|
msg-already-registered =
|
||||||
✅ Вы уже зарегистрированы в системе
|
✅ Вы уже зарегистрированы в системе
|
||||||
|
|
||||||
Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
|
Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
|
||||||
|
|
||||||
msg-invalid-phone = ❌ Некорректный номер телефона
|
msg-invalid-phone = ❌ Некорректный номер телефона
|
||||||
|
|
||||||
# Сообщения о контактах
|
# Сообщения о контактах
|
||||||
msg-send-client-contact =
|
msg-send-client-contact = 👤 Отправьте контакт клиента, которого вы хотите добавить.
|
||||||
👤 Отправьте контакт клиента, которого вы хотите добавить.
|
|
||||||
Для отмены операции используйте команду /cancel
|
|
||||||
|
|
||||||
msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
|
msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
|
||||||
|
|
||||||
msg-contact-added =
|
msg-contact-added =
|
||||||
✅ Добавили { $name } в список ваших клиентов
|
✅ Добавили { $name } в список ваших клиентов
|
||||||
|
|
||||||
Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи
|
Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи
|
||||||
|
|
||||||
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
|
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
|
||||||
|
|
||||||
# Сообщения для шаринга
|
# Сообщения для шаринга
|
||||||
@ -93,9 +79,11 @@ msg-share-bot =
|
|||||||
📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
|
📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
|
||||||
Нажмите кнопку ниже, чтобы начать
|
Нажмите кнопку ниже, чтобы начать
|
||||||
|
|
||||||
|
|
||||||
# Системные сообщения
|
# Системные сообщения
|
||||||
msg-cancel = ❌ Операция отменена
|
msg-cancel = ❌ Операция отменена
|
||||||
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
|
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
|
||||||
|
msg-cancel-operation = Для отмены операции используйте команду /cancel
|
||||||
|
|
||||||
# Ошибки
|
# Ошибки
|
||||||
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
||||||
@ -103,3 +91,20 @@ err-banned = 🚫 Ваш аккаунт заблокирован
|
|||||||
err-with-details = ❌ Произошла ошибка
|
err-with-details = ❌ Произошла ошибка
|
||||||
{ $error }
|
{ $error }
|
||||||
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного
|
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного
|
||||||
|
|
||||||
|
|
||||||
|
# Сообщения о доступе
|
||||||
|
msg-subscribe =
|
||||||
|
👑 Pro доступ
|
||||||
|
• Разблокирует неограниченное количество заказов
|
||||||
|
msg-subscribe-success = ✅ Платеж успешно обработан!
|
||||||
|
msg-subscribe-error = ❌ Произошла ошибка при обработке платежа
|
||||||
|
msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date }
|
||||||
|
msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days }
|
||||||
|
msg-subscription-expired =
|
||||||
|
Ваш Pro доступ истек.
|
||||||
|
Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов
|
||||||
|
msg-subscribe-disabled = 🚫 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉
|
||||||
|
|
||||||
|
# Информация о лимитах
|
||||||
|
msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count }
|
||||||
@ -25,10 +25,12 @@
|
|||||||
"@repo/graphql": "workspace:*",
|
"@repo/graphql": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"dayjs": "catalog:",
|
||||||
"grammy": "^1.38.1",
|
"grammy": "^1.38.1",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
"pino": "^9.9.0",
|
"pino": "^9.9.0",
|
||||||
"pino-pretty": "^13.1.1",
|
"pino-pretty": "^13.1.1",
|
||||||
|
"radashi": "catalog:",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
/* eslint-disable id-length */
|
/* eslint-disable id-length */
|
||||||
import { type Context } from '@/bot/context';
|
import { type Context } from '@/bot/context';
|
||||||
import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
|
import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
|
||||||
import { isCustomerMaster } from '@/utils/customer';
|
import { combine } from '@/utils/messages';
|
||||||
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
|
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
|
||||||
import { type Conversation } from '@grammyjs/conversations';
|
import { type Conversation } from '@grammyjs/conversations';
|
||||||
import { CustomersService } from '@repo/graphql/api/customers';
|
import { CustomersService } from '@repo/graphql/api/customers';
|
||||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||||
|
|
||||||
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
|
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
|
||||||
// Проверяем, что пользователь является мастером
|
// Все пользователи могут добавлять контакты
|
||||||
const telegramId = ctx.from?.id;
|
const telegramId = ctx.from?.id;
|
||||||
if (!telegramId) {
|
if (!telegramId) {
|
||||||
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
||||||
@ -24,12 +24,12 @@ export async function addContact(conversation: Conversation<Context, Context>, c
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCustomerMaster(customer)) {
|
|
||||||
return ctx.reply(await conversation.external(({ t }) => t('msg-not-master')));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Просим отправить контакт клиента
|
// Просим отправить контакт клиента
|
||||||
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact')));
|
await ctx.reply(
|
||||||
|
await conversation.external(({ t }) =>
|
||||||
|
combine(t('msg-send-client-contact'), t('msg-cancel-operation')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Ждем любое сообщение от пользователя
|
// Ждем любое сообщение от пользователя
|
||||||
const waitCtx = await conversation.wait();
|
const waitCtx = await conversation.wait();
|
||||||
@ -62,9 +62,9 @@ export async function addContact(conversation: Conversation<Context, Context>, c
|
|||||||
if (!documentId) throw new Error('Клиент не создан');
|
if (!documentId) throw new Error('Клиент не создан');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем текущего мастера к клиенту
|
// Добавляем текущего пользователя к приглашенному
|
||||||
const masters = [customer.documentId];
|
const invitedBy = [customer.documentId];
|
||||||
await customerService.addMasters({ data: { masters }, documentId });
|
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
|
||||||
|
|
||||||
// Отправляем подтверждения и инструкции
|
// Отправляем подтверждения и инструкции
|
||||||
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
|
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from './add-contact';
|
export * from './add-contact';
|
||||||
|
export * from './subscription';
|
||||||
|
|||||||
151
apps/bot/src/bot/conversations/subscription.ts
Normal file
151
apps/bot/src/bot/conversations/subscription.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
/* eslint-disable id-length */
|
||||||
|
import { type Context } from '@/bot/context';
|
||||||
|
import { env } from '@/config/env';
|
||||||
|
import { formatMoney } from '@/utils/format';
|
||||||
|
import { combine } from '@/utils/messages';
|
||||||
|
import { type Conversation } from '@grammyjs/conversations';
|
||||||
|
import { fmt, i } from '@grammyjs/parse-mode';
|
||||||
|
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||||
|
import * as GQL from '@repo/graphql/types';
|
||||||
|
import { InlineKeyboard } from 'grammy';
|
||||||
|
import { sift } from 'radashi';
|
||||||
|
|
||||||
|
export async function subscription(conversation: Conversation<Context, Context>, ctx: Context) {
|
||||||
|
const telegramId = ctx.from?.id;
|
||||||
|
if (!telegramId) {
|
||||||
|
return replyError(ctx, conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||||
|
|
||||||
|
const {
|
||||||
|
hasActiveSubscription,
|
||||||
|
remainingDays,
|
||||||
|
subscription: currentSubscription,
|
||||||
|
usedTrialSubscription,
|
||||||
|
} = await subscriptionsService.getSubscription({
|
||||||
|
telegramId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { subscriptionPrices } = await subscriptionsService.getSubscriptionPrices({
|
||||||
|
filters: {
|
||||||
|
active: {
|
||||||
|
eq: true,
|
||||||
|
},
|
||||||
|
period: {
|
||||||
|
ne: usedTrialSubscription ? GQL.Enum_Subscriptionprice_Period.Trial : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const prices = sift(subscriptionPrices);
|
||||||
|
|
||||||
|
// строим клавиатуру с указанием даты окончания после покупки
|
||||||
|
const keyboard = buildPricesKeyboard(prices, currentSubscription?.expiresAt);
|
||||||
|
|
||||||
|
// сообщение с выбором плана
|
||||||
|
const messageWithPrices = await ctx.reply(
|
||||||
|
combine(
|
||||||
|
await conversation.external(({ t }) => {
|
||||||
|
let statusLine = t('msg-subscribe');
|
||||||
|
if (hasActiveSubscription && currentSubscription?.expiresAt) {
|
||||||
|
statusLine = t('msg-subscription-active-until', {
|
||||||
|
date: new Date(currentSubscription.expiresAt).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (remainingDays) {
|
||||||
|
statusLine = t('msg-subscription-active-days', { days: remainingDays });
|
||||||
|
}
|
||||||
|
|
||||||
|
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ reply_markup: keyboard },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ждём выбора
|
||||||
|
const selectPlanWaitCtx = await conversation.wait();
|
||||||
|
|
||||||
|
// удаляем сообщение с выбором
|
||||||
|
try {
|
||||||
|
await ctx.api.deleteMessage(telegramId, messageWithPrices.message_id);
|
||||||
|
} catch {
|
||||||
|
/* игнорируем, если не удалось удалить */
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPeriod = selectPlanWaitCtx.callbackQuery?.data;
|
||||||
|
if (!selectedPeriod) return replyError(ctx, conversation);
|
||||||
|
|
||||||
|
const selectedPrice = prices.find((price) => price?.period === selectedPeriod);
|
||||||
|
if (!selectedPrice) return replyError(ctx, conversation);
|
||||||
|
|
||||||
|
// создаём invoice (с указанием даты, до которой будет доступ)
|
||||||
|
const baseDate = currentSubscription?.expiresAt
|
||||||
|
? new Date(Math.max(Date.now(), new Date(currentSubscription.expiresAt).getTime()))
|
||||||
|
: new Date();
|
||||||
|
const targetDate = addDays(baseDate, selectedPrice.days ?? 0);
|
||||||
|
const targetDateRu = targetDate.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
return ctx.replyWithInvoice(
|
||||||
|
'Оплата Pro доступа',
|
||||||
|
combine(
|
||||||
|
`${selectedPrice.description || 'Pro доступ'} — до ${targetDateRu}`,
|
||||||
|
'(Автопродление отключено)',
|
||||||
|
),
|
||||||
|
JSON.stringify({ period: selectedPrice.period }),
|
||||||
|
'RUB',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
amount: selectedPrice.amount * 100, // Telegram ждёт в копейках
|
||||||
|
label: `${selectedPrice.description || 'К оплате'} — до ${targetDateRu}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
provider_token: env.BOT_PROVIDER_TOKEN,
|
||||||
|
start_parameter: 'get_access',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPricesKeyboard(
|
||||||
|
prices: GQL.SubscriptionPriceFieldsFragment[],
|
||||||
|
currentExpiresAt?: string,
|
||||||
|
) {
|
||||||
|
const keyboard = new InlineKeyboard();
|
||||||
|
const baseTime = currentExpiresAt
|
||||||
|
? Math.max(Date.now(), new Date(currentExpiresAt).getTime())
|
||||||
|
: Date.now();
|
||||||
|
for (const price of prices) {
|
||||||
|
const targetDate = addDays(new Date(baseTime), price.days ?? 0);
|
||||||
|
const targetDateRu = targetDate.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
keyboard.row({
|
||||||
|
callback_data: price.period,
|
||||||
|
pay: true,
|
||||||
|
text: `Продлить до ${targetDateRu} (${formatMoney(price.amount)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyError(ctx: Context, conversation: Conversation<Context, Context>) {
|
||||||
|
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
|
||||||
|
}
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { type Context } from '@/bot/context';
|
|
||||||
import { logHandle } from '@/bot/helpers/logging';
|
|
||||||
import { KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
|
|
||||||
import { isCustomerMaster } from '@/utils/customer';
|
|
||||||
import { CustomersService } from '@repo/graphql/api/customers';
|
|
||||||
import { Enum_Customer_Role } from '@repo/graphql/types';
|
|
||||||
import { Composer } from 'grammy';
|
|
||||||
|
|
||||||
const composer = new Composer<Context>();
|
|
||||||
|
|
||||||
const feature = composer.chatType('private');
|
|
||||||
|
|
||||||
feature.command('becomemaster', logHandle('command-become-master'), async (ctx) => {
|
|
||||||
const telegramId = ctx.from.id;
|
|
||||||
const customerService = new CustomersService({ telegramId });
|
|
||||||
const { customer } = await customerService.getCustomer({ telegramId });
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
return ctx.reply(ctx.t('msg-need-phone'), { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCustomerMaster(customer)) {
|
|
||||||
return ctx.reply(ctx.t('msg-already-master'), { parse_mode: 'HTML' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем роль клиента на мастер
|
|
||||||
const response = await customerService
|
|
||||||
.updateCustomer({
|
|
||||||
data: { role: Enum_Customer_Role.Master },
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
ctx.reply(ctx.t('err-with-details', { error: String(error) }), { parse_mode: 'HTML' });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
return ctx.reply(ctx.t('msg-become-master'), { parse_mode: 'HTML' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.reply(ctx.t('err-generic'), { parse_mode: 'HTML' });
|
|
||||||
});
|
|
||||||
|
|
||||||
export { composer as becomeMaster };
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
export * from './add-contact';
|
export * from './add-contact';
|
||||||
export * from './become-master';
|
|
||||||
export * from './help';
|
export * from './help';
|
||||||
|
export * from './pro';
|
||||||
export * from './registration';
|
export * from './registration';
|
||||||
export * from './share-bot';
|
export * from './share-bot';
|
||||||
|
export * from './subscription';
|
||||||
export * from './welcome';
|
export * from './welcome';
|
||||||
|
|||||||
39
apps/bot/src/bot/features/pro.ts
Normal file
39
apps/bot/src/bot/features/pro.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { type Context } from '@/bot/context';
|
||||||
|
import { logHandle } from '@/bot/helpers/logging';
|
||||||
|
import { combine } from '@/utils/messages';
|
||||||
|
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||||
|
import { Composer } from 'grammy';
|
||||||
|
|
||||||
|
const composer = new Composer<Context>();
|
||||||
|
const feature = composer.chatType('private');
|
||||||
|
|
||||||
|
feature.command('pro', logHandle('command-pro'), async (ctx) => {
|
||||||
|
const telegramId = ctx.from.id;
|
||||||
|
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||||
|
|
||||||
|
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
||||||
|
const proEnabled = subscriptionSetting?.proEnabled;
|
||||||
|
|
||||||
|
if (!proEnabled) return ctx.reply(ctx.t('msg-subscribe-disabled'));
|
||||||
|
|
||||||
|
const { hasActiveSubscription, remainingDays, remainingOrdersCount } =
|
||||||
|
await subscriptionsService.getSubscription({ telegramId });
|
||||||
|
|
||||||
|
if (hasActiveSubscription && remainingDays > 0) {
|
||||||
|
return ctx.reply(
|
||||||
|
combine(
|
||||||
|
ctx.t('msg-subscription-active-days', { days: remainingDays }),
|
||||||
|
remainingDays === 0 ? ctx.t('msg-subscription-expired') : '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.reply(
|
||||||
|
combine(
|
||||||
|
ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }),
|
||||||
|
remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { composer as pro };
|
||||||
54
apps/bot/src/bot/features/subscription.ts
Normal file
54
apps/bot/src/bot/features/subscription.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { type Context } from '@/bot/context';
|
||||||
|
import { logHandle } from '@/bot/helpers/logging';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||||
|
import { Composer } from 'grammy';
|
||||||
|
|
||||||
|
const composer = new Composer<Context>();
|
||||||
|
|
||||||
|
// Telegram требует отвечать на pre_checkout_query
|
||||||
|
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
|
||||||
|
console.log('🚀 ~ ctx:', ctx);
|
||||||
|
await ctx.answerPreCheckoutQuery(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = composer.chatType('private');
|
||||||
|
|
||||||
|
// команда для входа в flow подписки
|
||||||
|
feature.command('subscribe', logHandle('command-subscribe'), async (ctx) => {
|
||||||
|
const telegramId = ctx.from.id;
|
||||||
|
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||||
|
|
||||||
|
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
||||||
|
|
||||||
|
const proEnabled = subscriptionSetting?.proEnabled;
|
||||||
|
|
||||||
|
if (!proEnabled) return ctx.reply(ctx.t('msg-subscribe-disabled'));
|
||||||
|
|
||||||
|
return ctx.conversation.enter('subscription');
|
||||||
|
});
|
||||||
|
|
||||||
|
// успешная оплата
|
||||||
|
feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
|
||||||
|
const telegramId = ctx.from.id;
|
||||||
|
const subscriptionsService = new SubscriptionsService({ telegramId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawPayload = ctx.message?.successful_payment.invoice_payload;
|
||||||
|
if (!rawPayload) throw new Error('Missing invoice payload');
|
||||||
|
|
||||||
|
const payload = JSON.parse(rawPayload);
|
||||||
|
|
||||||
|
const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(payload);
|
||||||
|
|
||||||
|
await ctx.reply(ctx.t('msg-subscribe-success'));
|
||||||
|
await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate }));
|
||||||
|
} catch (error) {
|
||||||
|
await ctx.reply(ctx.t('msg-subscribe-error'));
|
||||||
|
logger.error(
|
||||||
|
'Failed to process subscription after successful payment\n' + (error as Error)?.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { composer as subscription };
|
||||||
@ -3,7 +3,7 @@ import { I18n } from '@grammyjs/i18n';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
export const i18n = new I18n<Context>({
|
export const i18n = new I18n<Context>({
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'ru',
|
||||||
directory: path.resolve(process.cwd(), 'locales'),
|
directory: path.resolve(process.cwd(), 'locales'),
|
||||||
fluentBundleOptions: {
|
fluentBundleOptions: {
|
||||||
useIsolating: false,
|
useIsolating: false,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { setCommands } from './settings/commands';
|
|||||||
import { setInfo } from './settings/info';
|
import { setInfo } from './settings/info';
|
||||||
import { env } from '@/config/env';
|
import { env } from '@/config/env';
|
||||||
import { getRedisInstance } from '@/utils/redis';
|
import { getRedisInstance } from '@/utils/redis';
|
||||||
import { autoChatAction } from '@grammyjs/auto-chat-action';
|
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action';
|
||||||
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
|
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
|
||||||
import { hydrate } from '@grammyjs/hydrate';
|
import { hydrate } from '@grammyjs/hydrate';
|
||||||
import { limit } from '@grammyjs/ratelimiter';
|
import { limit } from '@grammyjs/ratelimiter';
|
||||||
@ -38,6 +38,10 @@ export function createBot({ token }: Parameters_) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
bot.use(autoChatAction(bot.api));
|
||||||
|
|
||||||
|
bot.use(chatAction('typing'));
|
||||||
|
|
||||||
bot.use(grammyConversations()).command('cancel', async (ctx) => {
|
bot.use(grammyConversations()).command('cancel', async (ctx) => {
|
||||||
await ctx.conversation.exitAll();
|
await ctx.conversation.exitAll();
|
||||||
await ctx.reply(ctx.t('msg-cancel'));
|
await ctx.reply(ctx.t('msg-cancel'));
|
||||||
@ -53,7 +57,6 @@ export function createBot({ token }: Parameters_) {
|
|||||||
const protectedBot = bot.errorBoundary(errorHandler);
|
const protectedBot = bot.errorBoundary(errorHandler);
|
||||||
|
|
||||||
protectedBot.use(middlewares.updateLogger());
|
protectedBot.use(middlewares.updateLogger());
|
||||||
protectedBot.use(autoChatAction(bot.api));
|
|
||||||
protectedBot.use(hydrate());
|
protectedBot.use(hydrate());
|
||||||
|
|
||||||
for (const feature of Object.values(features)) {
|
for (const feature of Object.values(features)) {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { type LanguageCode } from '@grammyjs/types';
|
|||||||
import { type Api, type Bot, type RawApi } from 'grammy';
|
import { type Api, type Bot, type RawApi } from 'grammy';
|
||||||
|
|
||||||
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
|
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
|
||||||
const commands = createCommands(['start', 'addcontact', 'becomemaster', 'sharebot', 'help']);
|
const commands = createCommands(['start', 'addcontact', 'sharebot', 'help', 'subscribe', 'pro']);
|
||||||
|
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
addLocalizations(command);
|
addLocalizations(command);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
|
BOT_PROVIDER_TOKEN: z.string(),
|
||||||
BOT_TOKEN: z.string(),
|
BOT_TOKEN: z.string(),
|
||||||
BOT_URL: z.string(),
|
BOT_URL: z.string(),
|
||||||
RATE_LIMIT: z
|
RATE_LIMIT: z
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
import * as GQL from '@repo/graphql/types';
|
|
||||||
|
|
||||||
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
|
|
||||||
return customer?.role === GQL.Enum_Customer_Role.Master;
|
|
||||||
}
|
|
||||||
4
apps/bot/src/utils/format.ts
Normal file
4
apps/bot/src/utils/format.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const formatMoney = Intl.NumberFormat('ru-RU', {
|
||||||
|
currency: 'RUB',
|
||||||
|
style: 'currency',
|
||||||
|
}).format;
|
||||||
@ -1,3 +1,3 @@
|
|||||||
export function combine(...messages: string[]) {
|
export function combine(...messages: Array<string | undefined>) {
|
||||||
return messages.join('\n\n');
|
return messages.filter(Boolean).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import * as customers from './server/customers';
|
import * as customers from './server/customers';
|
||||||
import { wrapClientAction } from '@/utils/actions';
|
import { wrapClientAction } from '@/utils/actions';
|
||||||
|
|
||||||
export const addMasters = wrapClientAction(customers.addMasters);
|
export const addInvitedBy = wrapClientAction(customers.addInvitedBy);
|
||||||
export const getClients = wrapClientAction(customers.getClients);
|
export const getInvited = wrapClientAction(customers.getInvited);
|
||||||
export const getCustomer = wrapClientAction(customers.getCustomer);
|
export const getCustomer = wrapClientAction(customers.getCustomer);
|
||||||
export const getMasters = wrapClientAction(customers.getMasters);
|
export const getCustomers = wrapClientAction(customers.getCustomers);
|
||||||
|
export const getInvitedBy = wrapClientAction(customers.getInvitedBy);
|
||||||
export const updateCustomer = wrapClientAction(customers.updateCustomer);
|
export const updateCustomer = wrapClientAction(customers.updateCustomer);
|
||||||
|
|||||||
@ -6,16 +6,10 @@ import { CustomersService } from '@repo/graphql/api/customers';
|
|||||||
|
|
||||||
const getService = useService(CustomersService);
|
const getService = useService(CustomersService);
|
||||||
|
|
||||||
export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
|
export async function addInvitedBy(...variables: Parameters<CustomersService['addInvitedBy']>) {
|
||||||
const service = await getService();
|
const service = await getService();
|
||||||
|
|
||||||
return wrapServerAction(() => service.addMasters(...variables));
|
return wrapServerAction(() => service.addInvitedBy(...variables));
|
||||||
}
|
|
||||||
|
|
||||||
export async function getClients(...variables: Parameters<CustomersService['getClients']>) {
|
|
||||||
const service = await getService();
|
|
||||||
|
|
||||||
return wrapServerAction(() => service.getClients(...variables));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
|
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
|
||||||
@ -24,10 +18,22 @@ export async function getCustomer(...variables: Parameters<CustomersService['get
|
|||||||
return wrapServerAction(() => service.getCustomer(...variables));
|
return wrapServerAction(() => service.getCustomer(...variables));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMasters(...variables: Parameters<CustomersService['getMasters']>) {
|
export async function getCustomers(...variables: Parameters<CustomersService['getCustomers']>) {
|
||||||
const service = await getService();
|
const service = await getService();
|
||||||
|
|
||||||
return wrapServerAction(() => service.getMasters(...variables));
|
return wrapServerAction(() => service.getCustomers(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvited(...variables: Parameters<CustomersService['getInvited']>) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.getInvited(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvitedBy(...variables: Parameters<CustomersService['getInvitedBy']>) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.getInvitedBy(...variables));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {
|
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {
|
||||||
|
|||||||
76
apps/web/actions/api/server/subscriptions.ts
Normal file
76
apps/web/actions/api/server/subscriptions.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
'use server';
|
||||||
|
import { useService } from '../lib/service';
|
||||||
|
import { wrapServerAction } from '@/utils/actions';
|
||||||
|
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
|
||||||
|
|
||||||
|
const getService = useService(SubscriptionsService);
|
||||||
|
|
||||||
|
export async function createSubscription(
|
||||||
|
...variables: Parameters<SubscriptionsService['createSubscription']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.createSubscription(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSubscriptionHistory(
|
||||||
|
...variables: Parameters<SubscriptionsService['createSubscriptionHistory']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.createSubscriptionHistory(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrialSubscription() {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.createTrialSubscription());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscription(
|
||||||
|
...variables: Parameters<SubscriptionsService['getSubscription']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.getSubscription(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscriptionHistory(
|
||||||
|
...variables: Parameters<SubscriptionsService['getSubscriptionHistory']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.getSubscriptionHistory(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscriptionPrices(
|
||||||
|
...variables: Parameters<SubscriptionsService['getSubscriptionPrices']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.getSubscriptionPrices(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscriptionSettings(
|
||||||
|
...variables: Parameters<SubscriptionsService['getSubscriptionSettings']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.getSubscriptionSettings(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubscription(
|
||||||
|
...variables: Parameters<SubscriptionsService['updateSubscription']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.updateSubscription(...variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubscriptionHistory(
|
||||||
|
...variables: Parameters<SubscriptionsService['updateSubscriptionHistory']>
|
||||||
|
) {
|
||||||
|
const service = await getService();
|
||||||
|
|
||||||
|
return wrapServerAction(() => service.updateSubscriptionHistory(...variables));
|
||||||
|
}
|
||||||
12
apps/web/actions/api/subscriptions.ts
Normal file
12
apps/web/actions/api/subscriptions.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import * as subscriptions from './server/subscriptions';
|
||||||
|
import { wrapClientAction } from '@/utils/actions';
|
||||||
|
|
||||||
|
export const getSubscription = wrapClientAction(subscriptions.getSubscription);
|
||||||
|
export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings);
|
||||||
|
export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices);
|
||||||
|
export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory);
|
||||||
|
export const createSubscription = wrapClientAction(subscriptions.createSubscription);
|
||||||
|
export const updateSubscription = wrapClientAction(subscriptions.updateSubscription);
|
||||||
|
export const createSubscriptionHistory = wrapClientAction(subscriptions.createSubscriptionHistory);
|
||||||
|
export const updateSubscriptionHistory = wrapClientAction(subscriptions.updateSubscriptionHistory);
|
||||||
|
export const createTrialSubscription = wrapClientAction(subscriptions.createTrialSubscription);
|
||||||
@ -22,7 +22,18 @@ export default function Auth() {
|
|||||||
signIn('telegram', {
|
signIn('telegram', {
|
||||||
callbackUrl: '/profile',
|
callbackUrl: '/profile',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
telegramId: user?.id,
|
telegramId: user?.id?.toString(),
|
||||||
|
}).then((result) => {
|
||||||
|
if (
|
||||||
|
result?.error &&
|
||||||
|
(result?.error?.includes('CredentialsSignin') ||
|
||||||
|
result?.error?.includes('UNREGISTERED'))
|
||||||
|
) {
|
||||||
|
// Пользователь не зарегистрирован
|
||||||
|
redirect('/unregistered');
|
||||||
|
} else if (result?.ok) {
|
||||||
|
redirect('/profile');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,18 @@ function useAuth() {
|
|||||||
signIn('telegram', {
|
signIn('telegram', {
|
||||||
callbackUrl: '/profile',
|
callbackUrl: '/profile',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
telegramId: initDataUser.id,
|
telegramId: initDataUser.id.toString(),
|
||||||
}).then(() => redirect('/profile'));
|
}).then((result) => {
|
||||||
|
if (
|
||||||
|
result?.error &&
|
||||||
|
(result?.error?.includes('CredentialsSignin') || result?.error?.includes('UNREGISTERED'))
|
||||||
|
) {
|
||||||
|
// Пользователь не зарегистрирован
|
||||||
|
redirect('/unregistered');
|
||||||
|
} else if (result?.ok) {
|
||||||
|
redirect('/profile');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [initDataUser?.id, status]);
|
}, [initDataUser?.id, status]);
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/web/app/(auth)/unregistered/page.tsx
Normal file
54
apps/web/app/(auth)/unregistered/page.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { UnregisteredClient } from './unregistered-client';
|
||||||
|
import { Container } from '@/components/layout';
|
||||||
|
import { env } from '@/config/env';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@repo/ui/components/ui/card';
|
||||||
|
import { Bot, MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function UnregisteredPage() {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20">
|
||||||
|
<Bot className="size-8 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Давайте познакомимся</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Для использования приложения необходимо поделиться своим номером телефона с ботом
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-muted p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MessageCircle className="mt-0.5 size-5 text-blue-500" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="mb-1 font-medium text-foreground">Как поделиться:</p>
|
||||||
|
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
||||||
|
<li>Вернитесь к Telegram боту</li>
|
||||||
|
<li>
|
||||||
|
Отправьте команду{' '}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 text-xs">/start</code>
|
||||||
|
</li>
|
||||||
|
<li>Нажмите на появившуюся кнопку "Отправить номер телефона"</li>
|
||||||
|
<li>Закройте и откройте это приложение еще раз</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UnregisteredClient botUrl={env.BOT_URL} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/app/(auth)/unregistered/unregistered-client.tsx
Normal file
37
apps/web/app/(auth)/unregistered/unregistered-client.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
|
import { Bot, ExternalLink } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
type UnregisteredClientProps = {
|
||||||
|
readonly botUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UnregisteredClient({ botUrl }: UnregisteredClientProps) {
|
||||||
|
const handleSignOut = () => {
|
||||||
|
signOut({ callbackUrl: '/' });
|
||||||
|
};
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href={botUrl} rel="noopener noreferrer" target="_blank">
|
||||||
|
<Bot className="mr-2 size-4" />
|
||||||
|
Перейти к боту
|
||||||
|
<ExternalLink className="ml-2 size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full" onClick={handleRefresh} variant="outline">
|
||||||
|
Обновить страницу
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full" onClick={handleSignOut} variant="outline">
|
||||||
|
Выйти из аккаунта
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ContactsFilter, ContactsList } from '@/components/contacts';
|
import { ContactsList } from '@/components/contacts';
|
||||||
import { ContactsContextProvider } from '@/context/contacts';
|
import { ContactsContextProvider } from '@/context/contacts';
|
||||||
import { Card } from '@repo/ui/components/ui/card';
|
import { Card } from '@repo/ui/components/ui/card';
|
||||||
|
|
||||||
@ -8,7 +8,7 @@ export default function ContactsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="flex flex-row items-center justify-between space-x-4 p-4">
|
<div className="flex flex-row items-center justify-between space-x-4 p-4">
|
||||||
<h1 className="font-bold">Контакты</h1>
|
<h1 className="font-bold">Контакты</h1>
|
||||||
<ContactsFilter />
|
{/* <ContactsFilter /> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 pt-0">
|
<div className="p-4 pt-0">
|
||||||
<ContactsList />
|
<ContactsList />
|
||||||
|
|||||||
96
apps/web/app/(main)/pro/page.tsx
Normal file
96
apps/web/app/(main)/pro/page.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { getSubscription } from '@/actions/api/subscriptions';
|
||||||
|
import { getSessionUser } from '@/actions/session';
|
||||||
|
import { PageHeader } from '@/components/navigation';
|
||||||
|
import { TryFreeButton } from '@/components/subscription';
|
||||||
|
import { env } from '@/config/env';
|
||||||
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
|
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default async function ProPage() {
|
||||||
|
const { telegramId } = await getSessionUser();
|
||||||
|
const { hasActiveSubscription, usedTrialSubscription } = await getSubscription({
|
||||||
|
telegramId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canUseTrial = !usedTrialSubscription;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
|
<PageHeader title="" />
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="px-4 py-16 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-4xl text-center">
|
||||||
|
<div className="mb-2 flex justify-center">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-purple-600 to-blue-600 opacity-30 blur-xl dark:from-purple-700 dark:to-blue-700" />
|
||||||
|
<div className="relative rounded-full bg-gradient-to-r from-purple-600 to-blue-600 p-4 dark:from-purple-700 dark:to-blue-700">
|
||||||
|
<Crown className="size-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-6 text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
|
||||||
|
Доступ{' '}
|
||||||
|
<span className="bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent dark:from-purple-700 dark:to-blue-700">
|
||||||
|
Pro
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mx-auto mb-8 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
||||||
|
{hasActiveSubscription
|
||||||
|
? 'Ваш Pro доступ активен!'
|
||||||
|
: 'Разблокируйте больше возможностей'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!hasActiveSubscription && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||||
|
{canUseTrial && <TryFreeButton />}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className={`w-full border-2 text-base font-semibold sm:w-auto ${
|
||||||
|
canUseTrial
|
||||||
|
? 'border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
: 'border-0 bg-gradient-to-r from-purple-600 to-blue-600 text-white hover:from-purple-700 hover:to-blue-700 dark:from-purple-700 dark:to-blue-700 dark:hover:from-purple-800 dark:hover:to-blue-800'
|
||||||
|
}`}
|
||||||
|
size="lg"
|
||||||
|
variant={canUseTrial ? 'outline' : 'default'}
|
||||||
|
>
|
||||||
|
<Link href={env.BOT_URL} rel="noopener noreferrer" target="_blank">
|
||||||
|
Приобрести Pro доступ через бота
|
||||||
|
<ArrowRight className="ml-2 size-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<h2 className="mb-6 text-center text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Преимущества
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
|
||||||
|
<div className="mt-1 shrink-0">
|
||||||
|
<InfinityIcon className="size-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
|
||||||
|
Доступно неограниченное количество записей в месяц
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
|
||||||
|
<div className="mt-1 shrink-0">
|
||||||
|
<Star className="size-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
|
||||||
|
Профиль и аватар выделяются цветом
|
||||||
|
</p>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,8 +3,7 @@ import { getSessionUser } from '@/actions/session';
|
|||||||
import { Container } from '@/components/layout';
|
import { Container } from '@/components/layout';
|
||||||
import { PageHeader } from '@/components/navigation';
|
import { PageHeader } from '@/components/navigation';
|
||||||
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
|
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
|
||||||
import { BookButton } from '@/components/shared/book-button';
|
import { ReadonlyServicesList } from '@/components/profile/services';
|
||||||
import { isCustomerMaster } from '@repo/utils/customer';
|
|
||||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
// Тип параметров страницы
|
// Тип параметров страницы
|
||||||
@ -31,25 +30,14 @@ export default async function ProfilePage(props: Readonly<Props>) {
|
|||||||
// Проверка наличия данных
|
// Проверка наличия данных
|
||||||
if (!profile || !currentUser) return null;
|
if (!profile || !currentUser) return null;
|
||||||
|
|
||||||
// Определяем роли и id
|
|
||||||
const isMaster = isCustomerMaster(currentUser);
|
|
||||||
const masterId = isMaster ? currentUser.documentId : profile.documentId;
|
|
||||||
const clientId = isMaster ? profile.documentId : currentUser.documentId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<PageHeader title="Профиль контакта" />
|
<PageHeader title="Профиль контакта" />
|
||||||
<Container className="px-0">
|
<Container className="px-0">
|
||||||
<PersonCard telegramId={contactTelegramId} />
|
<PersonCard telegramId={contactTelegramId} />
|
||||||
<ContactDataCard telegramId={contactTelegramId} />
|
<ContactDataCard telegramId={contactTelegramId} />
|
||||||
|
<ReadonlyServicesList masterId={profile.documentId} />
|
||||||
<ProfileOrdersList telegramId={contactTelegramId} />
|
<ProfileOrdersList telegramId={contactTelegramId} />
|
||||||
{masterId && clientId && (
|
|
||||||
<BookButton
|
|
||||||
clientId={clientId}
|
|
||||||
label={isMaster ? 'Записать' : 'Записаться'}
|
|
||||||
masterId={masterId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { getCustomer } from '@/actions/api/customers';
|
import { getCustomer } from '@/actions/api/customers';
|
||||||
|
import { getSubscriptionSettings } from '@/actions/api/subscriptions';
|
||||||
import { getSessionUser } from '@/actions/session';
|
import { getSessionUser } from '@/actions/session';
|
||||||
import { Container } from '@/components/layout';
|
import { Container } from '@/components/layout';
|
||||||
import { LinksCard, PersonCard, ProfileDataCard } from '@/components/profile';
|
import { LinksCard, PersonCard, ProfileDataCard, SubscriptionInfoBar } from '@/components/profile';
|
||||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const { telegramId } = await getSessionUser();
|
const { telegramId } = await getSessionUser();
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
@ -14,10 +14,18 @@ export default async function ProfilePage() {
|
|||||||
queryKey: ['customer', telegramId],
|
queryKey: ['customer', telegramId],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { subscriptionSetting } = await queryClient.fetchQuery({
|
||||||
|
queryFn: getSubscriptionSettings,
|
||||||
|
queryKey: ['customer', telegramId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const proEnabled = subscriptionSetting?.proEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<Container className="px-0">
|
<Container className="px-0">
|
||||||
<PersonCard />
|
<PersonCard />
|
||||||
|
{proEnabled && <SubscriptionInfoBar />}
|
||||||
<ProfileDataCard />
|
<ProfileDataCard />
|
||||||
<LinksCard />
|
<LinksCard />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { getCustomer } from '@/actions/api/customers';
|
|
||||||
import { getSlot } from '@/actions/api/slots';
|
import { getSlot } from '@/actions/api/slots';
|
||||||
import { getSessionUser } from '@/actions/session';
|
|
||||||
import { Container } from '@/components/layout';
|
import { Container } from '@/components/layout';
|
||||||
import { PageHeader } from '@/components/navigation';
|
import { PageHeader } from '@/components/navigation';
|
||||||
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
|
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
|
||||||
@ -21,22 +19,13 @@ export default async function SlotPage(props: Readonly<Props>) {
|
|||||||
queryKey: ['slot', documentId],
|
queryKey: ['slot', documentId],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получаем текущего пользователя
|
|
||||||
const sessionUser = await getSessionUser();
|
|
||||||
const { customer: currentUser } = await queryClient.fetchQuery({
|
|
||||||
queryFn: () => getCustomer({ telegramId: sessionUser.telegramId }),
|
|
||||||
queryKey: ['customer', sessionUser.telegramId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const masterId = currentUser?.documentId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<PageHeader title="Слот" />
|
<PageHeader title="Слот" />
|
||||||
<Container>
|
<Container>
|
||||||
<SlotDateTime {...parameters} />
|
<SlotDateTime {...parameters} />
|
||||||
<SlotOrdersList {...parameters} />
|
<SlotOrdersList {...parameters} />
|
||||||
{masterId && <BookButton label="Создать запись" masterId={masterId} />}
|
<BookButton label="Создать запись" />
|
||||||
<div className="pb-24" />
|
<div className="pb-24" />
|
||||||
<SlotButtons {...parameters} />
|
<SlotButtons {...parameters} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
5
apps/web/app/api/health/route.ts
Normal file
5
apps/web/app/api/health/route.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return new NextResponse('OK');
|
||||||
|
}
|
||||||
@ -2,21 +2,39 @@
|
|||||||
|
|
||||||
import { DataNotFound } from '../shared/alert';
|
import { DataNotFound } from '../shared/alert';
|
||||||
import { ContactRow } from '../shared/contact-row';
|
import { ContactRow } from '../shared/contact-row';
|
||||||
import { useCustomerContacts } from '@/hooks/api/contacts';
|
import { useContactsInfiniteQuery } from '@/hooks/api/customers';
|
||||||
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
|
|
||||||
export function ContactsList() {
|
export function ContactsList() {
|
||||||
const { contacts, isLoading } = useCustomerContacts();
|
const {
|
||||||
|
data: { pages } = {},
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isLoading,
|
||||||
|
} = useContactsInfiniteQuery();
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
const contacts = pages?.flatMap((page) => page.customers);
|
||||||
|
|
||||||
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
{contacts.map((contact) => (
|
{isLoading && <LoadingSpinner />}
|
||||||
<ContactRow key={contact.documentId} {...contact} />
|
{!isLoading && !contacts?.length ? <DataNotFound title="Контакты не найдены" /> : null}
|
||||||
))}
|
{contacts?.map(
|
||||||
|
(contact) =>
|
||||||
|
contact && (
|
||||||
|
<ContactRow
|
||||||
|
description={contact.services.map((service) => service?.name).join(', ')}
|
||||||
|
key={contact.documentId}
|
||||||
|
{...contact}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button onClick={() => fetchNextPage()} variant="ghost">
|
||||||
|
Загрузить еще
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,8 +13,8 @@ import { use } from 'react';
|
|||||||
|
|
||||||
const filterLabels: Record<FilterType, string> = {
|
const filterLabels: Record<FilterType, string> = {
|
||||||
all: 'Все',
|
all: 'Все',
|
||||||
clients: 'Клиенты',
|
invited: 'Приглашенные',
|
||||||
masters: 'Мастера',
|
invitedBy: 'Пригласили вас',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContactsFilter() {
|
export function ContactsFilter() {
|
||||||
@ -29,9 +29,13 @@ export function ContactsFilter() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setFilter('all')}>Все</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setFilter('all')}>{filterLabels['all']}</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setFilter('clients')}>Клиенты</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setFilter('invited')}>
|
||||||
<DropdownMenuItem onClick={() => setFilter('masters')}>Мастера</DropdownMenuItem>
|
{filterLabels['invited']}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setFilter('invitedBy')}>
|
||||||
|
{filterLabels['invitedBy']}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,12 +4,16 @@ import { NavButton } from './nav-button';
|
|||||||
import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react';
|
import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
const hideOn = ['/pro'];
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const isFirstLevel = pathname.split('/').length <= 2;
|
const isFirstLevel = pathname.split('/').length <= 2;
|
||||||
if (!isFirstLevel) return null;
|
if (!isFirstLevel) return null;
|
||||||
|
|
||||||
|
if (hideOn.includes(pathname)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background">
|
<nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background">
|
||||||
<div className="grid grid-cols-5">
|
<div className="grid grid-cols-5">
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
/* eslint-disable complexity */
|
||||||
/* eslint-disable canonical/id-match */
|
/* eslint-disable canonical/id-match */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import FloatingActionPanel from '../shared/action-panel';
|
import FloatingActionPanel from '../shared/action-panel';
|
||||||
import { type OrderComponentProps } from './types';
|
import { type OrderComponentProps } from './types';
|
||||||
import { useIsMaster } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders';
|
import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders';
|
||||||
import { usePushWithData } from '@/hooks/url';
|
import { usePushWithData } from '@/hooks/url';
|
||||||
import { Enum_Order_State } from '@repo/graphql/types';
|
import { Enum_Order_State } from '@repo/graphql/types';
|
||||||
@ -12,13 +13,16 @@ import { isBeforeNow } from '@repo/utils/datetime-format';
|
|||||||
export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
|
export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
|
||||||
const push = usePushWithData();
|
const push = usePushWithData();
|
||||||
|
|
||||||
const isMaster = useIsMaster();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
|
|
||||||
const { data: { order } = {} } = useOrderQuery({ documentId });
|
const { data: { order } = {} } = useOrderQuery({ documentId });
|
||||||
|
|
||||||
const { isPending, mutate: updateSlot } = useOrderMutation({ documentId });
|
const { isPending, mutate: updateOrder } = useOrderMutation({ documentId });
|
||||||
|
|
||||||
if (!order) return null;
|
if (!order || !customer) return null;
|
||||||
|
|
||||||
|
// Проверяем роль относительно конкретного заказа
|
||||||
|
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
|
||||||
|
const isOrderClient = order.client?.documentId === customer.documentId;
|
||||||
|
|
||||||
const isCreated = order?.state === Enum_Order_State.Created;
|
const isCreated = order?.state === Enum_Order_State.Created;
|
||||||
const isApproved = order?.state === Enum_Order_State.Approved;
|
const isApproved = order?.state === Enum_Order_State.Approved;
|
||||||
@ -27,22 +31,22 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
|
|||||||
const isCancelled = order?.state === Enum_Order_State.Cancelled;
|
const isCancelled = order?.state === Enum_Order_State.Cancelled;
|
||||||
|
|
||||||
function handleApprove() {
|
function handleApprove() {
|
||||||
if (isMaster) {
|
if (isOrderMaster) {
|
||||||
updateSlot({ data: { state: Enum_Order_State.Approved } });
|
updateOrder({ data: { state: Enum_Order_State.Approved } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
if (isMaster) {
|
if (isOrderMaster) {
|
||||||
updateSlot({ data: { state: Enum_Order_State.Cancelled } });
|
updateOrder({ data: { state: Enum_Order_State.Cancelled } });
|
||||||
} else {
|
} else if (isOrderClient) {
|
||||||
updateSlot({ data: { state: Enum_Order_State.Cancelling } });
|
updateOrder({ data: { state: Enum_Order_State.Cancelling } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOnComplete() {
|
function handleOnComplete() {
|
||||||
if (isMaster) {
|
if (isOrderMaster) {
|
||||||
updateSlot({ data: { state: Enum_Order_State.Completed } });
|
updateOrder({ data: { state: Enum_Order_State.Completed } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,11 +56,11 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
|
|||||||
|
|
||||||
const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start);
|
const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start);
|
||||||
|
|
||||||
const canCancel = !isOrderStale && (isCreated || (isMaster && isCancelling) || isApproved);
|
const canCancel = !isOrderStale && (isCreated || (isOrderMaster && isCancelling) || isApproved);
|
||||||
const canComplete = isMaster && isApproved;
|
const canComplete = isOrderMaster && isApproved;
|
||||||
const canConfirm = !isOrderStale && isMaster && isCreated;
|
const canConfirm = !isOrderStale && isOrderMaster && isCreated;
|
||||||
const canRepeat = isCancelled || isCompleted;
|
const canRepeat = isCancelled || isCompleted;
|
||||||
const canReturn = !isOrderStale && isMaster && isCancelled;
|
const canReturn = !isOrderStale && isOrderMaster && isCancelled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FloatingActionPanel
|
<FloatingActionPanel
|
||||||
|
|||||||
@ -10,17 +10,19 @@ export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<h1 className="font-bold">Контакты</h1>
|
<h1 className="font-bold">Участники</h1>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{order.slot?.master && (
|
{order.slot?.master && (
|
||||||
<ContactRow
|
<ContactRow
|
||||||
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
|
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
|
||||||
|
description="Мастер"
|
||||||
{...order.slot?.master}
|
{...order.slot?.master}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{order.client && (
|
{order.client && (
|
||||||
<ContactRow
|
<ContactRow
|
||||||
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
|
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
|
||||||
|
description="Клиент"
|
||||||
{...order.client}
|
{...order.client}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,73 +1,101 @@
|
|||||||
|
/* eslint-disable canonical/id-match */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { DataNotFound } from '@/components/shared/alert';
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar';
|
||||||
import { CardSectionHeader } from '@/components/ui';
|
import { CardSectionHeader } from '@/components/ui';
|
||||||
import { ContactsContextProvider } from '@/context/contacts';
|
import { useContactsInfiniteQuery, useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useCustomerContacts } from '@/hooks/api/contacts';
|
|
||||||
// eslint-disable-next-line import/extensions
|
|
||||||
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
|
|
||||||
import { useOrderStore } from '@/stores/order';
|
import { useOrderStore } from '@/stores/order';
|
||||||
import { withContext } from '@/utils/context';
|
import { type CustomerFieldsFragment, Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
import { type CustomerFieldsFragment } from '@repo/graphql/types';
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
import { Card } from '@repo/ui/components/ui/card';
|
import { Card } from '@repo/ui/components/ui/card';
|
||||||
import { Label } from '@repo/ui/components/ui/label';
|
import { Label } from '@repo/ui/components/ui/label';
|
||||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
import Image from 'next/image';
|
import { sift } from 'radashi';
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
type ContactsGridProps = {
|
type ContactsGridProps = {
|
||||||
readonly contacts: CustomerFieldsFragment[];
|
readonly contacts: CustomerFieldsFragment[];
|
||||||
|
readonly hasNextPage?: boolean;
|
||||||
|
readonly isLoading?: boolean;
|
||||||
|
readonly onClick: () => void;
|
||||||
|
readonly onFetchNextPage?: () => void;
|
||||||
readonly onSelect: (contactId: null | string) => void;
|
readonly onSelect: (contactId: null | string) => void;
|
||||||
readonly selected?: null | string;
|
readonly selected?: null | string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContactsGridBase({ contacts, onSelect, selected, title }: ContactsGridProps) {
|
export function ClientsGrid() {
|
||||||
|
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
|
||||||
|
|
||||||
|
const clientId = useOrderStore((store) => store.clientId);
|
||||||
|
const setClientId = useOrderStore((store) => store.setClientId);
|
||||||
|
const masterId = useOrderStore((store) => store.masterId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContactsGridBase
|
||||||
|
contacts={contacts.filter((contact) => contact.documentId !== masterId)}
|
||||||
|
hasNextPage={Boolean(hasNextPage)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (clientId) setClientId(null);
|
||||||
|
}}
|
||||||
|
onFetchNextPage={fetchNextPage}
|
||||||
|
onSelect={(contactId) => setClientId(contactId)}
|
||||||
|
selected={clientId}
|
||||||
|
title="Выбор клиента"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsGridBase({
|
||||||
|
contacts,
|
||||||
|
hasNextPage,
|
||||||
|
isLoading,
|
||||||
|
onClick,
|
||||||
|
onFetchNextPage,
|
||||||
|
onSelect,
|
||||||
|
selected,
|
||||||
|
title,
|
||||||
|
}: ContactsGridProps) {
|
||||||
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<CardSectionHeader title={title} />
|
<CardSectionHeader title={title} />
|
||||||
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!isLoading && (!contacts || contacts.length === 0) ? (
|
||||||
|
<DataNotFound title="Контакты не найдены" />
|
||||||
|
) : null}
|
||||||
<div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto">
|
<div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto">
|
||||||
{contacts.map((contact) => {
|
{!isLoading &&
|
||||||
|
contacts?.map((contact) => {
|
||||||
if (!contact) return null;
|
if (!contact) return null;
|
||||||
|
|
||||||
const isCurrentUser = contact?.name === 'Я';
|
const isCurrentUser = contact.documentId === customer?.documentId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
className="flex cursor-pointer flex-col items-center"
|
className="flex cursor-pointer flex-col items-center"
|
||||||
key={contact?.documentId}
|
key={contact.documentId}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
checked={selected === contact?.documentId}
|
checked={selected === contact.documentId}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
name="user"
|
name="user"
|
||||||
onChange={() => onSelect(contact?.documentId)}
|
onChange={() => onSelect(contact.documentId)}
|
||||||
|
onClick={onClick}
|
||||||
type="radio"
|
type="radio"
|
||||||
value={contact?.documentId}
|
value={contact.documentId}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-20 h-20 rounded-full border-2 transition-all duration-75',
|
'rounded-full border-2 transition-all duration-75',
|
||||||
selected === contact?.documentId ? 'border-primary' : 'border-transparent',
|
selected === contact.documentId ? 'border-primary' : 'border-transparent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<UserAvatar {...contact} size="md" />
|
||||||
className={cn(
|
|
||||||
'size-full rounded-full p-1',
|
|
||||||
isCurrentUser
|
|
||||||
? 'bg-gradient-to-r from-purple-500 to-pink-500'
|
|
||||||
: 'bg-transparent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
alt={contact?.name}
|
|
||||||
className="size-full rounded-full object-cover"
|
|
||||||
height={80}
|
|
||||||
src={contact?.photoUrl || AvatarPlaceholder}
|
|
||||||
width={80}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -75,55 +103,65 @@ export function ContactsGridBase({ contacts, onSelect, selected, title }: Contac
|
|||||||
isCurrentUser && 'font-bold',
|
isCurrentUser && 'font-bold',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{contact?.name}
|
{contact.name}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{hasNextPage && onFetchNextPage && (
|
||||||
|
<Button onClick={onFetchNextPage} variant="ghost">
|
||||||
|
Загрузить еще
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MastersGrid = withContext(ContactsContextProvider)(function () {
|
export function MastersGrid() {
|
||||||
const { contacts, isLoading, setFilter } = useCustomerContacts();
|
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
|
||||||
|
|
||||||
const masterId = useOrderStore((store) => store.masterId);
|
const masterId = useOrderStore((store) => store.masterId);
|
||||||
const setMasterId = useOrderStore((store) => store.setMasterId);
|
const setMasterId = useOrderStore((store) => store.setMasterId);
|
||||||
|
const clientId = useOrderStore((store) => store.clientId);
|
||||||
useEffect(() => {
|
|
||||||
setFilter('masters');
|
|
||||||
}, [setFilter]);
|
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContactsGridBase
|
<ContactsGridBase
|
||||||
contacts={contacts}
|
contacts={contacts.filter(
|
||||||
|
(contact) => contact.documentId !== clientId && contact.role !== Enum_Customer_Role.Client,
|
||||||
|
)}
|
||||||
|
hasNextPage={Boolean(hasNextPage)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (masterId) setMasterId(null);
|
||||||
|
}}
|
||||||
|
onFetchNextPage={fetchNextPage}
|
||||||
onSelect={(contactId) => setMasterId(contactId)}
|
onSelect={(contactId) => setMasterId(contactId)}
|
||||||
selected={masterId}
|
selected={masterId}
|
||||||
title="Мастера"
|
title="Выбор мастера"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
export const ClientsGrid = withContext(ContactsContextProvider)(function () {
|
function useContacts() {
|
||||||
const { contacts, isLoading, setFilter } = useCustomerContacts();
|
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||||
const clientId = useOrderStore((store) => store.clientId);
|
|
||||||
const setClientId = useOrderStore((store) => store.setClientId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
setFilter('clients');
|
data: { pages } = { pages: [] },
|
||||||
}, [setFilter]);
|
isLoading: isLoadingContacts,
|
||||||
|
...query
|
||||||
|
} = useContactsInfiniteQuery();
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
const isLoading = isLoadingContacts || isLoadingCustomer;
|
||||||
|
|
||||||
return (
|
const contacts = sift(
|
||||||
<ContactsGridBase
|
pages.flatMap((page) => page.customers).filter((contact) => Boolean(contact && contact.active)),
|
||||||
contacts={contacts}
|
|
||||||
onSelect={(contactId) => setClientId(contactId)}
|
|
||||||
selected={clientId}
|
|
||||||
title="Клиенты"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
...query,
|
||||||
|
contacts: [{ ...customer, name: 'Я' } as CustomerFieldsFragment, ...contacts],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import { ServiceCard } from '@/components/shared/service-card';
|
|||||||
import { useServicesQuery } from '@/hooks/api/services';
|
import { useServicesQuery } from '@/hooks/api/services';
|
||||||
import { useOrderStore } from '@/stores/order';
|
import { useOrderStore } from '@/stores/order';
|
||||||
import { type ServiceFieldsFragment } from '@repo/graphql/types';
|
import { type ServiceFieldsFragment } from '@repo/graphql/types';
|
||||||
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
|
|
||||||
export function ServicesSelect() {
|
export function ServicesSelect() {
|
||||||
const masterId = useOrderStore((store) => store.masterId);
|
const masterId = useOrderStore((store) => store.masterId);
|
||||||
|
|
||||||
const { data: { services } = {} } = useServicesQuery({
|
const { data: { services } = {}, isLoading } = useServicesQuery({
|
||||||
filters: {
|
filters: {
|
||||||
active: {
|
active: {
|
||||||
eq: true,
|
eq: true,
|
||||||
@ -24,6 +25,8 @@ export function ServicesSelect() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSpinner />;
|
||||||
|
|
||||||
if (!services?.length) return <DataNotFound title="Услуги не найдены" />;
|
if (!services?.length) return <DataNotFound title="Услуги не найдены" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useOrdersQuery } from '@/hooks/api/orders';
|
import { useOrdersQuery } from '@/hooks/api/orders';
|
||||||
import { useDateTimeStore } from '@/stores/datetime';
|
import { useDateTimeStore } from '@/stores/datetime';
|
||||||
import { Calendar } from '@repo/ui/components/ui/calendar';
|
import { Calendar } from '@repo/ui/components/ui/calendar';
|
||||||
@ -11,35 +11,25 @@ import { useMemo, useState } from 'react';
|
|||||||
export function DateSelect() {
|
export function DateSelect() {
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
|
|
||||||
const isMaster = useIsMaster();
|
|
||||||
|
|
||||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||||
|
|
||||||
const clientId = isMaster ? undefined : customer?.documentId;
|
|
||||||
const masterId = isMaster ? customer?.documentId : undefined;
|
|
||||||
|
|
||||||
const { data: { orders } = { orders: [] } } = useOrdersQuery(
|
const { data: { orders } = { orders: [] } } = useOrdersQuery(
|
||||||
{
|
{
|
||||||
filters: {
|
filters: {
|
||||||
client: {
|
// Показываем все записи где пользователь является клиентом или мастером
|
||||||
documentId: {
|
or: [
|
||||||
eq: clientId,
|
{ client: { documentId: { eq: customer?.documentId } } },
|
||||||
},
|
{ slot: { master: { documentId: { eq: customer?.documentId } } } },
|
||||||
},
|
],
|
||||||
slot: {
|
slot: {
|
||||||
datetime_start: {
|
datetime_start: {
|
||||||
gte: dayjs(currentMonthDate).startOf('month').toISOString(),
|
gte: dayjs(currentMonthDate).startOf('month').toISOString(),
|
||||||
lte: dayjs(currentMonthDate).endOf('month').toISOString(),
|
lte: dayjs(currentMonthDate).endOf('month').toISOString(),
|
||||||
},
|
},
|
||||||
master: {
|
|
||||||
documentId: {
|
|
||||||
eq: masterId,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
Boolean(customer?.documentId),
|
||||||
},
|
|
||||||
Boolean(clientId) || Boolean(masterId),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const daysWithOrders = useMemo(() => {
|
const daysWithOrders = useMemo(() => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { OrdersList as OrdersListComponent } from './orders-list';
|
import { OrdersList as OrdersListComponent } from './orders-list';
|
||||||
import { useCurrentAndNext } from './utils';
|
import { useCurrentAndNext } from './utils';
|
||||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
|
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
|
||||||
import { useDateTimeStore } from '@/stores/datetime';
|
import { useDateTimeStore } from '@/stores/datetime';
|
||||||
import type * as GQL from '@repo/graphql/types';
|
import type * as GQL from '@repo/graphql/types';
|
||||||
@ -10,7 +10,6 @@ import { getDateUTCRange } from '@repo/utils/datetime-format';
|
|||||||
|
|
||||||
export function OrdersList() {
|
export function OrdersList() {
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
const isMaster = useIsMaster();
|
|
||||||
const selectedDate = useDateTimeStore((store) => store.date);
|
const selectedDate = useDateTimeStore((store) => store.date);
|
||||||
const { endOfDay, startOfDay } = getDateUTCRange(selectedDate).day();
|
const { endOfDay, startOfDay } = getDateUTCRange(selectedDate).day();
|
||||||
|
|
||||||
@ -22,10 +21,13 @@ export function OrdersList() {
|
|||||||
} = useOrdersInfiniteQuery(
|
} = useOrdersInfiniteQuery(
|
||||||
{
|
{
|
||||||
filters: {
|
filters: {
|
||||||
client: isMaster ? undefined : { documentId: { eq: customer?.documentId } },
|
// Показываем все записи где пользователь является клиентом или мастером
|
||||||
|
or: [
|
||||||
|
{ client: { documentId: { eq: customer?.documentId } } },
|
||||||
|
{ slot: { master: { documentId: { eq: customer?.documentId } } } },
|
||||||
|
],
|
||||||
slot: {
|
slot: {
|
||||||
datetime_start: selectedDate ? { gte: startOfDay, lt: endOfDay } : undefined,
|
datetime_start: selectedDate ? { gte: startOfDay, lt: endOfDay } : undefined,
|
||||||
master: isMaster ? { documentId: { eq: customer?.documentId } } : undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -37,17 +39,15 @@ export function OrdersList() {
|
|||||||
|
|
||||||
const { current, next } = useCurrentAndNext(orders);
|
const { current, next } = useCurrentAndNext(orders);
|
||||||
|
|
||||||
if (!orders?.length || isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OrdersListComponent
|
<OrdersListComponent
|
||||||
avatarSource={isMaster ? 'client' : 'master'}
|
|
||||||
current={current}
|
current={current}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
|
isLoading={isLoading}
|
||||||
next={next}
|
next={next}
|
||||||
onLoadMore={() => fetchNextPage()}
|
onLoadMore={fetchNextPage}
|
||||||
orders={orders}
|
orders={orders}
|
||||||
title={isMaster ? 'Записи клиентов' : 'Ваши записи'}
|
title={selectedDate ? `Записи на ${selectedDate.toLocaleDateString()}` : 'Все записи'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { getOrderStatus, getStatusText, type OrderStatus } from './utils';
|
import { getOrderStatus, getStatusText, type OrderStatus } from './utils';
|
||||||
|
import { DataNotFound } from '@/components/shared/alert';
|
||||||
import { OrderCard } from '@/components/shared/order-card';
|
import { OrderCard } from '@/components/shared/order-card';
|
||||||
import type * as GQL from '@repo/graphql/types';
|
import type * as GQL from '@repo/graphql/types';
|
||||||
import { Button } from '@repo/ui/components/ui/button';
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
|
|
||||||
type Order = GQL.OrderFieldsFragment;
|
type Order = GQL.OrderFieldsFragment;
|
||||||
|
|
||||||
type OrdersListProps = Pick<Parameters<typeof OrderCard>[0], 'avatarSource'> & {
|
type OrdersListProps = {
|
||||||
readonly current: null | Order;
|
readonly current: null | Order;
|
||||||
readonly hasNextPage?: boolean;
|
readonly hasNextPage?: boolean;
|
||||||
|
readonly isLoading?: boolean;
|
||||||
readonly next: null | Order;
|
readonly next: null | Order;
|
||||||
readonly onLoadMore?: () => void;
|
readonly onLoadMore?: () => void;
|
||||||
readonly orders: Order[];
|
readonly orders: Order[];
|
||||||
@ -16,9 +19,9 @@ type OrdersListProps = Pick<Parameters<typeof OrderCard>[0], 'avatarSource'> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function OrdersList({
|
export function OrdersList({
|
||||||
avatarSource,
|
|
||||||
current,
|
current,
|
||||||
hasNextPage = false,
|
hasNextPage = false,
|
||||||
|
isLoading,
|
||||||
next,
|
next,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
orders,
|
orders,
|
||||||
@ -27,6 +30,8 @@ export function OrdersList({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<h1 className="font-bold">{title}</h1>
|
<h1 className="font-bold">{title}</h1>
|
||||||
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!isLoading && !orders.length ? <DataNotFound title="Заказы не найдены" /> : null}
|
||||||
{orders?.map((order) => {
|
{orders?.map((order) => {
|
||||||
if (!order) return null;
|
if (!order) return null;
|
||||||
|
|
||||||
@ -34,7 +39,7 @@ export function OrdersList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DateStatusWrapper key={order.documentId} status={status}>
|
<DateStatusWrapper key={order.documentId} status={status}>
|
||||||
<OrderCard avatarSource={avatarSource} showDate {...order} />
|
<OrderCard showDate {...order} />
|
||||||
</DateStatusWrapper>
|
</DateStatusWrapper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
|
/* eslint-disable canonical/id-match */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type ProfileProps } from '../types';
|
import { type ProfileProps } from '../types';
|
||||||
import { TextField } from '@/components/shared/data-fields';
|
import { CheckboxWithText, TextField } from '@/components/shared/data-fields';
|
||||||
import { CardSectionHeader } from '@/components/ui';
|
import { CardSectionHeader } from '@/components/ui';
|
||||||
import { useCustomerMutation, useCustomerQuery } from '@/hooks/api/customers';
|
import { useCustomerMutation, useCustomerQuery } from '@/hooks/api/customers';
|
||||||
|
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
import { Button } from '@repo/ui/components/ui/button';
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
import { Card } from '@repo/ui/components/ui/card';
|
import { Card } from '@repo/ui/components/ui/card';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@ -49,14 +51,14 @@ export function ProfileDataCard() {
|
|||||||
value={customer?.name ?? ''}
|
value={customer?.name ?? ''}
|
||||||
/>
|
/>
|
||||||
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
|
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
|
||||||
{/* <CheckboxWithText
|
<CheckboxWithText
|
||||||
checked={customer.role !== 'client'}
|
checked={customer.role !== 'client'}
|
||||||
description="Разрешить другим пользователям записываться к вам"
|
description="Разрешить другим пользователям записываться к вам"
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
updateField('role', checked ? Role.Master : Role.Client)
|
updateField('role', checked ? Enum_Customer_Role.Master : Enum_Customer_Role.Client)
|
||||||
}
|
}
|
||||||
text="Быть мастером"
|
text="Быть мастером"
|
||||||
/> */}
|
/>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button disabled={isPending} onClick={cancelChanges} variant="outline">
|
<Button disabled={isPending} onClick={cancelChanges} variant="outline">
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from './data-card';
|
|||||||
export * from './links-card';
|
export * from './links-card';
|
||||||
export * from './orders-list';
|
export * from './orders-list';
|
||||||
export * from './person-card';
|
export * from './person-card';
|
||||||
|
export * from './subscription-bar';
|
||||||
|
|||||||
@ -1,24 +1,26 @@
|
|||||||
|
/* eslint-disable canonical/id-match */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { LinkButton } from './link-button';
|
import { LinkButton } from './link-button';
|
||||||
import { useIsMaster } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
|
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
|
|
||||||
export function LinksCard() {
|
export function LinksCard() {
|
||||||
const isMaster = useIsMaster();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
|
|
||||||
|
if (customer?.role === Enum_Customer_Role.Client) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-4 py-0">
|
<div className="flex flex-col gap-4 p-4 py-0">
|
||||||
<LinkButton
|
<LinkButton
|
||||||
description="Указать доступные дни и время для записи клиентов"
|
description="Указать доступные дни и время для записи"
|
||||||
href="/profile/schedule"
|
href="/profile/schedule"
|
||||||
text="График работы"
|
text="График работы"
|
||||||
visible={isMaster}
|
|
||||||
/>
|
/>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
description="Добавить и редактировать ваши услуги мастера"
|
description="Добавить и редактировать ваши услуги"
|
||||||
href="/profile/services"
|
href="/profile/services"
|
||||||
text="Услуги"
|
text="Услуги"
|
||||||
visible={isMaster}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,12 +4,9 @@ type Props = {
|
|||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly href: string;
|
readonly href: string;
|
||||||
readonly text: string;
|
readonly text: string;
|
||||||
readonly visible?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LinkButton({ description, href, text, visible }: Props) {
|
export function LinkButton({ description, href, text }: Props) {
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={href} rel="noopener noreferrer">
|
<Link href={href} rel="noopener noreferrer">
|
||||||
<div className="flex min-h-24 w-full flex-col rounded-2xl bg-background p-4 px-6 shadow-lg backdrop-blur-2xl dark:bg-primary/5">
|
<div className="flex min-h-24 w-full flex-col rounded-2xl bg-background p-4 px-6 shadow-lg backdrop-blur-2xl dark:bg-primary/5">
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { DataNotFound } from '../shared/alert';
|
||||||
import { OrderCard } from '../shared/order-card';
|
import { OrderCard } from '../shared/order-card';
|
||||||
import { type ProfileProps } from './types';
|
import { type ProfileProps } from './types';
|
||||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
|
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
|
||||||
import { Button } from '@repo/ui/components/ui/button';
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
|
|
||||||
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
const isMaster = useIsMaster();
|
|
||||||
|
|
||||||
const { data: { customer: profile } = {} } = useCustomerQuery({ telegramId });
|
const { data: { customer: profile } = {} } = useCustomerQuery({ telegramId });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -20,18 +20,17 @@ export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
|||||||
} = useOrdersInfiniteQuery(
|
} = useOrdersInfiniteQuery(
|
||||||
{
|
{
|
||||||
filters: {
|
filters: {
|
||||||
client: {
|
// Показываем все записи между текущим пользователем и профилем
|
||||||
documentId: {
|
or: [
|
||||||
eq: isMaster ? profile?.documentId : customer?.documentId,
|
{
|
||||||
},
|
client: { documentId: { eq: customer?.documentId } },
|
||||||
},
|
slot: { master: { documentId: { eq: profile?.documentId } } },
|
||||||
slot: {
|
|
||||||
master: {
|
|
||||||
documentId: {
|
|
||||||
eq: isMaster ? customer?.documentId : profile?.documentId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
client: { documentId: { eq: profile?.documentId } },
|
||||||
|
slot: { master: { documentId: { eq: customer?.documentId } } },
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ enabled: Boolean(profile?.documentId) && Boolean(customer?.documentId) },
|
{ enabled: Boolean(profile?.documentId) && Boolean(customer?.documentId) },
|
||||||
@ -39,22 +38,12 @@ export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
|||||||
|
|
||||||
const orders = pages?.flatMap((page) => page.orders) ?? [];
|
const orders = pages?.flatMap((page) => page.orders) ?? [];
|
||||||
|
|
||||||
if (!orders?.length || isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-2 px-4">
|
<div className="flex flex-col space-y-2 px-4">
|
||||||
<h1 className="font-bold">Недавние записи</h1>
|
<h1 className="font-bold">Недавние записи</h1>
|
||||||
{orders?.map(
|
{isLoading && <LoadingSpinner />}
|
||||||
(order) =>
|
{!isLoading && !orders.length ? <DataNotFound title="Записи не найдены" /> : null}
|
||||||
order && (
|
{orders?.map((order) => order && <OrderCard key={order.documentId} showDate {...order} />)}
|
||||||
<OrderCard
|
|
||||||
avatarSource={isMaster ? 'master' : 'client'}
|
|
||||||
key={order.documentId}
|
|
||||||
showDate
|
|
||||||
{...order}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
{hasNextPage && (
|
{hasNextPage && (
|
||||||
<Button onClick={() => fetchNextPage()} variant="ghost">
|
<Button onClick={() => fetchNextPage()} variant="ghost">
|
||||||
Загрузить еще
|
Загрузить еще
|
||||||
|
|||||||
@ -1,28 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type ProfileProps } from './types';
|
import { type ProfileProps } from './types';
|
||||||
|
import { UserAvatar } from '@/components/shared/user-avatar';
|
||||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
|
|
||||||
import { Card } from '@repo/ui/components/ui/card';
|
import { Card } from '@repo/ui/components/ui/card';
|
||||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
|
|
||||||
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
|
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
|
||||||
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
||||||
|
|
||||||
if (isLoading || !customer)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!customer) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-transparent p-4 shadow-none">
|
<Card className="bg-transparent p-4 shadow-none">
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<div className="flex flex-col items-center space-y-2">
|
||||||
<Avatar className="size-20">
|
<UserAvatar {...customer} size="lg" />
|
||||||
<AvatarImage alt={customer?.name} src={customer.photoUrl || ''} />
|
|
||||||
<AvatarFallback>{customer?.name.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<h2 className="text-2xl font-bold">{customer?.name}</h2>
|
<h2 className="text-2xl font-bold">{customer?.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,27 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { DataNotFound } from '@/components/shared/alert';
|
||||||
import { ServiceCard } from '@/components/shared/service-card';
|
import { ServiceCard } from '@/components/shared/service-card';
|
||||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { useServicesQuery } from '@/hooks/api/services';
|
import { useServicesQuery } from '@/hooks/api/services';
|
||||||
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export function ServicesList() {
|
type MasterServicesListProps = {
|
||||||
const { data: { customer } = {}, isLoading } = useCustomerQuery();
|
masterId: string;
|
||||||
|
};
|
||||||
|
|
||||||
const { data: { services } = {} } = useServicesQuery({
|
// Компонент для отображения услуг мастера (без ссылок, только просмотр)
|
||||||
filters: {
|
export function ReadonlyServicesList({ masterId }: Readonly<MasterServicesListProps>) {
|
||||||
master: {
|
const { isLoading, services } = useServices(masterId);
|
||||||
documentId: {
|
|
||||||
eq: customer?.documentId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading || !customer) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 px-6">
|
<div className="space-y-2 px-4">
|
||||||
|
<h1 className="font-bold">Услуги</h1>
|
||||||
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!isLoading && !services?.length ? <DataNotFound title="Услуги не найдены" /> : null}
|
||||||
|
{services?.map(
|
||||||
|
(service) =>
|
||||||
|
service?.active && (
|
||||||
|
<div key={service.documentId}>
|
||||||
|
<ServiceCard {...service} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент для отображения услуг текущего пользователя (с ссылками)
|
||||||
|
export function ServicesList() {
|
||||||
|
const { isLoading, services } = useServices();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 px-4">
|
||||||
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!isLoading && !services?.length ? <DataNotFound title="Услуги не найдены" /> : null}
|
||||||
{services?.map(
|
{services?.map(
|
||||||
(service) =>
|
(service) =>
|
||||||
service && (
|
service && (
|
||||||
@ -35,3 +53,25 @@ export function ServicesList() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useServices(masterId?: string) {
|
||||||
|
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||||
|
|
||||||
|
// Используем переданный masterId или текущего пользователя
|
||||||
|
const targetMasterId = masterId || customer?.documentId;
|
||||||
|
|
||||||
|
const { data: { services } = {}, isLoading: isLoadingServices } = useServicesQuery({
|
||||||
|
filters: {
|
||||||
|
master: {
|
||||||
|
documentId: {
|
||||||
|
eq: targetMasterId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: isLoadingCustomer || isLoadingServices,
|
||||||
|
services,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
78
apps/web/components/profile/subscription-bar.tsx
Normal file
78
apps/web/components/profile/subscription-bar.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable canonical/id-match */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
|
import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
|
||||||
|
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||||
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function SubscriptionInfoBar() {
|
||||||
|
const { data, error, isLoading } = useSubscriptionQuery();
|
||||||
|
|
||||||
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
|
|
||||||
|
const isActive = data?.hasActiveSubscription;
|
||||||
|
const remainingOrdersCount = data?.remainingOrdersCount;
|
||||||
|
const remainingDays = data?.remainingDays;
|
||||||
|
const maxOrdersPerMonth = data?.maxOrdersPerMonth;
|
||||||
|
|
||||||
|
if (customer?.role === Enum_Customer_Role.Client) return null;
|
||||||
|
|
||||||
|
if (error) return null;
|
||||||
|
|
||||||
|
const title = isActive ? 'Pro доступ активен' : 'Pro доступ неактивен';
|
||||||
|
|
||||||
|
let description = 'Попробуйте бесплатно';
|
||||||
|
|
||||||
|
if (!isLoading && remainingOrdersCount && maxOrdersPerMonth) {
|
||||||
|
description = `Доступно ${remainingOrdersCount} из ${maxOrdersPerMonth} записей в этом месяце`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
description = `Осталось ${remainingDays} дней`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href="/pro" rel="noopener noreferrer">
|
||||||
|
<div className={cn('px-4', isLoading && 'animate-pulse')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-col justify-center rounded-2xl p-4 px-6 shadow-lg backdrop-blur-2xl',
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-purple-600 to-blue-600 dark:from-purple-700 dark:to-blue-700 text-white'
|
||||||
|
: 'bg-background dark:bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
'font-bold tracking-wider',
|
||||||
|
isActive ? 'text-white' : 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className={cn('mt-1 text-xs', isActive ? 'text-white/80' : 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'rounded-full p-1.5',
|
||||||
|
isActive ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ export function ScheduleCalendar() {
|
|||||||
|
|
||||||
const { endOfMonth, startOfMonth } = getDateUTCRange(currentMonthDate).month();
|
const { endOfMonth, startOfMonth } = getDateUTCRange(currentMonthDate).month();
|
||||||
|
|
||||||
const { data: { slots } = {} } = useSlotsQuery({
|
const { data: { slots } = {}, isLoading } = useSlotsQuery({
|
||||||
filters: {
|
filters: {
|
||||||
datetime_start: {
|
datetime_start: {
|
||||||
gte: startOfMonth,
|
gte: startOfMonth,
|
||||||
@ -41,6 +41,7 @@ export function ScheduleCalendar() {
|
|||||||
return (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
|
disabled={isLoading}
|
||||||
// disabled={(date) => {
|
// disabled={(date) => {
|
||||||
// return dayjs().isAfter(dayjs(date), 'day');
|
// return dayjs().isAfter(dayjs(date), 'day');
|
||||||
// }}
|
// }}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useMasterSlotsQuery } from '@/hooks/api/slots';
|
|||||||
import { useDateTimeStore } from '@/stores/datetime';
|
import { useDateTimeStore } from '@/stores/datetime';
|
||||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||||
import { getDateUTCRange, isNowOrAfter } from '@repo/utils/datetime-format';
|
import { getDateUTCRange, isNowOrAfter } from '@repo/utils/datetime-format';
|
||||||
import { type PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
export function DaySlotsList() {
|
export function DaySlotsList() {
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
@ -22,30 +21,15 @@ export function DaySlotsList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelectedDateTodayOrAfter = selectedDate && isNowOrAfter(selectedDate);
|
const isSelectedDateTodayOrAfter = selectedDate && isNowOrAfter(selectedDate);
|
||||||
|
|
||||||
if (!slots?.length) {
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<div className="flex flex-col space-y-2 px-4">
|
||||||
<DataNotFound title="Слоты не найдены" />
|
|
||||||
{isSelectedDateTodayOrAfter && <DaySlotAddForm />}
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<h1 className="font-bold">Слоты</h1>
|
<h1 className="font-bold">Слоты</h1>
|
||||||
{slots.map((slot) => slot && <SlotCard key={slot.documentId} {...slot} />)}
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!isLoading && !slots?.length ? <DataNotFound title="Слоты не найдены" /> : null}
|
||||||
|
{slots?.map((slot) => slot && <SlotCard key={slot.documentId} {...slot} />)}
|
||||||
{isSelectedDateTodayOrAfter && <DaySlotAddForm />}
|
{isSelectedDateTodayOrAfter && <DaySlotAddForm />}
|
||||||
</Wrapper>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Wrapper({ children }: Readonly<PropsWithChildren>) {
|
|
||||||
return <div className="flex flex-col space-y-2 px-4">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { DataNotFound } from '../shared/alert';
|
||||||
import { type SlotComponentProps } from './types';
|
import { type SlotComponentProps } from './types';
|
||||||
import { OrderCard } from '@/components/shared/order-card';
|
import { OrderCard } from '@/components/shared/order-card';
|
||||||
import { useOrdersQuery } from '@/hooks/api/orders';
|
import { useOrdersQuery } from '@/hooks/api/orders';
|
||||||
@ -16,13 +17,11 @@ export function SlotOrdersList({ documentId }: Readonly<SlotComponentProps>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
|
||||||
|
|
||||||
if (!orders?.length) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<h1 className="font-bold">Записи</h1>
|
<h1 className="font-bold">Записи</h1>
|
||||||
|
{isLoading && <LoadingSpinner />}
|
||||||
|
{!isLoading && !orders?.length ? <DataNotFound title="Записи не найдены" /> : null}
|
||||||
{orders?.map((order) => order && <OrderCard key={order.documentId} {...order} />)}
|
{orders?.map((order) => order && <OrderCard key={order.documentId} {...order} />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,34 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { usePushWithData } from '@/hooks/url';
|
import { usePushWithData } from '@/hooks/url';
|
||||||
import { CalendarPlus } from 'lucide-react';
|
import { CalendarPlus } from 'lucide-react';
|
||||||
|
|
||||||
type BookButtonProps = {
|
type BookButtonProps = {
|
||||||
clientId?: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
masterId: string;
|
|
||||||
onBooked?: () => void;
|
onBooked?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookButton({
|
export function BookButton({ disabled, label, onBooked }: Readonly<BookButtonProps>) {
|
||||||
clientId,
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
disabled,
|
const masterId = customer?.documentId;
|
||||||
label,
|
|
||||||
masterId,
|
|
||||||
onBooked,
|
|
||||||
}: Readonly<BookButtonProps>) {
|
|
||||||
const push = usePushWithData();
|
const push = usePushWithData();
|
||||||
|
|
||||||
const handleBook = () => {
|
const handleBook = () => {
|
||||||
push('/orders/add', {
|
push('/orders/add', {
|
||||||
...(clientId && { client: { documentId: clientId } }),
|
|
||||||
slot: { master: { documentId: masterId } },
|
slot: { master: { documentId: masterId } },
|
||||||
});
|
});
|
||||||
onBooked?.();
|
onBooked?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!masterId && !clientId) return null;
|
if (!masterId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
|
import { UserAvatar } from './user-avatar';
|
||||||
import type * as GQL from '@repo/graphql/types';
|
import type * as GQL from '@repo/graphql/types';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
|
|
||||||
import { Badge } from '@repo/ui/components/ui/badge';
|
import { Badge } from '@repo/ui/components/ui/badge';
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
import { isCustomerMaster } from '@repo/utils/customer';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
type ContactRowProps = GQL.CustomerFieldsFragment & {
|
type ContactRowProps = GQL.CustomerFieldsFragment & {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly showServices?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ContactRow = memo(function ({ className, ...contact }: ContactRowProps) {
|
export const ContactRow = memo(function ({ className, description, ...contact }: ContactRowProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className="block"
|
className="block"
|
||||||
@ -24,16 +25,13 @@ export const ContactRow = memo(function ({ className, ...contact }: ContactRowPr
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn('flex items-center space-x-4 rounded-lg py-2 transition-colors')}>
|
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
|
||||||
<Avatar>
|
<UserAvatar {...contact} size="sm" />
|
||||||
<AvatarImage alt={contact.name} src={contact.photoUrl || ''} />
|
|
||||||
<AvatarFallback>{contact.name.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{contact.name}</p>
|
<p className="font-medium">{contact.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
{description && (
|
||||||
{isCustomerMaster(contact) ? 'Мастер' : 'Клиент'}
|
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
|
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
|
||||||
|
|||||||
@ -1,39 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReadonlyTimeRange } from './time-range/readonly';
|
import { ReadonlyTimeRange } from './time-range/readonly';
|
||||||
|
import { UserAvatar } from './user-avatar';
|
||||||
import { getBadge } from '@/components/shared/status';
|
import { getBadge } from '@/components/shared/status';
|
||||||
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import type * as GQL from '@repo/graphql/types';
|
import type * as GQL from '@repo/graphql/types';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
|
|
||||||
import { formatDate } from '@repo/utils/datetime-format';
|
import { formatDate } from '@repo/utils/datetime-format';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
type OrderComponentProps = GQL.OrderFieldsFragment & {
|
type OrderComponentProps = GQL.OrderFieldsFragment & {
|
||||||
avatarSource?: 'client' | 'master';
|
|
||||||
showDate?: boolean;
|
showDate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrderCustomer = GQL.CustomerFieldsFragment;
|
export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComponentProps>) {
|
||||||
|
|
||||||
export function OrderCard({
|
|
||||||
avatarSource,
|
|
||||||
documentId,
|
|
||||||
showDate,
|
|
||||||
...order
|
|
||||||
}: Readonly<OrderComponentProps>) {
|
|
||||||
const services = order?.services.map((service) => service?.name).join(', ');
|
const services = order?.services.map((service) => service?.name).join(', ');
|
||||||
const customer = avatarSource === 'master' ? order?.slot?.master : order?.client;
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
|
|
||||||
|
const client = order?.client;
|
||||||
|
const master = order?.slot?.master;
|
||||||
|
|
||||||
|
const avatarSource = client?.documentId === customer?.documentId ? master : client;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/orders/${documentId}`} rel="noopener noreferrer">
|
<Link href={`/orders/${documentId}`} rel="noopener noreferrer">
|
||||||
<div className="relative flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
|
<div className="relative flex items-center justify-between rounded-2xl bg-background p-2 px-6 dark:bg-primary/5">
|
||||||
{order.order_number && (
|
{order.order_number && (
|
||||||
<span className="absolute left-1.5 top-1.5 z-10 flex size-5 items-center justify-center rounded-full border bg-background text-xs font-semibold text-muted-foreground shadow">
|
<span className="absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-full border bg-background text-xs font-semibold text-muted-foreground shadow">
|
||||||
{order.order_number}
|
{order.order_number}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{customer && <CustomerAvatar customer={customer} />}
|
{avatarSource && <UserAvatar {...avatarSource} size="xs" />}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<ReadonlyTimeRange
|
<ReadonlyTimeRange
|
||||||
datetimeEnd={order?.datetime_end}
|
datetimeEnd={order?.datetime_end}
|
||||||
@ -53,14 +51,3 @@ export function OrderCard({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomerAvatar({ customer }: { readonly customer: OrderCustomer }) {
|
|
||||||
if (!customer) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar>
|
|
||||||
<AvatarImage alt={customer.name} src={customer.photoUrl || ''} />
|
|
||||||
<AvatarFallback>{customer.name.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
49
apps/web/components/shared/user-avatar.tsx
Normal file
49
apps/web/components/shared/user-avatar.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
|
import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
|
||||||
|
// eslint-disable-next-line import/extensions
|
||||||
|
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
|
||||||
|
import { type CustomerFieldsFragment } from '@repo/graphql/types';
|
||||||
|
import { cn } from '@repo/ui/lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
type Sizes = 'lg' | 'md' | 'sm' | 'xs';
|
||||||
|
|
||||||
|
type UserAvatarProps = Pick<CustomerFieldsFragment, 'telegramId'> & {
|
||||||
|
readonly className?: string;
|
||||||
|
readonly size?: Sizes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<Sizes, string> = {
|
||||||
|
lg: 'size-32',
|
||||||
|
md: 'size-20',
|
||||||
|
sm: 'size-16',
|
||||||
|
xs: 'size-14',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserAvatar({ className, size = 'sm', telegramId = null }: UserAvatarProps) {
|
||||||
|
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
||||||
|
const { data: subscriptionData } = useSubscriptionQuery({ telegramId });
|
||||||
|
const hasActiveSubscription = subscriptionData?.hasActiveSubscription;
|
||||||
|
const sizeClass = sizeClasses[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(sizeClass, 'rounded-full', className, isLoading && 'animate-pulse')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'size-full rounded-full p-1',
|
||||||
|
hasActiveSubscription ? 'bg-gradient-to-r from-purple-600 to-blue-600' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt={customer?.name || 'contact-avatar'}
|
||||||
|
className="size-full rounded-full object-cover"
|
||||||
|
height={80}
|
||||||
|
src={customer?.photoUrl || AvatarPlaceholder}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/web/components/subscription/index.ts
Normal file
1
apps/web/components/subscription/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './try-free-button';
|
||||||
28
apps/web/components/subscription/try-free-button.tsx
Normal file
28
apps/web/components/subscription/try-free-button.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
import { useCreateTrialSubscriptionMutation } from '@/hooks/api/subscriptions';
|
||||||
|
import { Button } from '@repo/ui/components/ui/button';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export function TryFreeButton({ className = '', size = 'lg' }: ComponentProps<typeof Button>) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isPending, mutateAsync: createTrialSubscription } = useCreateTrialSubscriptionMutation();
|
||||||
|
|
||||||
|
const handleTryFree = async () => {
|
||||||
|
await createTrialSubscription();
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`w-full bg-gradient-to-r from-purple-600 to-blue-600 px-8 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-purple-700 hover:to-blue-700 hover:shadow-xl sm:w-auto ${className}`}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleTryFree}
|
||||||
|
size={size}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-2 size-5" />
|
||||||
|
{isPending ? 'Активация...' : 'Попробовать бесплатно'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,18 +1,20 @@
|
|||||||
|
import { getClientWithToken } from '@repo/graphql/apollo/client';
|
||||||
|
import * as GQL from '@repo/graphql/types';
|
||||||
import { type AuthOptions } from 'next-auth';
|
import { type AuthOptions } from 'next-auth';
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
|
|
||||||
export const authOptions: AuthOptions = {
|
export const authOptions: AuthOptions = {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
if (user) {
|
if (user?.telegramId) {
|
||||||
token.id = user.id;
|
token.telegramId = user.telegramId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (token?.id && session?.user) {
|
if (token?.telegramId && session?.user) {
|
||||||
session.user.telegramId = token.id as number;
|
session.user.telegramId = token.telegramId as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
@ -27,7 +29,34 @@ export const authOptions: AuthOptions = {
|
|||||||
throw new Error('Invalid Telegram ID');
|
throw new Error('Invalid Telegram ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id: telegramId };
|
const parsedTelegramId = Number.parseInt(telegramId, 10);
|
||||||
|
if (Number.isNaN(parsedTelegramId)) {
|
||||||
|
throw new TypeError('Invalid Telegram ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем, зарегистрирован ли пользователь
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetCustomerDocument,
|
||||||
|
variables: { telegramId: parsedTelegramId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = result.data.customers.at(0);
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
// Пользователь не зарегистрирован - перенаправляем на страницу регистрации
|
||||||
|
throw new Error('UNREGISTERED');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: parsedTelegramId.toString(), telegramId: parsedTelegramId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('UNREGISTERED')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Authentication failed');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
telegramId: { label: 'Telegram ID', type: 'text' },
|
telegramId: { label: 'Telegram ID', type: 'text' },
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
__DEV_TELEGRAM_ID: z.string().default(''),
|
__DEV_TELEGRAM_ID: z.string().default(''),
|
||||||
BOT_TOKEN: z.string(),
|
BOT_URL: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, type PropsWithChildren, useMemo, useState } from 'react';
|
import { createContext, type PropsWithChildren, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export type FilterType = 'all' | 'clients' | 'masters';
|
export type FilterType = 'all' | 'invited' | 'invitedBy';
|
||||||
|
|
||||||
type ContextType = { filter: FilterType; setFilter: (filter: FilterType) => void };
|
type ContextType = { filter: FilterType; setFilter: (filter: FilterType) => void };
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './query';
|
|
||||||
export * from './use-customer-contacts';
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { getClients, getMasters } from '@/actions/api/customers';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
|
|
||||||
export const useClientsQuery = (props?: Parameters<typeof getClients>[0]) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const telegramId = props?.telegramId || session?.user?.telegramId;
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryFn: () => getClients({ telegramId }),
|
|
||||||
queryKey: ['customer', 'telegramId', telegramId, 'clients'],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMastersQuery = (props?: Parameters<typeof getMasters>[0]) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const telegramId = props?.telegramId || session?.user?.telegramId;
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryFn: () => getMasters({ telegramId }),
|
|
||||||
queryKey: ['customer', 'telegramId', telegramId, 'masters'],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useClientsQuery, useMastersQuery } from './query';
|
|
||||||
import { ContactsContext } from '@/context/contacts';
|
|
||||||
import { sift } from 'radashi';
|
|
||||||
import { use, useEffect, useMemo } from 'react';
|
|
||||||
|
|
||||||
export function useCustomerContacts() {
|
|
||||||
const { filter, setFilter } = use(ContactsContext);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: clientsData,
|
|
||||||
isLoading: isLoadingClients,
|
|
||||||
refetch: refetchClients,
|
|
||||||
} = useClientsQuery();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mastersData,
|
|
||||||
isLoading: isLoadingMasters,
|
|
||||||
refetch: refetchMasters,
|
|
||||||
} = useMastersQuery();
|
|
||||||
|
|
||||||
const clients = clientsData?.clients || [];
|
|
||||||
const masters = mastersData?.masters || [];
|
|
||||||
|
|
||||||
const isLoading = isLoadingClients || isLoadingMasters;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (filter === 'clients') {
|
|
||||||
refetchClients();
|
|
||||||
} else if (filter === 'masters') {
|
|
||||||
refetchMasters();
|
|
||||||
} else {
|
|
||||||
refetchClients();
|
|
||||||
refetchMasters();
|
|
||||||
}
|
|
||||||
}, [filter, refetchClients, refetchMasters]);
|
|
||||||
|
|
||||||
const contacts = useMemo(() => {
|
|
||||||
if (filter === 'clients') return sift(clients);
|
|
||||||
if (filter === 'masters') return sift(masters);
|
|
||||||
|
|
||||||
return [...sift(clients), ...sift(masters)];
|
|
||||||
}, [clients, masters, filter]);
|
|
||||||
|
|
||||||
return { contacts, filter, isLoading, setFilter };
|
|
||||||
}
|
|
||||||
@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getCustomer, updateCustomer } from '@/actions/api/customers';
|
import { getCustomer, getCustomers, updateCustomer } from '@/actions/api/customers';
|
||||||
import { isCustomerBanned, isCustomerMaster } from '@repo/utils/customer';
|
import { isCustomerBanned } from '@repo/utils/customer';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0]) => {
|
export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0]) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const telegramId = variables?.telegramId || session?.user?.telegramId;
|
const telegramId =
|
||||||
|
variables?.telegramId === undefined ? session?.user?.telegramId : variables?.telegramId;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled: Boolean(telegramId),
|
enabled: Boolean(telegramId),
|
||||||
@ -16,12 +17,42 @@ export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0])
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useIsMaster = () => {
|
export const useCustomersQuery = (
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
variables: Parameters<typeof getCustomers>[0],
|
||||||
|
enabled?: boolean,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
enabled,
|
||||||
|
queryFn: () => getCustomers(variables),
|
||||||
|
queryKey: ['customers', variables],
|
||||||
|
staleTime: 60 * 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
if (!customer) return false;
|
export const useCustomersInfiniteQuery = (
|
||||||
|
variables: Omit<Parameters<typeof getCustomers>[0], 'pagination'>,
|
||||||
|
{ enabled = true, pageSize = 10 } = {},
|
||||||
|
) => {
|
||||||
|
const queryFunction = ({ pageParam: page = 1 }) =>
|
||||||
|
getCustomers({
|
||||||
|
...variables,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return isCustomerMaster(customer);
|
return useInfiniteQuery({
|
||||||
|
enabled,
|
||||||
|
getNextPageParam: (lastPage, _allPages, lastPageParameter) => {
|
||||||
|
if (!lastPage?.customers?.length) return undefined;
|
||||||
|
|
||||||
|
return lastPageParameter + 1;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
queryFn: queryFunction,
|
||||||
|
queryKey: ['customers', variables, 'infinite'],
|
||||||
|
staleTime: 60 * 1_000,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useIsBanned = () => {
|
export const useIsBanned = () => {
|
||||||
@ -50,3 +81,38 @@ export const useCustomerMutation = () => {
|
|||||||
onSuccess: handleOnSuccess,
|
onSuccess: handleOnSuccess,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function useContactsInfiniteQuery() {
|
||||||
|
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||||
|
|
||||||
|
const { isLoading: isLoadingContacts, ...query } = useCustomersInfiniteQuery(
|
||||||
|
{
|
||||||
|
filters: {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
invited: {
|
||||||
|
documentId: {
|
||||||
|
contains: customer?.documentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
invitedBy: {
|
||||||
|
documentId: {
|
||||||
|
eq: customer?.documentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ enabled: Boolean(customer?.documentId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = isLoadingContacts || isLoadingCustomer;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
138
apps/web/hooks/api/subscriptions.ts
Normal file
138
apps/web/hooks/api/subscriptions.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/* eslint-disable sonarjs/no-identical-functions */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSubscription,
|
||||||
|
createSubscriptionHistory,
|
||||||
|
createTrialSubscription,
|
||||||
|
getSubscription,
|
||||||
|
getSubscriptionPrices,
|
||||||
|
getSubscriptionSettings,
|
||||||
|
updateSubscription,
|
||||||
|
updateSubscriptionHistory,
|
||||||
|
} from '@/actions/api/subscriptions';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
export const useSubscriptionQuery = (variables?: Parameters<typeof getSubscription>[0]) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const telegramId =
|
||||||
|
variables?.telegramId === undefined ? session?.user?.telegramId : variables?.telegramId;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
enabled: Boolean(telegramId),
|
||||||
|
queryFn: () => getSubscription({ telegramId }),
|
||||||
|
queryKey: ['subscription', telegramId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubscriptionSettingQuery = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryFn: getSubscriptionSettings,
|
||||||
|
queryKey: ['subscriptionSetting'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubscriptionPricesQuery = (
|
||||||
|
variables?: Parameters<typeof getSubscriptionPrices>[0],
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryFn: () => getSubscriptionPrices(variables),
|
||||||
|
queryKey: ['subscriptionPrices', variables],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubscriptionMutation = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const telegramId = session?.user?.telegramId;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleOnSuccess = () => {
|
||||||
|
if (!telegramId) return;
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['subscription', telegramId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateSubscription,
|
||||||
|
onSuccess: handleOnSuccess,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubscriptionCreate = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const telegramId = session?.user?.telegramId;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleOnSuccess = () => {
|
||||||
|
if (!telegramId) return;
|
||||||
|
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ['subscription', telegramId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createSubscription,
|
||||||
|
onSuccess: handleOnSuccess,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubscriptionHistoryMutation = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const telegramId = session?.user?.telegramId;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleOnSuccess = () => {
|
||||||
|
if (!telegramId) return;
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['subscription', telegramId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateSubscriptionHistory,
|
||||||
|
onSuccess: handleOnSuccess,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubscriptionHistoryCreate = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const telegramId = session?.user?.telegramId;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleOnSuccess = () => {
|
||||||
|
if (!telegramId) return;
|
||||||
|
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ['subscription', telegramId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createSubscriptionHistory,
|
||||||
|
onSuccess: handleOnSuccess,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateTrialSubscriptionMutation = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const telegramId = session?.user?.telegramId;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleOnSuccess = () => {
|
||||||
|
if (!telegramId) return;
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['subscription', telegramId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createTrialSubscription,
|
||||||
|
onSuccess: handleOnSuccess,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -11,5 +11,5 @@ export default withAuth({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!auth|browser|telegram|api|_next/static|_next/image|favicon.ico).*)'],
|
matcher: ['/((?!auth|browser|telegram|unregistered|api|_next/static|_next/image|favicon.ico).*)'],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,25 +2,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useOrderStore } from './context';
|
import { useOrderStore } from './context';
|
||||||
import { type Steps } from './types';
|
import { type Steps } from './types';
|
||||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||||
import { type OrderFieldsFragment } from '@repo/graphql/types';
|
import { type OrderFieldsFragment } from '@repo/graphql/types';
|
||||||
import { sift } from 'radashi';
|
import { sift } from 'radashi';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
const STEPS: Steps[] = [
|
// Унифицированные шаги для всех пользователей
|
||||||
|
const UNIFIED_STEPS: Steps[] = [
|
||||||
'master-select',
|
'master-select',
|
||||||
'client-select',
|
'client-select',
|
||||||
'service-select',
|
'service-select',
|
||||||
'datetime-select',
|
'datetime-select',
|
||||||
'success',
|
'success',
|
||||||
];
|
];
|
||||||
export const MASTER_STEPS: Steps[] = STEPS.filter((step) => step !== 'master-select');
|
|
||||||
export const CLIENT_STEPS: Steps[] = STEPS.filter((step) => step !== 'client-select');
|
|
||||||
|
|
||||||
export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
const { data: { customer } = {} } = useCustomerQuery();
|
const { data: { customer } = {} } = useCustomerQuery();
|
||||||
const isMaster = useIsMaster();
|
|
||||||
|
|
||||||
const setMasterId = useOrderStore((store) => store.setMasterId);
|
const setMasterId = useOrderStore((store) => store.setMasterId);
|
||||||
const setClientId = useOrderStore((store) => store.setClientId);
|
const setClientId = useOrderStore((store) => store.setClientId);
|
||||||
@ -32,8 +30,7 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized.current || !customer || step !== 'loading') return;
|
if (initialized.current || !customer || step !== 'loading') return;
|
||||||
|
|
||||||
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
|
setStepsSequence(UNIFIED_STEPS);
|
||||||
setStepsSequence(steps);
|
|
||||||
|
|
||||||
// Инициализация из initData (например, для повторного заказа)
|
// Инициализация из initData (например, для повторного заказа)
|
||||||
if (initData) {
|
if (initData) {
|
||||||
@ -49,25 +46,19 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
|||||||
setStep('datetime-select');
|
setStep('datetime-select');
|
||||||
} else if (masterId && clientId) {
|
} else if (masterId && clientId) {
|
||||||
setStep('service-select');
|
setStep('service-select');
|
||||||
|
} else if (masterId) {
|
||||||
|
setStep('client-select');
|
||||||
} else {
|
} else {
|
||||||
setStep(steps[0] ?? 'loading');
|
setStep(UNIFIED_STEPS[0] ?? 'loading');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Обычная инициализация (новый заказ)
|
setStep(UNIFIED_STEPS[0] ?? 'loading');
|
||||||
if (isMaster) {
|
|
||||||
setMasterId(customer.documentId);
|
|
||||||
} else {
|
|
||||||
setClientId(customer.documentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStep(steps[0] ?? 'loading');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
}, [
|
}, [
|
||||||
customer,
|
customer,
|
||||||
initData,
|
initData,
|
||||||
isMaster,
|
|
||||||
setClientId,
|
setClientId,
|
||||||
setMasterId,
|
setMasterId,
|
||||||
setServiceIds,
|
setServiceIds,
|
||||||
|
|||||||
6
apps/web/types/next-auth.d.ts
vendored
6
apps/web/types/next-auth.d.ts
vendored
@ -12,3 +12,9 @@ declare module 'next-auth' {
|
|||||||
telegramId?: null | number;
|
telegramId?: null | number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'next-auth/jwt' {
|
||||||
|
interface JWT {
|
||||||
|
telegramId?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/']
|
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable canonical/id-match */
|
/* eslint-disable canonical/id-match */
|
||||||
import { getClientWithToken } from '../apollo/client';
|
import { getClientWithToken } from '../apollo/client';
|
||||||
import { ERRORS } from '../constants/errors';
|
import { ERRORS as SHARED_ERRORS } from '../constants/errors';
|
||||||
import * as GQL from '../types';
|
import * as GQL from '../types';
|
||||||
import { isCustomerBanned } from '@repo/utils/customer';
|
import { isCustomerBanned } from '@repo/utils/customer';
|
||||||
|
|
||||||
const BASE_ERRORS = {
|
export const ERRORS = {
|
||||||
MISSING_TELEGRAM_ID: 'Не указан Telegram ID',
|
MISSING_TELEGRAM_ID: 'Не указан Telegram ID',
|
||||||
NOT_FOUND_CUSTOMER: 'Пользователь не найден',
|
NOT_FOUND_CUSTOMER: 'Пользователь не найден',
|
||||||
} as const;
|
} as const;
|
||||||
@ -18,7 +18,7 @@ export class BaseService {
|
|||||||
|
|
||||||
constructor(user: UserProfile) {
|
constructor(user: UserProfile) {
|
||||||
if (!user?.telegramId) {
|
if (!user?.telegramId) {
|
||||||
throw new Error(BASE_ERRORS.MISSING_TELEGRAM_ID);
|
throw new Error(ERRORS.MISSING_TELEGRAM_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._user = user;
|
this._user = user;
|
||||||
@ -34,10 +34,10 @@ export class BaseService {
|
|||||||
|
|
||||||
const customer = result.data.customers.at(0);
|
const customer = result.data.customers.at(0);
|
||||||
|
|
||||||
if (!customer) throw new Error(BASE_ERRORS.NOT_FOUND_CUSTOMER);
|
if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER);
|
||||||
|
|
||||||
if (isCustomerBanned(customer)) {
|
if (isCustomerBanned(customer)) {
|
||||||
throw new Error(ERRORS.NO_PERMISSION);
|
throw new Error(SHARED_ERRORS.NO_PERMISSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { customer };
|
return { customer };
|
||||||
@ -53,8 +53,10 @@ export class BaseService {
|
|||||||
|
|
||||||
const customer = result.data.customers.at(0);
|
const customer = result.data.customers.at(0);
|
||||||
|
|
||||||
|
if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER);
|
||||||
|
|
||||||
if (customer && isCustomerBanned(customer)) {
|
if (customer && isCustomerBanned(customer)) {
|
||||||
throw new Error(ERRORS.NO_PERMISSION);
|
throw new Error(SHARED_ERRORS.NO_PERMISSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { customer };
|
return { customer };
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import * as GQL from '../types';
|
|||||||
import { BaseService } from './base';
|
import { BaseService } from './base';
|
||||||
import { type VariablesOf } from '@graphql-typed-document-node/core';
|
import { type VariablesOf } from '@graphql-typed-document-node/core';
|
||||||
|
|
||||||
|
const DEFAULT_CUSTOMERS_SORT = ['name:asc'];
|
||||||
|
|
||||||
export class CustomersService extends BaseService {
|
export class CustomersService extends BaseService {
|
||||||
async addMasters(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
|
async addInvitedBy(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
|
||||||
await this.checkIsBanned();
|
await this.checkIsBanned();
|
||||||
|
|
||||||
const newMasterIds = variables.data.masters;
|
const newInvitedByIds = variables.data.invitedBy;
|
||||||
|
|
||||||
// Проверяем, что пользователь не пытается изменить поле bannedUntil
|
// Проверяем, что пользователь не пытается изменить поле bannedUntil
|
||||||
if (variables.data.bannedUntil !== undefined) {
|
if (variables.data.bannedUntil !== undefined) {
|
||||||
@ -16,21 +18,23 @@ export class CustomersService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { mutate, query } = await getClientWithToken();
|
const { mutate, query } = await getClientWithToken();
|
||||||
const getMastersResult = await query({
|
const getInvitedByResult = await query({
|
||||||
query: GQL.GetMastersDocument,
|
query: GQL.GetInvitedByDocument,
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingMasterIds = getMastersResult?.data?.customers
|
const existingInvitedByIds = getInvitedByResult?.data?.customers
|
||||||
?.at(0)
|
?.at(0)
|
||||||
?.masters.map((x) => x?.documentId);
|
?.invitedBy.map((x) => x?.documentId);
|
||||||
|
|
||||||
const newMastersIds = [...new Set([...(existingMasterIds || []), ...(newMasterIds || [])])];
|
const newInvitedByIdsList = [
|
||||||
|
...new Set([...(existingInvitedByIds || []), ...(newInvitedByIds || [])]),
|
||||||
|
];
|
||||||
|
|
||||||
const mutationResult = await mutate({
|
const mutationResult = await mutate({
|
||||||
mutation: GQL.UpdateCustomerDocument,
|
mutation: GQL.UpdateCustomerDocument,
|
||||||
variables: {
|
variables: {
|
||||||
data: { masters: newMastersIds },
|
data: { invitedBy: newInvitedByIdsList },
|
||||||
documentId: variables.documentId,
|
documentId: variables.documentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -41,21 +45,6 @@ export class CustomersService extends BaseService {
|
|||||||
return mutationResult.data;
|
return mutationResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClients(variables?: VariablesOf<typeof GQL.GetClientsDocument>) {
|
|
||||||
await this.checkIsBanned();
|
|
||||||
|
|
||||||
const { query } = await getClientWithToken();
|
|
||||||
|
|
||||||
const result = await query({
|
|
||||||
query: GQL.GetClientsDocument,
|
|
||||||
variables,
|
|
||||||
});
|
|
||||||
|
|
||||||
const customer = result.data.customers.at(0);
|
|
||||||
|
|
||||||
return customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
|
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
|
||||||
await this.checkIsBanned();
|
await this.checkIsBanned();
|
||||||
|
|
||||||
@ -71,13 +60,44 @@ export class CustomersService extends BaseService {
|
|||||||
return { customer };
|
return { customer };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMasters(variables?: VariablesOf<typeof GQL.GetMastersDocument>) {
|
async getCustomers(variables: VariablesOf<typeof GQL.GetCustomersDocument>) {
|
||||||
await this.checkIsBanned();
|
await this.checkIsBanned();
|
||||||
|
|
||||||
const { query } = await getClientWithToken();
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
const result = await query({
|
const result = await query({
|
||||||
query: GQL.GetMastersDocument,
|
query: GQL.GetCustomersDocument,
|
||||||
|
variables: {
|
||||||
|
sort: DEFAULT_CUSTOMERS_SORT,
|
||||||
|
...variables,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvited(variables?: VariablesOf<typeof GQL.GetInvitedDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetInvitedDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = result.data.customers.at(0);
|
||||||
|
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvitedBy(variables?: VariablesOf<typeof GQL.GetInvitedByDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetInvitedByDocument,
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { CustomersService } from './customers';
|
|||||||
import { ERRORS, OrdersService } from './orders';
|
import { ERRORS, OrdersService } from './orders';
|
||||||
import { ServicesService } from './services';
|
import { ServicesService } from './services';
|
||||||
import { SlotsService } from './slots';
|
import { SlotsService } from './slots';
|
||||||
|
import { SubscriptionsService } from './subscriptions';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@ -11,10 +12,10 @@ vi.mock('../apollo/client');
|
|||||||
vi.mock('./customers');
|
vi.mock('./customers');
|
||||||
vi.mock('./services');
|
vi.mock('./services');
|
||||||
vi.mock('./slots');
|
vi.mock('./slots');
|
||||||
|
vi.mock('./subscriptions');
|
||||||
vi.mock('../config/env', () => {
|
vi.mock('../config/env', () => {
|
||||||
return {
|
return {
|
||||||
env: {
|
env: {
|
||||||
BOT_TOKEN: 'test',
|
|
||||||
LOGIN_GRAPHQL: 'test',
|
LOGIN_GRAPHQL: 'test',
|
||||||
PASSWORD_GRAPHQL: 'test',
|
PASSWORD_GRAPHQL: 'test',
|
||||||
URL_GRAPHQL: 'test',
|
URL_GRAPHQL: 'test',
|
||||||
@ -26,6 +27,7 @@ const mockGetClientWithToken = vi.mocked(getClientWithToken);
|
|||||||
const mockCustomersService = vi.mocked(CustomersService);
|
const mockCustomersService = vi.mocked(CustomersService);
|
||||||
const mockServicesService = vi.mocked(ServicesService);
|
const mockServicesService = vi.mocked(ServicesService);
|
||||||
const mockSlotsService = vi.mocked(SlotsService);
|
const mockSlotsService = vi.mocked(SlotsService);
|
||||||
|
const mockSubscriptionsService = vi.mocked(SubscriptionsService);
|
||||||
|
|
||||||
describe('OrdersService', () => {
|
describe('OrdersService', () => {
|
||||||
/**
|
/**
|
||||||
@ -97,6 +99,11 @@ describe('OrdersService', () => {
|
|||||||
customer: mockCustomer,
|
customer: mockCustomer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Глобальный мок для checkIsBanned
|
||||||
|
vi.spyOn(ordersService, 'checkIsBanned').mockResolvedValue({
|
||||||
|
customer: mockCustomer,
|
||||||
|
});
|
||||||
|
|
||||||
// Глобальные моки для сервисов
|
// Глобальные моки для сервисов
|
||||||
mockServicesService.mockImplementation(() => ({
|
mockServicesService.mockImplementation(() => ({
|
||||||
getService: vi.fn().mockResolvedValue({
|
getService: vi.fn().mockResolvedValue({
|
||||||
@ -114,8 +121,29 @@ describe('OrdersService', () => {
|
|||||||
getCustomer: vi.fn().mockResolvedValue({
|
getCustomer: vi.fn().mockResolvedValue({
|
||||||
customer: mockCustomer,
|
customer: mockCustomer,
|
||||||
}),
|
}),
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockSubscriptionsService.mockImplementation(() => ({
|
||||||
|
getSubscription: vi.fn().mockResolvedValue({
|
||||||
|
maxOrdersPerMonth: 10,
|
||||||
|
remainingOrdersCount: 5,
|
||||||
|
subscription: {
|
||||||
|
autoRenew: false,
|
||||||
|
documentId: 'subscription-123',
|
||||||
|
expiresAt: now.add(30, 'day').toISOString(),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getSubscriptionSettings: vi.fn().mockResolvedValue({
|
||||||
|
subscriptionSetting: {
|
||||||
|
documentId: 'subscription-setting-123',
|
||||||
|
maxOrdersPerMonth: 10,
|
||||||
|
referralBonusDays: 3,
|
||||||
|
referralRewardDays: 7,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@ -212,8 +240,8 @@ describe('OrdersService', () => {
|
|||||||
getCustomer: vi.fn().mockResolvedValue({
|
getCustomer: vi.fn().mockResolvedValue({
|
||||||
customer: masterCustomer,
|
customer: masterCustomer,
|
||||||
}),
|
}),
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [masterCustomer],
|
invitedBy: [masterCustomer],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -324,7 +352,7 @@ describe('OrdersService', () => {
|
|||||||
|
|
||||||
const result = ordersService.createOrder(mockVariables);
|
const result = ordersService.createOrder(mockVariables);
|
||||||
|
|
||||||
await expect(result).rejects.toThrow(ERRORS.MISSING_SLOT);
|
await expect(result).rejects.toThrow(ERRORS.INVALID_MASTER);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when order is out of slot time', async () => {
|
it('should throw error when order is out of slot time', async () => {
|
||||||
@ -353,8 +381,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -404,8 +432,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -440,8 +468,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -481,8 +509,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -527,8 +555,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [inactiveMaster],
|
invitedBy: [inactiveMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -574,8 +602,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [activeCustomerAsMaster],
|
invitedBy: [activeCustomerAsMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -610,8 +638,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [], // клиент не связан с мастером
|
invitedBy: [], // клиент не связан с мастером
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -657,8 +685,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -698,8 +726,8 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
mockCustomersService.mockImplementation(() => ({
|
mockCustomersService.mockImplementation(() => ({
|
||||||
getCustomer: mockGetCustomer,
|
getCustomer: mockGetCustomer,
|
||||||
getMasters: vi.fn().mockResolvedValue({
|
getInvitedBy: vi.fn().mockResolvedValue({
|
||||||
masters: [mockMaster],
|
invitedBy: [mockMaster],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import { BaseService } from './base';
|
|||||||
import { CustomersService } from './customers';
|
import { CustomersService } from './customers';
|
||||||
import { ServicesService } from './services';
|
import { ServicesService } from './services';
|
||||||
import { SlotsService } from './slots';
|
import { SlotsService } from './slots';
|
||||||
|
import { SubscriptionsService } from './subscriptions';
|
||||||
import { type VariablesOf } from '@graphql-typed-document-node/core';
|
import { type VariablesOf } from '@graphql-typed-document-node/core';
|
||||||
import { isCustomerMaster } from '@repo/utils/customer';
|
|
||||||
import { getMinutes, isBeforeNow, isNowOrAfter } from '@repo/utils/datetime-format';
|
import { getMinutes, isBeforeNow, isNowOrAfter } from '@repo/utils/datetime-format';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
@ -31,10 +31,15 @@ export const ERRORS = {
|
|||||||
NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе',
|
NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе',
|
||||||
NO_ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом',
|
NO_ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом',
|
||||||
NO_ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота',
|
NO_ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота',
|
||||||
|
NO_SELF_ORDER: 'Нельзя записать к самому себе',
|
||||||
NOT_FOUND_CLIENT: 'Клиент не найден',
|
NOT_FOUND_CLIENT: 'Клиент не найден',
|
||||||
NOT_FOUND_MASTER: 'Мастер не найден',
|
NOT_FOUND_MASTER: 'Мастер не найден',
|
||||||
NOT_FOUND_ORDER: 'Заказ не найден',
|
NOT_FOUND_ORDER: 'Заказ не найден',
|
||||||
NOT_FOUND_ORDER_SLOT: 'Слот заказа не найден',
|
NOT_FOUND_ORDER_SLOT: 'Слот заказа не найден',
|
||||||
|
ORDER_LIMIT_EXCEEDED_CLIENT:
|
||||||
|
'Достигнут лимит заказов у этого мастера на месяц. Попробуйте записаться позже или к другому мастеру.',
|
||||||
|
ORDER_LIMIT_EXCEEDED_MASTER:
|
||||||
|
'Достигнут лимит заказов на месяц. Оформите Pro доступ для продолжения работы.',
|
||||||
OVERLAPPING_TIME: 'Время пересекается с другими заказами',
|
OVERLAPPING_TIME: 'Время пересекается с другими заказами',
|
||||||
SLOT_CLOSED: 'Слот закрыт',
|
SLOT_CLOSED: 'Слот закрыт',
|
||||||
};
|
};
|
||||||
@ -43,6 +48,7 @@ const DEFAULT_ORDERS_SORT = ['slot.datetime_start:desc', 'datetime_start:desc'];
|
|||||||
|
|
||||||
export class OrdersService extends BaseService {
|
export class OrdersService extends BaseService {
|
||||||
async createOrder(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
|
async createOrder(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
const { customer } = await this._getUser();
|
const { customer } = await this._getUser();
|
||||||
|
|
||||||
// Проверки на существование обязательных полей для предотвращения ошибок типов
|
// Проверки на существование обязательных полей для предотвращения ошибок типов
|
||||||
@ -50,6 +56,37 @@ export class OrdersService extends BaseService {
|
|||||||
if (!variables.input.services?.length) throw new Error(ERRORS.MISSING_SERVICES);
|
if (!variables.input.services?.length) throw new Error(ERRORS.MISSING_SERVICES);
|
||||||
if (!variables.input.client) throw new Error(ERRORS.MISSING_CLIENT);
|
if (!variables.input.client) throw new Error(ERRORS.MISSING_CLIENT);
|
||||||
|
|
||||||
|
// Получаем информацию о мастере слота для проверки лимита
|
||||||
|
const slotService = new SlotsService(this._user);
|
||||||
|
const { slot } = await slotService.getSlot({ documentId: variables.input.slot });
|
||||||
|
|
||||||
|
if (!slot?.master?.telegramId) {
|
||||||
|
throw new Error(ERRORS.INVALID_MASTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка лимита заказов мастера слота
|
||||||
|
const subscriptionsService = new SubscriptionsService(this._user);
|
||||||
|
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
|
||||||
|
|
||||||
|
const proEnabled = subscriptionSetting?.proEnabled;
|
||||||
|
|
||||||
|
if (proEnabled) {
|
||||||
|
const { remainingOrdersCount, subscription } = await subscriptionsService.getSubscription(
|
||||||
|
slot.master,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMasterCreating = slot.master.documentId === customer?.documentId;
|
||||||
|
|
||||||
|
// Если у мастера слота нет активной подписки и не осталось доступных заказов
|
||||||
|
if (!subscription?.active && remainingOrdersCount <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
isMasterCreating
|
||||||
|
? ERRORS.ORDER_LIMIT_EXCEEDED_MASTER
|
||||||
|
: ERRORS.ORDER_LIMIT_EXCEEDED_CLIENT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const servicesService = new ServicesService(this._user);
|
const servicesService = new ServicesService(this._user);
|
||||||
|
|
||||||
// Получаем все услуги по их идентификаторам
|
// Получаем все услуги по их идентификаторам
|
||||||
@ -84,6 +121,8 @@ export class OrdersService extends BaseService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSlotMaster = slot.master.documentId === customer.documentId;
|
||||||
|
|
||||||
const { mutate } = await getClientWithToken();
|
const { mutate } = await getClientWithToken();
|
||||||
|
|
||||||
const mutationResult = await mutate({
|
const mutationResult = await mutate({
|
||||||
@ -93,9 +132,7 @@ export class OrdersService extends BaseService {
|
|||||||
input: {
|
input: {
|
||||||
...variables.input,
|
...variables.input,
|
||||||
datetime_end: datetimeEnd,
|
datetime_end: datetimeEnd,
|
||||||
state: isCustomerMaster(customer)
|
state: isSlotMaster ? GQL.Enum_Order_State.Approved : GQL.Enum_Order_State.Created,
|
||||||
? GQL.Enum_Order_State.Approved
|
|
||||||
: GQL.Enum_Order_State.Created,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -107,6 +144,7 @@ export class OrdersService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getOrder(variables: VariablesOf<typeof GQL.GetOrderDocument>) {
|
async getOrder(variables: VariablesOf<typeof GQL.GetOrderDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
const { query } = await getClientWithToken();
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
const result = await query({
|
const result = await query({
|
||||||
@ -118,6 +156,7 @@ export class OrdersService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getOrders(variables: VariablesOf<typeof GQL.GetOrdersDocument>) {
|
async getOrders(variables: VariablesOf<typeof GQL.GetOrdersDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
const { query } = await getClientWithToken();
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
const result = await query({
|
const result = await query({
|
||||||
@ -132,6 +171,7 @@ export class OrdersService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateOrder(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
|
async updateOrder(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
await this.checkUpdatePermission(variables);
|
await this.checkUpdatePermission(variables);
|
||||||
await this.checkBeforeUpdate(variables);
|
await this.checkBeforeUpdate(variables);
|
||||||
|
|
||||||
@ -223,6 +263,8 @@ export class OrdersService extends BaseService {
|
|||||||
|
|
||||||
if (!slot) throw new Error(ERRORS.MISSING_SLOT);
|
if (!slot) throw new Error(ERRORS.MISSING_SLOT);
|
||||||
|
|
||||||
|
if (clientId === slot?.master?.documentId) throw new Error(ERRORS.NO_SELF_ORDER);
|
||||||
|
|
||||||
// Проверка, что заказ укладывается в рамки слота
|
// Проверка, что заказ укладывается в рамки слота
|
||||||
if (
|
if (
|
||||||
new Date(datetime_start) < new Date(slot.datetime_start) ||
|
new Date(datetime_start) < new Date(slot.datetime_start) ||
|
||||||
@ -254,37 +296,12 @@ export class OrdersService extends BaseService {
|
|||||||
throw new Error(ERRORS.INACTIVE_MASTER);
|
throw new Error(ERRORS.INACTIVE_MASTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Проверка ролей и связей
|
|
||||||
const isClientMaster = clientEntity.role === GQL.Enum_Customer_Role.Master;
|
|
||||||
const slotMasterId = slot.master?.documentId;
|
const slotMasterId = slot.master?.documentId;
|
||||||
|
|
||||||
if (!slotMasterId) {
|
if (!slotMasterId) {
|
||||||
throw new Error(ERRORS.NOT_FOUND_MASTER);
|
throw new Error(ERRORS.NOT_FOUND_MASTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isClientMaster) {
|
|
||||||
// Мастер может записывать только себя
|
|
||||||
if (slotMasterId !== clientId) {
|
|
||||||
throw new Error(ERRORS.INVALID_MASTER);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Клиент не должен быть мастером слота
|
|
||||||
if (slotMasterId === clientId) {
|
|
||||||
throw new Error(ERRORS.NO_MASTER_SELF_BOOK);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientMasters = await customerService.getMasters({ documentId: clientId });
|
|
||||||
|
|
||||||
const isLinkedToSlotMaster = clientMasters?.masters.some(
|
|
||||||
(master) => master?.documentId === slotMasterId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Клиент должен быть привязан к мастеру слота
|
|
||||||
if (!isLinkedToSlotMaster) {
|
|
||||||
throw new Error(ERRORS.INVALID_MASTER);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка пересечений заказов по времени.
|
// Проверка пересечений заказов по времени.
|
||||||
|
|
||||||
const { orders: overlappingOrders } = await this.getOrders({
|
const { orders: overlappingOrders } = await this.getOrders({
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import * as GQL from '../types';
|
|||||||
import { ERRORS as BASE_ERRORS } from './base';
|
import { ERRORS as BASE_ERRORS } from './base';
|
||||||
import { ServicesService } from './services';
|
import { ServicesService } from './services';
|
||||||
import { ERRORS, SlotsService } from './slots';
|
import { ERRORS, SlotsService } from './slots';
|
||||||
|
import { SubscriptionsService } from './subscriptions';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@ -13,10 +14,10 @@ if (!dayjs.prototype.duration) {
|
|||||||
|
|
||||||
vi.mock('../apollo/client');
|
vi.mock('../apollo/client');
|
||||||
vi.mock('./services');
|
vi.mock('./services');
|
||||||
|
vi.mock('./subscriptions');
|
||||||
vi.mock('../config/env', () => {
|
vi.mock('../config/env', () => {
|
||||||
return {
|
return {
|
||||||
env: {
|
env: {
|
||||||
BOT_TOKEN: 'test',
|
|
||||||
LOGIN_GRAPHQL: 'test',
|
LOGIN_GRAPHQL: 'test',
|
||||||
PASSWORD_GRAPHQL: 'test',
|
PASSWORD_GRAPHQL: 'test',
|
||||||
URL_GRAPHQL: 'test',
|
URL_GRAPHQL: 'test',
|
||||||
@ -26,6 +27,7 @@ vi.mock('../config/env', () => {
|
|||||||
|
|
||||||
const mockGetClientWithToken = vi.mocked(getClientWithToken);
|
const mockGetClientWithToken = vi.mocked(getClientWithToken);
|
||||||
const mockServicesService = vi.mocked(ServicesService);
|
const mockServicesService = vi.mocked(ServicesService);
|
||||||
|
const mockSubscriptionsService = vi.mocked(SubscriptionsService);
|
||||||
|
|
||||||
describe('SlotsService', () => {
|
describe('SlotsService', () => {
|
||||||
/**
|
/**
|
||||||
@ -68,6 +70,33 @@ describe('SlotsService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
slotsService = new SlotsService(mockUser);
|
slotsService = new SlotsService(mockUser);
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Глобальный мок для checkIsBanned
|
||||||
|
vi.spyOn(slotsService, 'checkIsBanned').mockResolvedValue({
|
||||||
|
customer: mockCustomer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Глобальный мок для SubscriptionsService
|
||||||
|
mockSubscriptionsService.mockImplementation(() => ({
|
||||||
|
getSubscription: vi.fn().mockResolvedValue({
|
||||||
|
maxOrdersPerMonth: 10,
|
||||||
|
remainingOrdersCount: 5,
|
||||||
|
subscription: {
|
||||||
|
autoRenew: false,
|
||||||
|
documentId: 'subscription-123',
|
||||||
|
expiresAt: now.add(30, 'day').toISOString(),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getSubscriptionSettings: vi.fn().mockResolvedValue({
|
||||||
|
subscriptionSetting: {
|
||||||
|
documentId: 'subscription-setting-123',
|
||||||
|
maxOrdersPerMonth: 10,
|
||||||
|
referralBonusDays: 3,
|
||||||
|
referralRewardDays: 7,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -470,26 +499,6 @@ describe('SlotsService', () => {
|
|||||||
expect(result.times).toHaveLength(0);
|
expect(result.times).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle GraphQL errors', async () => {
|
|
||||||
const mockQuery = vi.fn().mockImplementation(({ query }) => {
|
|
||||||
if (query === GQL.GetSlotsOrdersDocument) {
|
|
||||||
return Promise.resolve({
|
|
||||||
error: { message: 'GraphQL error' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({ data: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetClientWithToken.mockResolvedValue({
|
|
||||||
query: mockQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext);
|
|
||||||
|
|
||||||
await expect(result).rejects.toThrow('GraphQL error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate total service duration correctly', async () => {
|
it('should calculate total service duration correctly', async () => {
|
||||||
const serviceWithDuration1 = {
|
const serviceWithDuration1 = {
|
||||||
...mockService1,
|
...mockService1,
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export class SlotsService extends BaseService {
|
|||||||
|
|
||||||
if (!masterEntity) throw new Error(ERRORS.NOT_FOUND_MASTER);
|
if (!masterEntity) throw new Error(ERRORS.NOT_FOUND_MASTER);
|
||||||
|
|
||||||
if (!masterEntity?.active || masterEntity.role !== 'master') {
|
if (!masterEntity?.active) {
|
||||||
throw new Error(ERRORS.INACTIVE_MASTER);
|
throw new Error(ERRORS.INACTIVE_MASTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
370
packages/graphql/api/subscriptions.ts
Normal file
370
packages/graphql/api/subscriptions.ts
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
import { getClientWithToken } from '../apollo/client';
|
||||||
|
import * as GQL from '../types';
|
||||||
|
import { BaseService } from './base';
|
||||||
|
import { OrdersService } from './orders';
|
||||||
|
import { type VariablesOf } from '@graphql-typed-document-node/core';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import minMax from 'dayjs/plugin/minMax';
|
||||||
|
|
||||||
|
if (!dayjs.prototype.minMax) {
|
||||||
|
dayjs.extend(minMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
FAILED_TO_CREATE_TRIAL_SUBSCRIPTION: 'Не удалось оформить доступ к пробному периоду',
|
||||||
|
SUBSCRIPTION_PRICES_NOT_FOUND: 'Цены Pro доступа не найдены',
|
||||||
|
SUBSCRIPTION_SETTING_NOT_FOUND: 'Настройки Pro доступа не найдены',
|
||||||
|
TRIAL_PERIOD_ALREADY_USED: 'Пробный период уже был использован',
|
||||||
|
TRIAL_PERIOD_NOT_ACTIVE: 'Пробный период неактивен',
|
||||||
|
TRIAL_PERIOD_NOT_FOUND: 'Пробный период не найден',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SubscriptionsService extends BaseService {
|
||||||
|
async createOrUpdateSubscription(payload: { period: GQL.Enum_Subscriptionprice_Period }) {
|
||||||
|
const { subscriptionPrices } = await this.getSubscriptionPrices({
|
||||||
|
filters: { period: { eq: payload.period } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscriptionPrice = subscriptionPrices[0];
|
||||||
|
if (!subscriptionPrice) throw new Error('Subscription price not found');
|
||||||
|
|
||||||
|
const { subscription: existingSubscription } = await this.getSubscription({
|
||||||
|
telegramId: this._user.telegramId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newExpiresAt = dayjs
|
||||||
|
.max(dayjs(existingSubscription?.expiresAt), dayjs())
|
||||||
|
.add(subscriptionPrice.days, 'day');
|
||||||
|
|
||||||
|
const { customer } = await this.checkIsBanned();
|
||||||
|
|
||||||
|
const result = await this.createSubscription({
|
||||||
|
data: {
|
||||||
|
active: true,
|
||||||
|
customer: customer.documentId,
|
||||||
|
expiresAt: newExpiresAt.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем в последнюю подписку ссылку на новую только что созданную
|
||||||
|
if (result?.createSubscription && existingSubscription)
|
||||||
|
await this.updateSubscription({
|
||||||
|
data: {
|
||||||
|
active: true,
|
||||||
|
nextSubscription: result.createSubscription.documentId,
|
||||||
|
},
|
||||||
|
documentId: existingSubscription?.documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.createSubscriptionHistory({
|
||||||
|
data: {
|
||||||
|
amount: subscriptionPrice.amount,
|
||||||
|
currency: 'RUB',
|
||||||
|
description: existingSubscription ? 'Продление Pro доступа' : 'Новая подписка',
|
||||||
|
period: subscriptionPrice.period,
|
||||||
|
source: GQL.Enum_Subscriptionhistory_Source.Payment,
|
||||||
|
state: GQL.Enum_Subscriptionhistory_State.Success,
|
||||||
|
subscription: result?.createSubscription?.documentId,
|
||||||
|
subscription_price: subscriptionPrice.documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
expiresAt: newExpiresAt.toDate(),
|
||||||
|
formattedDate: newExpiresAt.toDate().toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSubscription(variables: VariablesOf<typeof GQL.CreateSubscriptionDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { mutate } = await getClientWithToken();
|
||||||
|
|
||||||
|
const mutationResult = await mutate({
|
||||||
|
mutation: GQL.CreateSubscriptionDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = mutationResult.errors?.at(0);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return mutationResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSubscriptionHistory(
|
||||||
|
variables: VariablesOf<typeof GQL.CreateSubscriptionHistoryDocument>,
|
||||||
|
) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { mutate } = await getClientWithToken();
|
||||||
|
|
||||||
|
const mutationResult = await mutate({
|
||||||
|
mutation: GQL.CreateSubscriptionHistoryDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = mutationResult.errors?.at(0);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return mutationResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTrialSubscription() {
|
||||||
|
// Получаем пользователя и проверяем бан
|
||||||
|
const { customer } = await this.checkIsBanned();
|
||||||
|
|
||||||
|
// Проверяем, не использовал ли пользователь уже пробный период
|
||||||
|
const hasUserTrial = await this.usedTrialSubscription();
|
||||||
|
if (hasUserTrial) throw new Error(ERRORS.TRIAL_PERIOD_ALREADY_USED);
|
||||||
|
|
||||||
|
// Получаем цены подписки для определения длительности пробного периода
|
||||||
|
const { subscriptionPrices } = await this.getSubscriptionPrices({
|
||||||
|
filters: {
|
||||||
|
active: {
|
||||||
|
eq: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!subscriptionPrices) throw new Error(ERRORS.SUBSCRIPTION_PRICES_NOT_FOUND);
|
||||||
|
|
||||||
|
// Ищем пробный период
|
||||||
|
const trialPrice = subscriptionPrices.find(
|
||||||
|
(price) => price?.period === GQL.Enum_Subscriptionprice_Period.Trial,
|
||||||
|
);
|
||||||
|
if (!trialPrice) throw new Error(ERRORS.TRIAL_PERIOD_NOT_FOUND);
|
||||||
|
if (!trialPrice.active) throw new Error(ERRORS.TRIAL_PERIOD_NOT_ACTIVE);
|
||||||
|
|
||||||
|
const trialPeriodDays = trialPrice?.days;
|
||||||
|
const now = dayjs();
|
||||||
|
const expiresAt = now.add(trialPeriodDays, 'day');
|
||||||
|
|
||||||
|
// Создаем пробную подписку
|
||||||
|
const subscriptionData = await this.createSubscription({
|
||||||
|
data: {
|
||||||
|
active: true,
|
||||||
|
customer: customer?.documentId,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscriptionData?.createSubscription) {
|
||||||
|
throw new Error(ERRORS.FAILED_TO_CREATE_TRIAL_SUBSCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = subscriptionData.createSubscription;
|
||||||
|
|
||||||
|
// Создаем запись в истории подписки
|
||||||
|
await this.createSubscriptionHistory({
|
||||||
|
data: {
|
||||||
|
amount: 0,
|
||||||
|
currency: 'RUB',
|
||||||
|
description: `Пробный период на ${trialPeriodDays} дней`,
|
||||||
|
period: trialPrice.period,
|
||||||
|
source: GQL.Enum_Subscriptionhistory_Source.Trial,
|
||||||
|
state: GQL.Enum_Subscriptionhistory_State.Success,
|
||||||
|
subscription: subscription.documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscriptionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscription({
|
||||||
|
telegramId,
|
||||||
|
}: Pick<VariablesOf<typeof GQL.GetCustomerDocument>, 'telegramId'>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const data = await this.getSubscriptions({
|
||||||
|
filters: {
|
||||||
|
active: {
|
||||||
|
eq: true,
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
telegramId: { eq: telegramId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = data.subscriptions.find((x) => !x?.nextSubscription?.documentId);
|
||||||
|
const remainingDays = subscription ? this.getRemainingDays(subscription) : 0;
|
||||||
|
const hasActiveSubscription = subscription?.active && remainingDays > 0;
|
||||||
|
|
||||||
|
const { maxOrdersPerMonth, remainingOrdersCount } = await this.getRemainingOrdersCount();
|
||||||
|
|
||||||
|
const usedTrialSubscription = await this.usedTrialSubscription();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasActiveSubscription,
|
||||||
|
maxOrdersPerMonth,
|
||||||
|
remainingDays,
|
||||||
|
remainingOrdersCount,
|
||||||
|
subscription,
|
||||||
|
usedTrialSubscription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptionHistory(variables: VariablesOf<typeof GQL.GetSubscriptionHistoryDocument>) {
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetSubscriptionHistoryDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscriptionHistories = result.data.subscriptionHistories;
|
||||||
|
|
||||||
|
return { subscriptionHistories };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptionPrices(variables?: VariablesOf<typeof GQL.GetSubscriptionPricesDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetSubscriptionPricesDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptions(variables?: VariablesOf<typeof GQL.GetSubscriptionsDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetSubscriptionsDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptionSettings() {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { query } = await getClientWithToken();
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
query: GQL.GetSubscriptionSettingsDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscription(variables: VariablesOf<typeof GQL.UpdateSubscriptionDocument>) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { mutate } = await getClientWithToken();
|
||||||
|
|
||||||
|
const mutationResult = await mutate({
|
||||||
|
mutation: GQL.UpdateSubscriptionDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = mutationResult.errors?.at(0);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return mutationResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscriptionHistory(
|
||||||
|
variables: VariablesOf<typeof GQL.UpdateSubscriptionHistoryDocument>,
|
||||||
|
) {
|
||||||
|
await this.checkIsBanned();
|
||||||
|
|
||||||
|
const { mutate } = await getClientWithToken();
|
||||||
|
|
||||||
|
const mutationResult = await mutate({
|
||||||
|
mutation: GQL.UpdateSubscriptionHistoryDocument,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = mutationResult.errors?.at(0);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return mutationResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async usedTrialSubscription() {
|
||||||
|
const { customer } = await this._getUser();
|
||||||
|
|
||||||
|
const { subscriptionHistories } = await this.getSubscriptionHistory({
|
||||||
|
filters: {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
source: {
|
||||||
|
eq: GQL.Enum_Subscriptionhistory_Source.Trial,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: {
|
||||||
|
eq: GQL.Enum_Subscriptionprice_Period.Trial,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
subscription: {
|
||||||
|
customer: {
|
||||||
|
documentId: {
|
||||||
|
eq: customer?.documentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscriptionHistories?.some(
|
||||||
|
(history) => history?.state === GQL.Enum_Subscriptionhistory_State.Success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRemainingDays(subscription: GQL.SubscriptionFieldsFragment) {
|
||||||
|
if (!subscription) return 0;
|
||||||
|
|
||||||
|
const remainingDays = dayjs(subscription?.expiresAt).diff(dayjs(), 'day', true);
|
||||||
|
|
||||||
|
if (remainingDays <= 0) return 0;
|
||||||
|
|
||||||
|
return Math.ceil(remainingDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRemainingOrdersCount() {
|
||||||
|
const ordersService = new OrdersService(this._user);
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
const { orders } = await ordersService.getOrders({
|
||||||
|
filters: {
|
||||||
|
datetime_end: {
|
||||||
|
lte: now.endOf('month').toISOString(),
|
||||||
|
},
|
||||||
|
datetime_start: {
|
||||||
|
gte: now.startOf('month').toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
state: {
|
||||||
|
eq: GQL.Enum_Order_State.Completed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { subscriptionSetting } = await this.getSubscriptionSettings();
|
||||||
|
|
||||||
|
if (!subscriptionSetting) throw new Error(ERRORS.SUBSCRIPTION_SETTING_NOT_FOUND);
|
||||||
|
|
||||||
|
const { maxOrdersPerMonth } = subscriptionSetting;
|
||||||
|
|
||||||
|
let remainingOrdersCount = maxOrdersPerMonth - (orders?.length ?? 0);
|
||||||
|
|
||||||
|
if (remainingOrdersCount < 0) remainingOrdersCount = 0;
|
||||||
|
|
||||||
|
return { maxOrdersPerMonth, remainingOrdersCount };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,10 @@ fragment CustomerFields on Customer {
|
|||||||
photoUrl
|
photoUrl
|
||||||
role
|
role
|
||||||
telegramId
|
telegramId
|
||||||
|
services(filters: { active: { eq: true } }) {
|
||||||
|
documentId
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation CreateCustomer($name: String!, $telegramId: Long, $phone: String) {
|
mutation CreateCustomer($name: String!, $telegramId: Long, $phone: String) {
|
||||||
@ -29,7 +33,19 @@ query GetCustomer($phone: String, $telegramId: Long, $documentId: ID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query GetMasters($phone: String, $telegramId: Long, $documentId: ID) {
|
mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) {
|
||||||
|
updateCustomer(documentId: $documentId, data: $data) {
|
||||||
|
...CustomerFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetCustomers($filters: CustomerFiltersInput, $pagination: PaginationArg, $sort: [String!]) {
|
||||||
|
customers(filters: $filters, pagination: $pagination, sort: $sort) {
|
||||||
|
...CustomerFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetInvitedBy($phone: String, $telegramId: Long, $documentId: ID) {
|
||||||
customers(
|
customers(
|
||||||
filters: {
|
filters: {
|
||||||
or: [
|
or: [
|
||||||
@ -41,13 +57,13 @@ query GetMasters($phone: String, $telegramId: Long, $documentId: ID) {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
documentId
|
documentId
|
||||||
masters {
|
invitedBy {
|
||||||
...CustomerFields
|
...CustomerFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query GetClients($phone: String, $telegramId: Long) {
|
query GetInvited($phone: String, $telegramId: Long) {
|
||||||
customers(
|
customers(
|
||||||
filters: {
|
filters: {
|
||||||
or: [{ phone: { eq: $phone } }, { telegramId: { eq: $telegramId } }]
|
or: [{ phone: { eq: $phone } }, { telegramId: { eq: $telegramId } }]
|
||||||
@ -55,14 +71,8 @@ query GetClients($phone: String, $telegramId: Long) {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
documentId
|
documentId
|
||||||
clients {
|
invited {
|
||||||
...CustomerFields
|
...CustomerFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) {
|
|
||||||
updateCustomer(documentId: $documentId, data: $data) {
|
|
||||||
...CustomerFields
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
101
packages/graphql/operations/subscriptions.graphql
Normal file
101
packages/graphql/operations/subscriptions.graphql
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
fragment SubscriptionFields on Subscription {
|
||||||
|
documentId
|
||||||
|
active
|
||||||
|
expiresAt
|
||||||
|
nextSubscription {
|
||||||
|
documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment SubscriptionHistoryFields on SubscriptionHistory {
|
||||||
|
documentId
|
||||||
|
period
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
state
|
||||||
|
paymentId
|
||||||
|
source
|
||||||
|
description
|
||||||
|
subscription {
|
||||||
|
...SubscriptionFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment SubscriptionSettingFields on SubscriptionSetting {
|
||||||
|
documentId
|
||||||
|
maxOrdersPerMonth
|
||||||
|
referralRewardDays
|
||||||
|
proEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment SubscriptionPriceFields on SubscriptionPrice {
|
||||||
|
documentId
|
||||||
|
period
|
||||||
|
days
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
active
|
||||||
|
description
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment SubscriptionRewardFields on SubscriptionReward {
|
||||||
|
documentId
|
||||||
|
days
|
||||||
|
expiresAt
|
||||||
|
activated
|
||||||
|
description
|
||||||
|
owner {
|
||||||
|
...CustomerFields
|
||||||
|
}
|
||||||
|
invited {
|
||||||
|
...CustomerFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetSubscriptions($filters: SubscriptionFiltersInput) {
|
||||||
|
subscriptions(filters: $filters) {
|
||||||
|
...SubscriptionFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query getSubscriptionSettings {
|
||||||
|
subscriptionSetting {
|
||||||
|
...SubscriptionSettingFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetSubscriptionPrices($filters: SubscriptionPriceFiltersInput) {
|
||||||
|
subscriptionPrices(filters: $filters, sort: "amount:asc") {
|
||||||
|
...SubscriptionPriceFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetSubscriptionHistory($filters: SubscriptionHistoryFiltersInput) {
|
||||||
|
subscriptionHistories(filters: $filters) {
|
||||||
|
...SubscriptionHistoryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation CreateSubscription($data: SubscriptionInput!) {
|
||||||
|
createSubscription(data: $data) {
|
||||||
|
...SubscriptionFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UpdateSubscription($documentId: ID!, $data: SubscriptionInput!) {
|
||||||
|
updateSubscription(documentId: $documentId, data: $data) {
|
||||||
|
...SubscriptionFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation CreateSubscriptionHistory($data: SubscriptionHistoryInput!) {
|
||||||
|
createSubscriptionHistory(data: $data) {
|
||||||
|
...SubscriptionHistoryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UpdateSubscriptionHistory($documentId: ID!, $data: SubscriptionHistoryInput!) {
|
||||||
|
updateSubscriptionHistory(documentId: $documentId, data: $data) {
|
||||||
|
...SubscriptionHistoryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -15,7 +15,7 @@ export function LoadingSpinner({ className, size = 'md', ...props }: LoadingSpin
|
|||||||
'h-12 w-12': size === 'lg',
|
'h-12 w-12': size === 'lg',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Loading...</span>
|
<span className="sr-only">Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,4 @@ export function isCustomerBanned(customer: GQL.CustomerFieldsFragment): boolean
|
|||||||
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
|
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
|
// isCustomerMaster удален - больше не нужен при равенстве пользователей
|
||||||
return customer?.role === GQL.Enum_Customer_Role.Master;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable import/no-unassigned-import */
|
/* eslint-disable import/no-unassigned-import */
|
||||||
import { type OpUnitType } from 'dayjs';
|
import dayjs, { type ConfigType, type OpUnitType } from 'dayjs';
|
||||||
import dayjs, { type ConfigType } from 'dayjs';
|
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import 'dayjs/locale/ru';
|
import 'dayjs/locale/ru';
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -141,6 +141,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 20.19.4
|
version: 20.19.4
|
||||||
|
dayjs:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 1.11.13
|
||||||
grammy:
|
grammy:
|
||||||
specifier: ^1.38.1
|
specifier: ^1.38.1
|
||||||
version: 1.38.1
|
version: 1.38.1
|
||||||
@ -153,6 +156,9 @@ importers:
|
|||||||
pino-pretty:
|
pino-pretty:
|
||||||
specifier: ^13.1.1
|
specifier: ^13.1.1
|
||||||
version: 13.1.1
|
version: 13.1.1
|
||||||
|
radashi:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 12.5.1
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.0(jiti@2.4.1)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0)
|
version: 8.5.0(jiti@2.4.1)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0)
|
||||||
|
|||||||
10
turbo.json
10
turbo.json
@ -6,7 +6,15 @@
|
|||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
"outputs": [".next/**", "!.next/cache/**"],
|
"outputs": [".next/**", "!.next/cache/**"],
|
||||||
"env": ["URL_GRAPHQL", "PASSWORD_GRAPHQL", "LOGIN_GRAPHQL", "BOT_TOKEN", "NEXTAUTH_SECRET"]
|
"env": [
|
||||||
|
"URL_GRAPHQL",
|
||||||
|
"PASSWORD_GRAPHQL",
|
||||||
|
"LOGIN_GRAPHQL",
|
||||||
|
"BOT_TOKEN",
|
||||||
|
"NEXTAUTH_SECRET",
|
||||||
|
"BOT_URL",
|
||||||
|
"BOT_PROVIDER_TOKEN"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": ["^lint"]
|
"dependsOn": ["^lint"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user