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 "BOT_URL=http://localhost:3000" >> .env
|
||||
echo "REDIS_PASSWORD=fake" >> .env
|
||||
echo "BOT_PROVIDER_TOKEN=fake" >> .env
|
||||
|
||||
- name: Set image tags
|
||||
id: vars
|
||||
@ -85,6 +86,7 @@ jobs:
|
||||
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" >> .env
|
||||
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
|
||||
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
|
||||
echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env
|
||||
|
||||
- name: Copy .env to VPS via SCP
|
||||
uses: appleboy/scp-action@master
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
# Общие
|
||||
-support-contact = ℹ️ По всем вопросам и обратной связи: @v_dev_support
|
||||
|
||||
# Описание бота
|
||||
short-description =
|
||||
Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅
|
||||
|
||||
ℹ️ По всем вопросам и обратной связи: @vchikalkin
|
||||
|
||||
{ -support-contact }
|
||||
description =
|
||||
📲 Запишись.онлайн — это бесплатное Telegram-приложение для мастеров и тренеров в вашем смартфоне.
|
||||
📲 Запишись.онлайн — это встроенное в Telegram приложение + бот для мастеров и тренеров в вашем смартфоне.
|
||||
|
||||
Возможности:
|
||||
• 📅 Ведение графика и запись клиентов
|
||||
@ -17,75 +19,59 @@ description =
|
||||
✨ Всё, что нужно — ваш смартфон.
|
||||
|
||||
|
||||
ℹ️ По всем вопросам и обратной связи: @vchikalkin
|
||||
{ -support-contact }
|
||||
|
||||
# Команды
|
||||
start =
|
||||
.description = Запуск бота
|
||||
addcontact =
|
||||
.description = Добавить контакт клиента
|
||||
becomemaster =
|
||||
.description = Стать мастером
|
||||
sharebot =
|
||||
.description = Поделиться ботом
|
||||
subscribe =
|
||||
.description = Приобрести Pro доступ
|
||||
pro =
|
||||
.description = Информация о вашем Pro доступе
|
||||
help =
|
||||
.description = Список команд и поддержка
|
||||
|
||||
commands-list =
|
||||
📋 Доступные команды:
|
||||
• /addcontact — добавить контакт клиента
|
||||
• /becomemaster — стать мастером
|
||||
• /sharebot — поделиться ботом
|
||||
• /subscribe — приобрести Pro доступ
|
||||
• /pro — информация о вашем Pro доступе
|
||||
• /help — список команд
|
||||
|
||||
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
|
||||
|
||||
support =
|
||||
ℹ️ По всем вопросам и обратной связи: @vchikalkin
|
||||
{ -support-contact }
|
||||
|
||||
|
||||
# Приветственные сообщения
|
||||
msg-welcome =
|
||||
👋 Добро пожаловать!
|
||||
Пожалуйста, поделитесь своим номером телефона для регистрации
|
||||
|
||||
msg-welcome-back = 👋 С возвращением, { $name }!
|
||||
|
||||
# Сообщения о статусе мастера
|
||||
msg-not-master =
|
||||
⛔️ Только мастер может добавлять контакты
|
||||
Стать мастером можно на странице профиля в приложении или с помощью команды /becomemaster
|
||||
|
||||
msg-already-master = 🎉 Вы уже являетесь мастером!
|
||||
|
||||
msg-become-master = 🥳 Поздравляем! Теперь вы мастер
|
||||
|
||||
# Сообщения о телефоне
|
||||
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
|
||||
|
||||
msg-phone-saved =
|
||||
✅ Спасибо! Мы сохранили ваш номер телефона
|
||||
Теперь вы можете открыть приложение или воспользоваться командами бота
|
||||
|
||||
msg-already-registered =
|
||||
✅ Вы уже зарегистрированы в системе
|
||||
|
||||
Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
|
||||
|
||||
msg-invalid-phone = ❌ Некорректный номер телефона
|
||||
|
||||
# Сообщения о контактах
|
||||
msg-send-client-contact =
|
||||
👤 Отправьте контакт клиента, которого вы хотите добавить.
|
||||
Для отмены операции используйте команду /cancel
|
||||
|
||||
msg-send-client-contact = 👤 Отправьте контакт клиента, которого вы хотите добавить.
|
||||
msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
|
||||
|
||||
msg-contact-added =
|
||||
✅ Добавили { $name } в список ваших клиентов
|
||||
|
||||
Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи
|
||||
|
||||
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
|
||||
|
||||
# Сообщения для шаринга
|
||||
@ -93,13 +79,32 @@ msg-share-bot =
|
||||
📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
|
||||
Нажмите кнопку ниже, чтобы начать
|
||||
|
||||
|
||||
# Системные сообщения
|
||||
msg-cancel = ❌ Операция отменена
|
||||
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
|
||||
msg-cancel-operation = Для отмены операции используйте команду /cancel
|
||||
|
||||
# Ошибки
|
||||
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
|
||||
err-banned = 🚫 Ваш аккаунт заблокирован
|
||||
err-with-details = ❌ Произошла ошибка
|
||||
{ $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/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"grammy": "^1.38.1",
|
||||
"ioredis": "^5.7.0",
|
||||
"pino": "^9.9.0",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"radashi": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
/* eslint-disable id-length */
|
||||
import { type Context } from '@/bot/context';
|
||||
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 { type Conversation } from '@grammyjs/conversations';
|
||||
import { CustomersService } from '@repo/graphql/api/customers';
|
||||
import { RegistrationService } from '@repo/graphql/api/registration';
|
||||
|
||||
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
|
||||
// Проверяем, что пользователь является мастером
|
||||
// Все пользователи могут добавлять контакты
|
||||
const telegramId = ctx.from?.id;
|
||||
if (!telegramId) {
|
||||
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();
|
||||
@ -62,9 +62,9 @@ export async function addContact(conversation: Conversation<Context, Context>, c
|
||||
if (!documentId) throw new Error('Клиент не создан');
|
||||
}
|
||||
|
||||
// Добавляем текущего мастера к клиенту
|
||||
const masters = [customer.documentId];
|
||||
await customerService.addMasters({ data: { masters }, documentId });
|
||||
// Добавляем текущего пользователя к приглашенному
|
||||
const invitedBy = [customer.documentId];
|
||||
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
|
||||
|
||||
// Отправляем подтверждения и инструкции
|
||||
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
|
||||
|
||||
@ -1 +1,2 @@
|
||||
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 './become-master';
|
||||
export * from './help';
|
||||
export * from './pro';
|
||||
export * from './registration';
|
||||
export * from './share-bot';
|
||||
export * from './subscription';
|
||||
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';
|
||||
|
||||
export const i18n = new I18n<Context>({
|
||||
defaultLocale: 'en',
|
||||
defaultLocale: 'ru',
|
||||
directory: path.resolve(process.cwd(), 'locales'),
|
||||
fluentBundleOptions: {
|
||||
useIsolating: false,
|
||||
|
||||
@ -9,7 +9,7 @@ import { setCommands } from './settings/commands';
|
||||
import { setInfo } from './settings/info';
|
||||
import { env } from '@/config/env';
|
||||
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 { hydrate } from '@grammyjs/hydrate';
|
||||
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) => {
|
||||
await ctx.conversation.exitAll();
|
||||
await ctx.reply(ctx.t('msg-cancel'));
|
||||
@ -53,7 +57,6 @@ export function createBot({ token }: Parameters_) {
|
||||
const protectedBot = bot.errorBoundary(errorHandler);
|
||||
|
||||
protectedBot.use(middlewares.updateLogger());
|
||||
protectedBot.use(autoChatAction(bot.api));
|
||||
protectedBot.use(hydrate());
|
||||
|
||||
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';
|
||||
|
||||
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) {
|
||||
addLocalizations(command);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
BOT_PROVIDER_TOKEN: z.string(),
|
||||
BOT_TOKEN: z.string(),
|
||||
BOT_URL: z.string(),
|
||||
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[]) {
|
||||
return messages.join('\n\n');
|
||||
export function combine(...messages: Array<string | undefined>) {
|
||||
return messages.filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import * as customers from './server/customers';
|
||||
import { wrapClientAction } from '@/utils/actions';
|
||||
|
||||
export const addMasters = wrapClientAction(customers.addMasters);
|
||||
export const getClients = wrapClientAction(customers.getClients);
|
||||
export const addInvitedBy = wrapClientAction(customers.addInvitedBy);
|
||||
export const getInvited = wrapClientAction(customers.getInvited);
|
||||
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);
|
||||
|
||||
@ -6,16 +6,10 @@ import { CustomersService } from '@repo/graphql/api/customers';
|
||||
|
||||
const getService = useService(CustomersService);
|
||||
|
||||
export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
|
||||
export async function addInvitedBy(...variables: Parameters<CustomersService['addInvitedBy']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.addMasters(...variables));
|
||||
}
|
||||
|
||||
export async function getClients(...variables: Parameters<CustomersService['getClients']>) {
|
||||
const service = await getService();
|
||||
|
||||
return wrapServerAction(() => service.getClients(...variables));
|
||||
return wrapServerAction(() => service.addInvitedBy(...variables));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export async function getMasters(...variables: Parameters<CustomersService['getMasters']>) {
|
||||
export async function getCustomers(...variables: Parameters<CustomersService['getCustomers']>) {
|
||||
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']>) {
|
||||
|
||||
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', {
|
||||
callbackUrl: '/profile',
|
||||
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', {
|
||||
callbackUrl: '/profile',
|
||||
redirect: false,
|
||||
telegramId: initDataUser.id,
|
||||
}).then(() => redirect('/profile'));
|
||||
telegramId: initDataUser.id.toString(),
|
||||
}).then((result) => {
|
||||
if (
|
||||
result?.error &&
|
||||
(result?.error?.includes('CredentialsSignin') || result?.error?.includes('UNREGISTERED'))
|
||||
) {
|
||||
// Пользователь не зарегистрирован
|
||||
redirect('/unregistered');
|
||||
} else if (result?.ok) {
|
||||
redirect('/profile');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [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 { Card } from '@repo/ui/components/ui/card';
|
||||
|
||||
@ -8,7 +8,7 @@ export default function ContactsPage() {
|
||||
<Card>
|
||||
<div className="flex flex-row items-center justify-between space-x-4 p-4">
|
||||
<h1 className="font-bold">Контакты</h1>
|
||||
<ContactsFilter />
|
||||
{/* <ContactsFilter /> */}
|
||||
</div>
|
||||
<div className="p-4 pt-0">
|
||||
<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 { PageHeader } from '@/components/navigation';
|
||||
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
|
||||
import { BookButton } from '@/components/shared/book-button';
|
||||
import { isCustomerMaster } from '@repo/utils/customer';
|
||||
import { ReadonlyServicesList } from '@/components/profile/services';
|
||||
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;
|
||||
|
||||
// Определяем роли и id
|
||||
const isMaster = isCustomerMaster(currentUser);
|
||||
const masterId = isMaster ? currentUser.documentId : profile.documentId;
|
||||
const clientId = isMaster ? profile.documentId : currentUser.documentId;
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PageHeader title="Профиль контакта" />
|
||||
<Container className="px-0">
|
||||
<PersonCard telegramId={contactTelegramId} />
|
||||
<ContactDataCard telegramId={contactTelegramId} />
|
||||
<ReadonlyServicesList masterId={profile.documentId} />
|
||||
<ProfileOrdersList telegramId={contactTelegramId} />
|
||||
{masterId && clientId && (
|
||||
<BookButton
|
||||
clientId={clientId}
|
||||
label={isMaster ? 'Записать' : 'Записаться'}
|
||||
masterId={masterId}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { getCustomer } from '@/actions/api/customers';
|
||||
import { getSubscriptionSettings } from '@/actions/api/subscriptions';
|
||||
import { getSessionUser } from '@/actions/session';
|
||||
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';
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const { telegramId } = await getSessionUser();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
@ -14,10 +14,18 @@ export default async function ProfilePage() {
|
||||
queryKey: ['customer', telegramId],
|
||||
});
|
||||
|
||||
const { subscriptionSetting } = await queryClient.fetchQuery({
|
||||
queryFn: getSubscriptionSettings,
|
||||
queryKey: ['customer', telegramId],
|
||||
});
|
||||
|
||||
const proEnabled = subscriptionSetting?.proEnabled;
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Container className="px-0">
|
||||
<PersonCard />
|
||||
{proEnabled && <SubscriptionInfoBar />}
|
||||
<ProfileDataCard />
|
||||
<LinksCard />
|
||||
</Container>
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { getCustomer } from '@/actions/api/customers';
|
||||
import { getSlot } from '@/actions/api/slots';
|
||||
import { getSessionUser } from '@/actions/session';
|
||||
import { Container } from '@/components/layout';
|
||||
import { PageHeader } from '@/components/navigation';
|
||||
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
|
||||
@ -21,22 +19,13 @@ export default async function SlotPage(props: Readonly<Props>) {
|
||||
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 (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PageHeader title="Слот" />
|
||||
<Container>
|
||||
<SlotDateTime {...parameters} />
|
||||
<SlotOrdersList {...parameters} />
|
||||
{masterId && <BookButton label="Создать запись" masterId={masterId} />}
|
||||
<BookButton label="Создать запись" />
|
||||
<div className="pb-24" />
|
||||
<SlotButtons {...parameters} />
|
||||
</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 { 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';
|
||||
|
||||
export function ContactsList() {
|
||||
const { contacts, isLoading } = useCustomerContacts();
|
||||
const {
|
||||
data: { pages } = {},
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
} = useContactsInfiniteQuery();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
|
||||
const contacts = pages?.flatMap((page) => page.customers);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{contacts.map((contact) => (
|
||||
<ContactRow key={contact.documentId} {...contact} />
|
||||
))}
|
||||
<div className="flex flex-col space-y-2">
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ import { use } from 'react';
|
||||
|
||||
const filterLabels: Record<FilterType, string> = {
|
||||
all: 'Все',
|
||||
clients: 'Клиенты',
|
||||
masters: 'Мастера',
|
||||
invited: 'Приглашенные',
|
||||
invitedBy: 'Пригласили вас',
|
||||
};
|
||||
|
||||
export function ContactsFilter() {
|
||||
@ -29,9 +29,13 @@ export function ContactsFilter() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setFilter('all')}>Все</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilter('clients')}>Клиенты</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilter('masters')}>Мастера</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilter('all')}>{filterLabels['all']}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilter('invited')}>
|
||||
{filterLabels['invited']}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilter('invitedBy')}>
|
||||
{filterLabels['invitedBy']}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@ -4,12 +4,16 @@ import { NavButton } from './nav-button';
|
||||
import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const hideOn = ['/pro'];
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isFirstLevel = pathname.split('/').length <= 2;
|
||||
if (!isFirstLevel) return null;
|
||||
|
||||
if (hideOn.includes(pathname)) return null;
|
||||
|
||||
return (
|
||||
<nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background">
|
||||
<div className="grid grid-cols-5">
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
/* eslint-disable complexity */
|
||||
/* eslint-disable canonical/id-match */
|
||||
'use client';
|
||||
|
||||
import FloatingActionPanel from '../shared/action-panel';
|
||||
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 { usePushWithData } from '@/hooks/url';
|
||||
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>) {
|
||||
const push = usePushWithData();
|
||||
|
||||
const isMaster = useIsMaster();
|
||||
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
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 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;
|
||||
|
||||
function handleApprove() {
|
||||
if (isMaster) {
|
||||
updateSlot({ data: { state: Enum_Order_State.Approved } });
|
||||
if (isOrderMaster) {
|
||||
updateOrder({ data: { state: Enum_Order_State.Approved } });
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isMaster) {
|
||||
updateSlot({ data: { state: Enum_Order_State.Cancelled } });
|
||||
} else {
|
||||
updateSlot({ data: { state: Enum_Order_State.Cancelling } });
|
||||
if (isOrderMaster) {
|
||||
updateOrder({ data: { state: Enum_Order_State.Cancelled } });
|
||||
} else if (isOrderClient) {
|
||||
updateOrder({ data: { state: Enum_Order_State.Cancelling } });
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnComplete() {
|
||||
if (isMaster) {
|
||||
updateSlot({ data: { state: Enum_Order_State.Completed } });
|
||||
if (isOrderMaster) {
|
||||
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 canCancel = !isOrderStale && (isCreated || (isMaster && isCancelling) || isApproved);
|
||||
const canComplete = isMaster && isApproved;
|
||||
const canConfirm = !isOrderStale && isMaster && isCreated;
|
||||
const canCancel = !isOrderStale && (isCreated || (isOrderMaster && isCancelling) || isApproved);
|
||||
const canComplete = isOrderMaster && isApproved;
|
||||
const canConfirm = !isOrderStale && isOrderMaster && isCreated;
|
||||
const canRepeat = isCancelled || isCompleted;
|
||||
const canReturn = !isOrderStale && isMaster && isCancelled;
|
||||
const canReturn = !isOrderStale && isOrderMaster && isCancelled;
|
||||
|
||||
return (
|
||||
<FloatingActionPanel
|
||||
|
||||
@ -10,17 +10,19 @@ export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="font-bold">Контакты</h1>
|
||||
<h1 className="font-bold">Участники</h1>
|
||||
<div className="space-y-2">
|
||||
{order.slot?.master && (
|
||||
<ContactRow
|
||||
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
|
||||
description="Мастер"
|
||||
{...order.slot?.master}
|
||||
/>
|
||||
)}
|
||||
{order.client && (
|
||||
<ContactRow
|
||||
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
|
||||
description="Клиент"
|
||||
{...order.client}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,129 +1,167 @@
|
||||
/* eslint-disable canonical/id-match */
|
||||
'use client';
|
||||
|
||||
import { DataNotFound } from '@/components/shared/alert';
|
||||
import { UserAvatar } from '@/components/shared/user-avatar';
|
||||
import { CardSectionHeader } from '@/components/ui';
|
||||
import { ContactsContextProvider } from '@/context/contacts';
|
||||
import { useCustomerContacts } from '@/hooks/api/contacts';
|
||||
// eslint-disable-next-line import/extensions
|
||||
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
|
||||
import { useContactsInfiniteQuery, useCustomerQuery } from '@/hooks/api/customers';
|
||||
import { useOrderStore } from '@/stores/order';
|
||||
import { withContext } from '@/utils/context';
|
||||
import { type CustomerFieldsFragment } from '@repo/graphql/types';
|
||||
import { type CustomerFieldsFragment, Enum_Customer_Role } from '@repo/graphql/types';
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import { Card } from '@repo/ui/components/ui/card';
|
||||
import { Label } from '@repo/ui/components/ui/label';
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
import { cn } from '@repo/ui/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { useEffect } from 'react';
|
||||
import { sift } from 'radashi';
|
||||
|
||||
type ContactsGridProps = {
|
||||
readonly contacts: CustomerFieldsFragment[];
|
||||
readonly hasNextPage?: boolean;
|
||||
readonly isLoading?: boolean;
|
||||
readonly onClick: () => void;
|
||||
readonly onFetchNextPage?: () => void;
|
||||
readonly onSelect: (contactId: null | string) => void;
|
||||
readonly selected?: null | 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 (
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">
|
||||
{contacts.map((contact) => {
|
||||
if (!contact) return null;
|
||||
{!isLoading &&
|
||||
contacts?.map((contact) => {
|
||||
if (!contact) return null;
|
||||
|
||||
const isCurrentUser = contact?.name === 'Я';
|
||||
const isCurrentUser = contact.documentId === customer?.documentId;
|
||||
|
||||
return (
|
||||
<Label
|
||||
className="flex cursor-pointer flex-col items-center"
|
||||
key={contact?.documentId}
|
||||
>
|
||||
<input
|
||||
checked={selected === contact?.documentId}
|
||||
className="hidden"
|
||||
name="user"
|
||||
onChange={() => onSelect(contact?.documentId)}
|
||||
type="radio"
|
||||
value={contact?.documentId}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'w-20 h-20 rounded-full border-2 transition-all duration-75',
|
||||
selected === contact?.documentId ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
return (
|
||||
<Label
|
||||
className="flex cursor-pointer flex-col items-center"
|
||||
key={contact.documentId}
|
||||
>
|
||||
<input
|
||||
checked={selected === contact.documentId}
|
||||
className="hidden"
|
||||
name="user"
|
||||
onChange={() => onSelect(contact.documentId)}
|
||||
onClick={onClick}
|
||||
type="radio"
|
||||
value={contact.documentId}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'size-full rounded-full p-1',
|
||||
isCurrentUser
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500'
|
||||
: 'bg-transparent',
|
||||
'rounded-full border-2 transition-all duration-75',
|
||||
selected === contact.documentId ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
alt={contact?.name}
|
||||
className="size-full rounded-full object-cover"
|
||||
height={80}
|
||||
src={contact?.photoUrl || AvatarPlaceholder}
|
||||
width={80}
|
||||
/>
|
||||
<UserAvatar {...contact} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-2 max-w-20 break-words text-center text-sm font-medium',
|
||||
isCurrentUser && 'font-bold',
|
||||
)}
|
||||
>
|
||||
{contact?.name}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
<span
|
||||
className={cn(
|
||||
'mt-2 max-w-20 break-words text-center text-sm font-medium',
|
||||
isCurrentUser && 'font-bold',
|
||||
)}
|
||||
>
|
||||
{contact.name}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasNextPage && onFetchNextPage && (
|
||||
<Button onClick={onFetchNextPage} variant="ghost">
|
||||
Загрузить еще
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const MastersGrid = withContext(ContactsContextProvider)(function () {
|
||||
const { contacts, isLoading, setFilter } = useCustomerContacts();
|
||||
export function MastersGrid() {
|
||||
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
|
||||
|
||||
const masterId = useOrderStore((store) => store.masterId);
|
||||
const setMasterId = useOrderStore((store) => store.setMasterId);
|
||||
|
||||
useEffect(() => {
|
||||
setFilter('masters');
|
||||
}, [setFilter]);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
const clientId = useOrderStore((store) => store.clientId);
|
||||
|
||||
return (
|
||||
<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)}
|
||||
selected={masterId}
|
||||
title="Мастера"
|
||||
title="Выбор мастера"
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const ClientsGrid = withContext(ContactsContextProvider)(function () {
|
||||
const { contacts, isLoading, setFilter } = useCustomerContacts();
|
||||
const clientId = useOrderStore((store) => store.clientId);
|
||||
const setClientId = useOrderStore((store) => store.setClientId);
|
||||
function useContacts() {
|
||||
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
|
||||
|
||||
useEffect(() => {
|
||||
setFilter('clients');
|
||||
}, [setFilter]);
|
||||
const {
|
||||
data: { pages } = { pages: [] },
|
||||
isLoading: isLoadingContacts,
|
||||
...query
|
||||
} = useContactsInfiniteQuery();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
const isLoading = isLoadingContacts || isLoadingCustomer;
|
||||
|
||||
return (
|
||||
<ContactsGridBase
|
||||
contacts={contacts}
|
||||
onSelect={(contactId) => setClientId(contactId)}
|
||||
selected={clientId}
|
||||
title="Клиенты"
|
||||
/>
|
||||
const contacts = sift(
|
||||
pages.flatMap((page) => page.customers).filter((contact) => Boolean(contact && contact.active)),
|
||||
);
|
||||
});
|
||||
|
||||
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 { useOrderStore } from '@/stores/order';
|
||||
import { type ServiceFieldsFragment } from '@repo/graphql/types';
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
import { cn } from '@repo/ui/lib/utils';
|
||||
|
||||
export function ServicesSelect() {
|
||||
const masterId = useOrderStore((store) => store.masterId);
|
||||
|
||||
const { data: { services } = {} } = useServicesQuery({
|
||||
const { data: { services } = {}, isLoading } = useServicesQuery({
|
||||
filters: {
|
||||
active: {
|
||||
eq: true,
|
||||
@ -24,6 +25,8 @@ export function ServicesSelect() {
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
if (!services?.length) return <DataNotFound title="Услуги не найдены" />;
|
||||
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||
import { useOrdersQuery } from '@/hooks/api/orders';
|
||||
import { useDateTimeStore } from '@/stores/datetime';
|
||||
import { Calendar } from '@repo/ui/components/ui/calendar';
|
||||
@ -11,35 +11,25 @@ import { useMemo, useState } from 'react';
|
||||
export function DateSelect() {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
|
||||
const isMaster = useIsMaster();
|
||||
|
||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||
|
||||
const clientId = isMaster ? undefined : customer?.documentId;
|
||||
const masterId = isMaster ? customer?.documentId : undefined;
|
||||
|
||||
const { data: { orders } = { orders: [] } } = useOrdersQuery(
|
||||
{
|
||||
filters: {
|
||||
client: {
|
||||
documentId: {
|
||||
eq: clientId,
|
||||
},
|
||||
},
|
||||
// Показываем все записи где пользователь является клиентом или мастером
|
||||
or: [
|
||||
{ client: { documentId: { eq: customer?.documentId } } },
|
||||
{ slot: { master: { documentId: { eq: customer?.documentId } } } },
|
||||
],
|
||||
slot: {
|
||||
datetime_start: {
|
||||
gte: dayjs(currentMonthDate).startOf('month').toISOString(),
|
||||
lte: dayjs(currentMonthDate).endOf('month').toISOString(),
|
||||
},
|
||||
master: {
|
||||
documentId: {
|
||||
eq: masterId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Boolean(clientId) || Boolean(masterId),
|
||||
Boolean(customer?.documentId),
|
||||
);
|
||||
|
||||
const daysWithOrders = useMemo(() => {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { OrdersList as OrdersListComponent } from './orders-list';
|
||||
import { useCurrentAndNext } from './utils';
|
||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
|
||||
import { useDateTimeStore } from '@/stores/datetime';
|
||||
import type * as GQL from '@repo/graphql/types';
|
||||
@ -10,7 +10,6 @@ import { getDateUTCRange } from '@repo/utils/datetime-format';
|
||||
|
||||
export function OrdersList() {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
const isMaster = useIsMaster();
|
||||
const selectedDate = useDateTimeStore((store) => store.date);
|
||||
const { endOfDay, startOfDay } = getDateUTCRange(selectedDate).day();
|
||||
|
||||
@ -22,10 +21,13 @@ export function OrdersList() {
|
||||
} = useOrdersInfiniteQuery(
|
||||
{
|
||||
filters: {
|
||||
client: isMaster ? undefined : { documentId: { eq: customer?.documentId } },
|
||||
// Показываем все записи где пользователь является клиентом или мастером
|
||||
or: [
|
||||
{ client: { documentId: { eq: customer?.documentId } } },
|
||||
{ slot: { master: { documentId: { eq: customer?.documentId } } } },
|
||||
],
|
||||
slot: {
|
||||
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);
|
||||
|
||||
if (!orders?.length || isLoading) return null;
|
||||
|
||||
return (
|
||||
<OrdersListComponent
|
||||
avatarSource={isMaster ? 'client' : 'master'}
|
||||
current={current}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
next={next}
|
||||
onLoadMore={() => fetchNextPage()}
|
||||
onLoadMore={fetchNextPage}
|
||||
orders={orders}
|
||||
title={isMaster ? 'Записи клиентов' : 'Ваши записи'}
|
||||
title={selectedDate ? `Записи на ${selectedDate.toLocaleDateString()}` : 'Все записи'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { getOrderStatus, getStatusText, type OrderStatus } from './utils';
|
||||
import { DataNotFound } from '@/components/shared/alert';
|
||||
import { OrderCard } from '@/components/shared/order-card';
|
||||
import type * as GQL from '@repo/graphql/types';
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
import { cn } from '@repo/ui/lib/utils';
|
||||
|
||||
type Order = GQL.OrderFieldsFragment;
|
||||
|
||||
type OrdersListProps = Pick<Parameters<typeof OrderCard>[0], 'avatarSource'> & {
|
||||
type OrdersListProps = {
|
||||
readonly current: null | Order;
|
||||
readonly hasNextPage?: boolean;
|
||||
readonly isLoading?: boolean;
|
||||
readonly next: null | Order;
|
||||
readonly onLoadMore?: () => void;
|
||||
readonly orders: Order[];
|
||||
@ -16,9 +19,9 @@ type OrdersListProps = Pick<Parameters<typeof OrderCard>[0], 'avatarSource'> & {
|
||||
};
|
||||
|
||||
export function OrdersList({
|
||||
avatarSource,
|
||||
current,
|
||||
hasNextPage = false,
|
||||
isLoading,
|
||||
next,
|
||||
onLoadMore,
|
||||
orders,
|
||||
@ -27,6 +30,8 @@ export function OrdersList({
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="font-bold">{title}</h1>
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!isLoading && !orders.length ? <DataNotFound title="Заказы не найдены" /> : null}
|
||||
{orders?.map((order) => {
|
||||
if (!order) return null;
|
||||
|
||||
@ -34,7 +39,7 @@ export function OrdersList({
|
||||
|
||||
return (
|
||||
<DateStatusWrapper key={order.documentId} status={status}>
|
||||
<OrderCard avatarSource={avatarSource} showDate {...order} />
|
||||
<OrderCard showDate {...order} />
|
||||
</DateStatusWrapper>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
/* eslint-disable canonical/id-match */
|
||||
'use client';
|
||||
|
||||
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 { useCustomerMutation, useCustomerQuery } from '@/hooks/api/customers';
|
||||
import { Enum_Customer_Role } from '@repo/graphql/types';
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import { Card } from '@repo/ui/components/ui/card';
|
||||
import Link from 'next/link';
|
||||
@ -49,14 +51,14 @@ export function ProfileDataCard() {
|
||||
value={customer?.name ?? ''}
|
||||
/>
|
||||
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
|
||||
{/* <CheckboxWithText
|
||||
<CheckboxWithText
|
||||
checked={customer.role !== 'client'}
|
||||
description="Разрешить другим пользователям записываться к вам"
|
||||
onChange={(checked) =>
|
||||
updateField('role', checked ? Role.Master : Role.Client)
|
||||
updateField('role', checked ? Enum_Customer_Role.Master : Enum_Customer_Role.Client)
|
||||
}
|
||||
text="Быть мастером"
|
||||
/> */}
|
||||
/>
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button disabled={isPending} onClick={cancelChanges} variant="outline">
|
||||
|
||||
@ -2,3 +2,4 @@ export * from './data-card';
|
||||
export * from './links-card';
|
||||
export * from './orders-list';
|
||||
export * from './person-card';
|
||||
export * from './subscription-bar';
|
||||
|
||||
@ -1,24 +1,26 @@
|
||||
/* eslint-disable canonical/id-match */
|
||||
'use client';
|
||||
|
||||
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() {
|
||||
const isMaster = useIsMaster();
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
|
||||
if (customer?.role === Enum_Customer_Role.Client) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 py-0">
|
||||
<LinkButton
|
||||
description="Указать доступные дни и время для записи клиентов"
|
||||
description="Указать доступные дни и время для записи"
|
||||
href="/profile/schedule"
|
||||
text="График работы"
|
||||
visible={isMaster}
|
||||
/>
|
||||
<LinkButton
|
||||
description="Добавить и редактировать ваши услуги мастера"
|
||||
description="Добавить и редактировать ваши услуги"
|
||||
href="/profile/services"
|
||||
text="Услуги"
|
||||
visible={isMaster}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,12 +4,9 @@ type Props = {
|
||||
readonly description?: string;
|
||||
readonly href: string;
|
||||
readonly text: string;
|
||||
readonly visible?: boolean;
|
||||
};
|
||||
|
||||
export function LinkButton({ description, href, text, visible }: Props) {
|
||||
if (!visible) return null;
|
||||
|
||||
export function LinkButton({ description, href, text }: Props) {
|
||||
return (
|
||||
<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">
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { DataNotFound } from '../shared/alert';
|
||||
import { OrderCard } from '../shared/order-card';
|
||||
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 { Button } from '@repo/ui/components/ui/button';
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
|
||||
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
const isMaster = useIsMaster();
|
||||
|
||||
const { data: { customer: profile } = {} } = useCustomerQuery({ telegramId });
|
||||
|
||||
const {
|
||||
@ -20,18 +20,17 @@ export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
||||
} = useOrdersInfiniteQuery(
|
||||
{
|
||||
filters: {
|
||||
client: {
|
||||
documentId: {
|
||||
eq: isMaster ? profile?.documentId : customer?.documentId,
|
||||
// Показываем все записи между текущим пользователем и профилем
|
||||
or: [
|
||||
{
|
||||
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) },
|
||||
@ -39,22 +38,12 @@ export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
||||
|
||||
const orders = pages?.flatMap((page) => page.orders) ?? [];
|
||||
|
||||
if (!orders?.length || isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 px-4">
|
||||
<h1 className="font-bold">Недавние записи</h1>
|
||||
{orders?.map(
|
||||
(order) =>
|
||||
order && (
|
||||
<OrderCard
|
||||
avatarSource={isMaster ? 'master' : 'client'}
|
||||
key={order.documentId}
|
||||
showDate
|
||||
{...order}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!isLoading && !orders.length ? <DataNotFound title="Записи не найдены" /> : null}
|
||||
{orders?.map((order) => order && <OrderCard key={order.documentId} showDate {...order} />)}
|
||||
{hasNextPage && (
|
||||
<Button onClick={() => fetchNextPage()} variant="ghost">
|
||||
Загрузить еще
|
||||
|
||||
@ -1,28 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { type ProfileProps } from './types';
|
||||
import { UserAvatar } from '@/components/shared/user-avatar';
|
||||
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 { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
|
||||
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
|
||||
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
|
||||
|
||||
if (isLoading || !customer)
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="p-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!customer) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent p-4 shadow-none">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Avatar className="size-20">
|
||||
<AvatarImage alt={customer?.name} src={customer.photoUrl || ''} />
|
||||
<AvatarFallback>{customer?.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar {...customer} size="lg" />
|
||||
<h2 className="text-2xl font-bold">{customer?.name}</h2>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -1,27 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { DataNotFound } from '@/components/shared/alert';
|
||||
import { ServiceCard } from '@/components/shared/service-card';
|
||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||
import { useServicesQuery } from '@/hooks/api/services';
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ServicesList() {
|
||||
const { data: { customer } = {}, isLoading } = useCustomerQuery();
|
||||
type MasterServicesListProps = {
|
||||
masterId: string;
|
||||
};
|
||||
|
||||
const { data: { services } = {} } = useServicesQuery({
|
||||
filters: {
|
||||
master: {
|
||||
documentId: {
|
||||
eq: customer?.documentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !customer) return null;
|
||||
// Компонент для отображения услуг мастера (без ссылок, только просмотр)
|
||||
export function ReadonlyServicesList({ masterId }: Readonly<MasterServicesListProps>) {
|
||||
const { isLoading, services } = useServices(masterId);
|
||||
|
||||
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(
|
||||
(service) =>
|
||||
service && (
|
||||
@ -35,3 +53,25 @@ export function ServicesList() {
|
||||
</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 { data: { slots } = {} } = useSlotsQuery({
|
||||
const { data: { slots } = {}, isLoading } = useSlotsQuery({
|
||||
filters: {
|
||||
datetime_start: {
|
||||
gte: startOfMonth,
|
||||
@ -41,6 +41,7 @@ export function ScheduleCalendar() {
|
||||
return (
|
||||
<Calendar
|
||||
className="bg-background"
|
||||
disabled={isLoading}
|
||||
// disabled={(date) => {
|
||||
// return dayjs().isAfter(dayjs(date), 'day');
|
||||
// }}
|
||||
|
||||
@ -8,7 +8,6 @@ import { useMasterSlotsQuery } from '@/hooks/api/slots';
|
||||
import { useDateTimeStore } from '@/stores/datetime';
|
||||
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
|
||||
import { getDateUTCRange, isNowOrAfter } from '@repo/utils/datetime-format';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export function DaySlotsList() {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
@ -22,30 +21,15 @@ export function DaySlotsList() {
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const isSelectedDateTodayOrAfter = selectedDate && isNowOrAfter(selectedDate);
|
||||
|
||||
if (!slots?.length) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<DataNotFound title="Слоты не найдены" />
|
||||
{isSelectedDateTodayOrAfter && <DaySlotAddForm />}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="flex flex-col space-y-2 px-4">
|
||||
<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 />}
|
||||
</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';
|
||||
|
||||
import { DataNotFound } from '../shared/alert';
|
||||
import { type SlotComponentProps } from './types';
|
||||
import { OrderCard } from '@/components/shared/order-card';
|
||||
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 (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="font-bold">Записи</h1>
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!isLoading && !orders?.length ? <DataNotFound title="Записи не найдены" /> : null}
|
||||
{orders?.map((order) => order && <OrderCard key={order.documentId} {...order} />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,34 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||
import { usePushWithData } from '@/hooks/url';
|
||||
import { CalendarPlus } from 'lucide-react';
|
||||
|
||||
type BookButtonProps = {
|
||||
clientId?: string;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
masterId: string;
|
||||
onBooked?: () => void;
|
||||
};
|
||||
|
||||
export function BookButton({
|
||||
clientId,
|
||||
disabled,
|
||||
label,
|
||||
masterId,
|
||||
onBooked,
|
||||
}: Readonly<BookButtonProps>) {
|
||||
export function BookButton({ disabled, label, onBooked }: Readonly<BookButtonProps>) {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
const masterId = customer?.documentId;
|
||||
const push = usePushWithData();
|
||||
|
||||
const handleBook = () => {
|
||||
push('/orders/add', {
|
||||
...(clientId && { client: { documentId: clientId } }),
|
||||
slot: { master: { documentId: masterId } },
|
||||
});
|
||||
onBooked?.();
|
||||
};
|
||||
|
||||
if (!masterId && !clientId) return null;
|
||||
if (!masterId) return null;
|
||||
|
||||
return (
|
||||
<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 { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
|
||||
import { Badge } from '@repo/ui/components/ui/badge';
|
||||
import { cn } from '@repo/ui/lib/utils';
|
||||
import { isCustomerMaster } from '@repo/utils/customer';
|
||||
import Link from 'next/link';
|
||||
import { memo } from 'react';
|
||||
|
||||
type ContactRowProps = GQL.CustomerFieldsFragment & {
|
||||
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 (
|
||||
<Link
|
||||
className="block"
|
||||
@ -24,16 +25,13 @@ export const ContactRow = memo(function ({ className, ...contact }: ContactRowPr
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center space-x-4 rounded-lg py-2 transition-colors')}>
|
||||
<Avatar>
|
||||
<AvatarImage alt={contact.name} src={contact.photoUrl || ''} />
|
||||
<AvatarFallback>{contact.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
|
||||
<UserAvatar {...contact} size="sm" />
|
||||
<div>
|
||||
<p className="font-medium">{contact.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isCustomerMaster(contact) ? 'Мастер' : 'Клиент'}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
|
||||
|
||||
@ -1,39 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ReadonlyTimeRange } from './time-range/readonly';
|
||||
import { UserAvatar } from './user-avatar';
|
||||
import { getBadge } from '@/components/shared/status';
|
||||
import { useCustomerQuery } from '@/hooks/api/customers';
|
||||
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 Link from 'next/link';
|
||||
|
||||
type OrderComponentProps = GQL.OrderFieldsFragment & {
|
||||
avatarSource?: 'client' | 'master';
|
||||
showDate?: boolean;
|
||||
};
|
||||
|
||||
type OrderCustomer = GQL.CustomerFieldsFragment;
|
||||
|
||||
export function OrderCard({
|
||||
avatarSource,
|
||||
documentId,
|
||||
showDate,
|
||||
...order
|
||||
}: Readonly<OrderComponentProps>) {
|
||||
export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComponentProps>) {
|
||||
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 (
|
||||
<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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<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">
|
||||
<ReadonlyTimeRange
|
||||
datetimeEnd={order?.datetime_end}
|
||||
@ -53,14 +51,3 @@ export function OrderCard({
|
||||
</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 CredentialsProvider from 'next-auth/providers/credentials';
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
if (user?.telegramId) {
|
||||
token.telegramId = user.telegramId;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token?.id && session?.user) {
|
||||
session.user.telegramId = token.id as number;
|
||||
if (token?.telegramId && session?.user) {
|
||||
session.user.telegramId = token.telegramId as number;
|
||||
}
|
||||
|
||||
return session;
|
||||
@ -27,7 +29,34 @@ export const authOptions: AuthOptions = {
|
||||
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: {
|
||||
telegramId: { label: 'Telegram ID', type: 'text' },
|
||||
|
||||
@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
__DEV_TELEGRAM_ID: z.string().default(''),
|
||||
BOT_TOKEN: z.string(),
|
||||
BOT_URL: z.string(),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
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 };
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
import { getCustomer, updateCustomer } from '@/actions/api/customers';
|
||||
import { isCustomerBanned, isCustomerMaster } from '@repo/utils/customer';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCustomer, getCustomers, updateCustomer } from '@/actions/api/customers';
|
||||
import { isCustomerBanned } from '@repo/utils/customer';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0]) => {
|
||||
const { data: session } = useSession();
|
||||
const telegramId = variables?.telegramId || session?.user?.telegramId;
|
||||
const telegramId =
|
||||
variables?.telegramId === undefined ? session?.user?.telegramId : variables?.telegramId;
|
||||
|
||||
return useQuery({
|
||||
enabled: Boolean(telegramId),
|
||||
@ -16,12 +17,42 @@ export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0])
|
||||
});
|
||||
};
|
||||
|
||||
export const useIsMaster = () => {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
export const useCustomersQuery = (
|
||||
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 = () => {
|
||||
@ -50,3 +81,38 @@ export const useCustomerMutation = () => {
|
||||
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 = {
|
||||
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';
|
||||
import { useOrderStore } from './context';
|
||||
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 { sift } from 'radashi';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const STEPS: Steps[] = [
|
||||
// Унифицированные шаги для всех пользователей
|
||||
const UNIFIED_STEPS: Steps[] = [
|
||||
'master-select',
|
||||
'client-select',
|
||||
'service-select',
|
||||
'datetime-select',
|
||||
'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) {
|
||||
const initialized = useRef(false);
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
const isMaster = useIsMaster();
|
||||
|
||||
const setMasterId = useOrderStore((store) => store.setMasterId);
|
||||
const setClientId = useOrderStore((store) => store.setClientId);
|
||||
@ -32,8 +30,7 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
useEffect(() => {
|
||||
if (initialized.current || !customer || step !== 'loading') return;
|
||||
|
||||
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
|
||||
setStepsSequence(steps);
|
||||
setStepsSequence(UNIFIED_STEPS);
|
||||
|
||||
// Инициализация из initData (например, для повторного заказа)
|
||||
if (initData) {
|
||||
@ -49,25 +46,19 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
setStep('datetime-select');
|
||||
} else if (masterId && clientId) {
|
||||
setStep('service-select');
|
||||
} else if (masterId) {
|
||||
setStep('client-select');
|
||||
} else {
|
||||
setStep(steps[0] ?? 'loading');
|
||||
setStep(UNIFIED_STEPS[0] ?? 'loading');
|
||||
}
|
||||
} else {
|
||||
// Обычная инициализация (новый заказ)
|
||||
if (isMaster) {
|
||||
setMasterId(customer.documentId);
|
||||
} else {
|
||||
setClientId(customer.documentId);
|
||||
}
|
||||
|
||||
setStep(steps[0] ?? 'loading');
|
||||
setStep(UNIFIED_STEPS[0] ?? 'loading');
|
||||
}
|
||||
|
||||
initialized.current = true;
|
||||
}, [
|
||||
customer,
|
||||
initData,
|
||||
isMaster,
|
||||
setClientId,
|
||||
setMasterId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
telegramId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ services:
|
||||
- .env
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/']
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
/* eslint-disable canonical/id-match */
|
||||
import { getClientWithToken } from '../apollo/client';
|
||||
import { ERRORS } from '../constants/errors';
|
||||
import { ERRORS as SHARED_ERRORS } from '../constants/errors';
|
||||
import * as GQL from '../types';
|
||||
import { isCustomerBanned } from '@repo/utils/customer';
|
||||
|
||||
const BASE_ERRORS = {
|
||||
export const ERRORS = {
|
||||
MISSING_TELEGRAM_ID: 'Не указан Telegram ID',
|
||||
NOT_FOUND_CUSTOMER: 'Пользователь не найден',
|
||||
} as const;
|
||||
@ -18,7 +18,7 @@ export class BaseService {
|
||||
|
||||
constructor(user: UserProfile) {
|
||||
if (!user?.telegramId) {
|
||||
throw new Error(BASE_ERRORS.MISSING_TELEGRAM_ID);
|
||||
throw new Error(ERRORS.MISSING_TELEGRAM_ID);
|
||||
}
|
||||
|
||||
this._user = user;
|
||||
@ -34,10 +34,10 @@ export class BaseService {
|
||||
|
||||
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)) {
|
||||
throw new Error(ERRORS.NO_PERMISSION);
|
||||
throw new Error(SHARED_ERRORS.NO_PERMISSION);
|
||||
}
|
||||
|
||||
return { customer };
|
||||
@ -53,8 +53,10 @@ export class BaseService {
|
||||
|
||||
const customer = result.data.customers.at(0);
|
||||
|
||||
if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER);
|
||||
|
||||
if (customer && isCustomerBanned(customer)) {
|
||||
throw new Error(ERRORS.NO_PERMISSION);
|
||||
throw new Error(SHARED_ERRORS.NO_PERMISSION);
|
||||
}
|
||||
|
||||
return { customer };
|
||||
|
||||
@ -4,11 +4,13 @@ import * as GQL from '../types';
|
||||
import { BaseService } from './base';
|
||||
import { type VariablesOf } from '@graphql-typed-document-node/core';
|
||||
|
||||
const DEFAULT_CUSTOMERS_SORT = ['name:asc'];
|
||||
|
||||
export class CustomersService extends BaseService {
|
||||
async addMasters(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
|
||||
async addInvitedBy(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
|
||||
await this.checkIsBanned();
|
||||
|
||||
const newMasterIds = variables.data.masters;
|
||||
const newInvitedByIds = variables.data.invitedBy;
|
||||
|
||||
// Проверяем, что пользователь не пытается изменить поле bannedUntil
|
||||
if (variables.data.bannedUntil !== undefined) {
|
||||
@ -16,21 +18,23 @@ export class CustomersService extends BaseService {
|
||||
}
|
||||
|
||||
const { mutate, query } = await getClientWithToken();
|
||||
const getMastersResult = await query({
|
||||
query: GQL.GetMastersDocument,
|
||||
const getInvitedByResult = await query({
|
||||
query: GQL.GetInvitedByDocument,
|
||||
variables,
|
||||
});
|
||||
|
||||
const existingMasterIds = getMastersResult?.data?.customers
|
||||
const existingInvitedByIds = getInvitedByResult?.data?.customers
|
||||
?.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({
|
||||
mutation: GQL.UpdateCustomerDocument,
|
||||
variables: {
|
||||
data: { masters: newMastersIds },
|
||||
data: { invitedBy: newInvitedByIdsList },
|
||||
documentId: variables.documentId,
|
||||
},
|
||||
});
|
||||
@ -41,21 +45,6 @@ export class CustomersService extends BaseService {
|
||||
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>) {
|
||||
await this.checkIsBanned();
|
||||
|
||||
@ -71,13 +60,44 @@ export class CustomersService extends BaseService {
|
||||
return { customer };
|
||||
}
|
||||
|
||||
async getMasters(variables?: VariablesOf<typeof GQL.GetMastersDocument>) {
|
||||
async getCustomers(variables: VariablesOf<typeof GQL.GetCustomersDocument>) {
|
||||
await this.checkIsBanned();
|
||||
|
||||
const { query } = await getClientWithToken();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { CustomersService } from './customers';
|
||||
import { ERRORS, OrdersService } from './orders';
|
||||
import { ServicesService } from './services';
|
||||
import { SlotsService } from './slots';
|
||||
import { SubscriptionsService } from './subscriptions';
|
||||
import dayjs from 'dayjs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@ -11,10 +12,10 @@ vi.mock('../apollo/client');
|
||||
vi.mock('./customers');
|
||||
vi.mock('./services');
|
||||
vi.mock('./slots');
|
||||
vi.mock('./subscriptions');
|
||||
vi.mock('../config/env', () => {
|
||||
return {
|
||||
env: {
|
||||
BOT_TOKEN: 'test',
|
||||
LOGIN_GRAPHQL: 'test',
|
||||
PASSWORD_GRAPHQL: 'test',
|
||||
URL_GRAPHQL: 'test',
|
||||
@ -26,6 +27,7 @@ const mockGetClientWithToken = vi.mocked(getClientWithToken);
|
||||
const mockCustomersService = vi.mocked(CustomersService);
|
||||
const mockServicesService = vi.mocked(ServicesService);
|
||||
const mockSlotsService = vi.mocked(SlotsService);
|
||||
const mockSubscriptionsService = vi.mocked(SubscriptionsService);
|
||||
|
||||
describe('OrdersService', () => {
|
||||
/**
|
||||
@ -97,6 +99,11 @@ describe('OrdersService', () => {
|
||||
customer: mockCustomer,
|
||||
});
|
||||
|
||||
// Глобальный мок для checkIsBanned
|
||||
vi.spyOn(ordersService, 'checkIsBanned').mockResolvedValue({
|
||||
customer: mockCustomer,
|
||||
});
|
||||
|
||||
// Глобальные моки для сервисов
|
||||
mockServicesService.mockImplementation(() => ({
|
||||
getService: vi.fn().mockResolvedValue({
|
||||
@ -114,8 +121,29 @@ describe('OrdersService', () => {
|
||||
getCustomer: vi.fn().mockResolvedValue({
|
||||
customer: mockCustomer,
|
||||
}),
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
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({
|
||||
customer: masterCustomer,
|
||||
}),
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [masterCustomer],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [masterCustomer],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -324,7 +352,7 @@ describe('OrdersService', () => {
|
||||
|
||||
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 () => {
|
||||
@ -353,8 +381,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [mockMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -404,8 +432,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [mockMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -440,8 +468,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [mockMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -481,8 +509,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [mockMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -527,8 +555,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [inactiveMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [inactiveMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -574,8 +602,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [activeCustomerAsMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [activeCustomerAsMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -610,8 +638,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [], // клиент не связан с мастером
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [], // клиент не связан с мастером
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -657,8 +685,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [mockMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -698,8 +726,8 @@ describe('OrdersService', () => {
|
||||
});
|
||||
mockCustomersService.mockImplementation(() => ({
|
||||
getCustomer: mockGetCustomer,
|
||||
getMasters: vi.fn().mockResolvedValue({
|
||||
masters: [mockMaster],
|
||||
getInvitedBy: vi.fn().mockResolvedValue({
|
||||
invitedBy: [mockMaster],
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@ -7,8 +7,8 @@ import { BaseService } from './base';
|
||||
import { CustomersService } from './customers';
|
||||
import { ServicesService } from './services';
|
||||
import { SlotsService } from './slots';
|
||||
import { SubscriptionsService } from './subscriptions';
|
||||
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 dayjs from 'dayjs';
|
||||
|
||||
@ -31,10 +31,15 @@ export const ERRORS = {
|
||||
NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе',
|
||||
NO_ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом',
|
||||
NO_ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота',
|
||||
NO_SELF_ORDER: 'Нельзя записать к самому себе',
|
||||
NOT_FOUND_CLIENT: 'Клиент не найден',
|
||||
NOT_FOUND_MASTER: 'Мастер не найден',
|
||||
NOT_FOUND_ORDER: 'Заказ не найден',
|
||||
NOT_FOUND_ORDER_SLOT: 'Слот заказа не найден',
|
||||
ORDER_LIMIT_EXCEEDED_CLIENT:
|
||||
'Достигнут лимит заказов у этого мастера на месяц. Попробуйте записаться позже или к другому мастеру.',
|
||||
ORDER_LIMIT_EXCEEDED_MASTER:
|
||||
'Достигнут лимит заказов на месяц. Оформите Pro доступ для продолжения работы.',
|
||||
OVERLAPPING_TIME: 'Время пересекается с другими заказами',
|
||||
SLOT_CLOSED: 'Слот закрыт',
|
||||
};
|
||||
@ -43,6 +48,7 @@ const DEFAULT_ORDERS_SORT = ['slot.datetime_start:desc', 'datetime_start:desc'];
|
||||
|
||||
export class OrdersService extends BaseService {
|
||||
async createOrder(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
|
||||
await this.checkIsBanned();
|
||||
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.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);
|
||||
|
||||
// Получаем все услуги по их идентификаторам
|
||||
@ -84,6 +121,8 @@ export class OrdersService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
const isSlotMaster = slot.master.documentId === customer.documentId;
|
||||
|
||||
const { mutate } = await getClientWithToken();
|
||||
|
||||
const mutationResult = await mutate({
|
||||
@ -93,9 +132,7 @@ export class OrdersService extends BaseService {
|
||||
input: {
|
||||
...variables.input,
|
||||
datetime_end: datetimeEnd,
|
||||
state: isCustomerMaster(customer)
|
||||
? GQL.Enum_Order_State.Approved
|
||||
: GQL.Enum_Order_State.Created,
|
||||
state: isSlotMaster ? 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>) {
|
||||
await this.checkIsBanned();
|
||||
const { query } = await getClientWithToken();
|
||||
|
||||
const result = await query({
|
||||
@ -118,6 +156,7 @@ export class OrdersService extends BaseService {
|
||||
}
|
||||
|
||||
async getOrders(variables: VariablesOf<typeof GQL.GetOrdersDocument>) {
|
||||
await this.checkIsBanned();
|
||||
const { query } = await getClientWithToken();
|
||||
|
||||
const result = await query({
|
||||
@ -132,6 +171,7 @@ export class OrdersService extends BaseService {
|
||||
}
|
||||
|
||||
async updateOrder(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
|
||||
await this.checkIsBanned();
|
||||
await this.checkUpdatePermission(variables);
|
||||
await this.checkBeforeUpdate(variables);
|
||||
|
||||
@ -223,6 +263,8 @@ export class OrdersService extends BaseService {
|
||||
|
||||
if (!slot) throw new Error(ERRORS.MISSING_SLOT);
|
||||
|
||||
if (clientId === slot?.master?.documentId) throw new Error(ERRORS.NO_SELF_ORDER);
|
||||
|
||||
// Проверка, что заказ укладывается в рамки слота
|
||||
if (
|
||||
new Date(datetime_start) < new Date(slot.datetime_start) ||
|
||||
@ -254,37 +296,12 @@ export class OrdersService extends BaseService {
|
||||
throw new Error(ERRORS.INACTIVE_MASTER);
|
||||
}
|
||||
|
||||
// 2. Проверка ролей и связей
|
||||
const isClientMaster = clientEntity.role === GQL.Enum_Customer_Role.Master;
|
||||
const slotMasterId = slot.master?.documentId;
|
||||
|
||||
if (!slotMasterId) {
|
||||
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({
|
||||
|
||||
@ -3,6 +3,7 @@ import * as GQL from '../types';
|
||||
import { ERRORS as BASE_ERRORS } from './base';
|
||||
import { ServicesService } from './services';
|
||||
import { ERRORS, SlotsService } from './slots';
|
||||
import { SubscriptionsService } from './subscriptions';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@ -13,10 +14,10 @@ if (!dayjs.prototype.duration) {
|
||||
|
||||
vi.mock('../apollo/client');
|
||||
vi.mock('./services');
|
||||
vi.mock('./subscriptions');
|
||||
vi.mock('../config/env', () => {
|
||||
return {
|
||||
env: {
|
||||
BOT_TOKEN: 'test',
|
||||
LOGIN_GRAPHQL: 'test',
|
||||
PASSWORD_GRAPHQL: 'test',
|
||||
URL_GRAPHQL: 'test',
|
||||
@ -26,6 +27,7 @@ vi.mock('../config/env', () => {
|
||||
|
||||
const mockGetClientWithToken = vi.mocked(getClientWithToken);
|
||||
const mockServicesService = vi.mocked(ServicesService);
|
||||
const mockSubscriptionsService = vi.mocked(SubscriptionsService);
|
||||
|
||||
describe('SlotsService', () => {
|
||||
/**
|
||||
@ -68,6 +70,33 @@ describe('SlotsService', () => {
|
||||
beforeEach(() => {
|
||||
slotsService = new SlotsService(mockUser);
|
||||
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(() => {
|
||||
@ -470,26 +499,6 @@ describe('SlotsService', () => {
|
||||
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 () => {
|
||||
const serviceWithDuration1 = {
|
||||
...mockService1,
|
||||
|
||||
@ -207,7 +207,7 @@ export class SlotsService extends BaseService {
|
||||
|
||||
if (!masterEntity) throw new Error(ERRORS.NOT_FOUND_MASTER);
|
||||
|
||||
if (!masterEntity?.active || masterEntity.role !== 'master') {
|
||||
if (!masterEntity?.active) {
|
||||
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
|
||||
role
|
||||
telegramId
|
||||
services(filters: { active: { eq: true } }) {
|
||||
documentId
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
filters: {
|
||||
or: [
|
||||
@ -41,13 +57,13 @@ query GetMasters($phone: String, $telegramId: Long, $documentId: ID) {
|
||||
}
|
||||
) {
|
||||
documentId
|
||||
masters {
|
||||
invitedBy {
|
||||
...CustomerFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetClients($phone: String, $telegramId: Long) {
|
||||
query GetInvited($phone: String, $telegramId: Long) {
|
||||
customers(
|
||||
filters: {
|
||||
or: [{ phone: { eq: $phone } }, { telegramId: { eq: $telegramId } }]
|
||||
@ -55,14 +71,8 @@ query GetClients($phone: String, $telegramId: Long) {
|
||||
}
|
||||
) {
|
||||
documentId
|
||||
clients {
|
||||
invited {
|
||||
...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',
|
||||
})}
|
||||
/>
|
||||
<span className="sr-only">Loading...</span>
|
||||
<span className="sr-only">Загрузка...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,6 +4,4 @@ export function isCustomerBanned(customer: GQL.CustomerFieldsFragment): boolean
|
||||
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
|
||||
}
|
||||
|
||||
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
|
||||
return customer?.role === GQL.Enum_Customer_Role.Master;
|
||||
}
|
||||
// isCustomerMaster удален - больше не нужен при равенстве пользователей
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
/* eslint-disable import/no-unassigned-import */
|
||||
import { type OpUnitType } from 'dayjs';
|
||||
import dayjs, { type ConfigType } from 'dayjs';
|
||||
import dayjs, { type ConfigType, type OpUnitType } from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import 'dayjs/locale/ru';
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -141,6 +141,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 20.19.4
|
||||
dayjs:
|
||||
specifier: 'catalog:'
|
||||
version: 1.11.13
|
||||
grammy:
|
||||
specifier: ^1.38.1
|
||||
version: 1.38.1
|
||||
@ -153,6 +156,9 @@ importers:
|
||||
pino-pretty:
|
||||
specifier: ^13.1.1
|
||||
version: 13.1.1
|
||||
radashi:
|
||||
specifier: 'catalog:'
|
||||
version: 12.5.1
|
||||
tsup:
|
||||
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)
|
||||
|
||||
10
turbo.json
10
turbo.json
@ -6,7 +6,15 @@
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"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": {
|
||||
"dependsOn": ["^lint"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user