Compare commits

...

45 Commits

Author SHA1 Message Date
vchikalkin
5dac83a249 .github\workflows\deploy.yml: add BOT_PROVIDER_TOKEN 2025-09-17 14:36:06 +03:00
vchikalkin
32ae140dca update bot description 2025-09-17 14:29:08 +03:00
vchikalkin
da12e65145 update support contact 2025-09-17 14:15:18 +03:00
vchikalkin
2de018b8d4 feat(subscriptions): enhance subscription flow and localization updates
- Updated default locale to Russian for improved user experience.
- Refactored subscription messages to include expiration dates and active subscription status.
- Enhanced keyboard display for subscription options with clear expiration information.
- Improved handling of subscription-related queries and responses for better clarity.
2025-09-17 13:22:02 +03:00
vchikalkin
e6c823570c feat(localization): update Pro access terminology and enhance subscription messages
- Replaced instances of "подписка" with "доступ" to clarify Pro access terminology.
- Updated subscription-related messages for improved user understanding and consistency.
- Enhanced command list and bot responses to reflect changes in Pro access messaging.
2025-09-17 12:44:21 +03:00
vchikalkin
7862713a06 feat(localization): add Pro subscription information and update command list
- Introduced new localization entry for Pro subscription information.
- Updated command list to include 'pro' command for better user guidance.
- Enhanced existing subscription messages for clarity and consistency.
2025-09-17 12:22:49 +03:00
vchikalkin
297ab1df2b feat(localization): update Russian localization with support contact and message adjustments
- Added a new support contact message for user inquiries.
- Refactored existing messages to utilize the new support contact variable for consistency.
- Cleaned up redundant messages and ensured proper localization formatting across various sections.
2025-09-17 12:11:20 +03:00
vchikalkin
0fd104048f feat(bot): enhance conversation handling by removing redundant typing indication
- Added a chat action for 'typing' indication at the start of the bot's conversation flow.
- Removed the redundant 'typing' action from individual conversation handlers to streamline the code.
2025-09-17 12:07:04 +03:00
vchikalkin
7b0b2c7074 feat(subscriptions): enhance subscription handling and localization updates
- Added new message `msg-subscribe-disabled` to inform users when their subscription is disabled.
- Updated `msg-subscription-active-days` to ensure proper localization formatting.
- Refactored subscription command in the bot to check for subscription status and respond accordingly.
- Enhanced ProfilePage to conditionally render the SubscriptionInfoBar based on subscription status.
- Updated GraphQL types and queries to include `proEnabled` for better subscription management.
2025-09-17 11:54:32 +03:00
vchikalkin
336f3a11fd feat(subscriptions): update subscription messages and enhance bot functionality
- Renamed `msg-subscribe-active-until` to `msg-subscription-active-until` for consistency in localization.
- Added `msg-subscription-active-days` to inform users about remaining subscription days.
- Refactored subscription handling in the bot to utilize updated subscription checks and improve user messaging.
- Enhanced conversation flow by integrating chat action for typing indication during subscription interactions.
2025-09-16 19:16:21 +03:00
vchikalkin
a6d05bcf69 feat(subscriptions): refactor subscription handling and update related queries
- Renamed `hasUserTrialSubscription` to `usedTrialSubscription` for clarity in the SubscriptionsService.
- Updated subscription-related queries and fragments to use `active` instead of `isActive` for consistency.
- Enhanced the ProPage component to utilize the new subscription checks and improve trial usage logic.
- Removed unused subscription history query to streamline the codebase.
- Adjusted the SubscriptionInfoBar to reflect the new subscription state handling.
2025-09-16 18:37:35 +03:00
vchikalkin
eab6da5e89 test payment 2025-09-12 13:20:20 +03:00
vchikalkin
6228832aff feat(subscriptions): add SubscriptionRewardFields and update related types
- Introduced SubscriptionRewardFields fragment to encapsulate reward-related data for subscriptions.
- Updated CustomerFiltersInput and SubscriptionHistoryFiltersInput to include subscription_rewards for enhanced filtering capabilities.
- Added SubscriptionRewardFiltersInput and SubscriptionRewardInput types to support reward management in subscriptions.
- Modified existing fragments and queries to reflect the new structure and ensure consistency across the codebase.
2025-09-11 11:37:31 +03:00
vchikalkin
17ce24ae04 fix(auth): handle unregistered users in authentication flow
- Updated the authentication logic in both Auth and useAuth functions to redirect unregistered users to the '/unregistered' page.
- Enhanced error handling in the authOptions to check for user registration status using the Telegram ID.
- Improved the matcher configuration in middleware to exclude the '/unregistered' route from authentication checks.
2025-09-10 18:27:31 +03:00
vchikalkin
c9187816a1 fix(auth): ensure telegramId is a string for consistent handling
- Updated the signIn calls in both Auth and useAuth functions to convert telegramId to a string.
- Modified the JWT callback to store telegramId as a string in the token.
- Enhanced session handling to correctly assign telegramId from the token to the session user.
- Added type definitions for telegramId in next-auth to ensure proper type handling.
2025-09-10 17:47:03 +03:00
vchikalkin
4139aa918d fix(avatar): update UserAvatar sizes for consistency across components
- Changed UserAvatar size from 'xl' to 'lg' in PersonCard for better alignment with design.
- Adjusted UserAvatar size from 'sm' to 'xs' in OrderCard to ensure uniformity in avatar presentation.
- Updated sizeClasses in UserAvatar component to reflect the new 'xs' size, enhancing responsiveness.
2025-09-10 17:30:40 +03:00
vchikalkin
78e45718a8 refactor(contacts): consolidate customer queries and enhance contact handling
- Replaced use of useCustomersInfiniteQuery with a new useContactsInfiniteQuery hook for improved data fetching.
- Simplified ContactsList and MastersGrid components by removing unnecessary customer documentId filters.
- Deleted outdated contact-related hooks and queries to streamline the codebase.
- Enhanced loading state management across components for better user experience.
2025-09-10 13:44:43 +03:00
vchikalkin
d8f374d5da feat(customers): add getCustomers API and enhance customer queries
- Introduced getCustomers action and corresponding server method to fetch customer data with pagination and sorting.
- Updated hooks to support infinite querying of customers, improving data handling in components.
- Refactored ContactsList and related components to utilize the new customer fetching logic, enhancing user experience.
- Adjusted filter labels in dropdowns for better clarity and user understanding.
2025-09-10 12:50:54 +03:00
vchikalkin
30bdc0447f feat(contacts): enhance contact display and improve user experience
- Updated ContactsList to include a description prop in ContactRow for better service representation.
- Renamed header in OrderContacts from "Контакты" to "Участники" for clarity.
- Replaced Avatar components with UserAvatar in various components for consistent user representation.
- Filtered active contacts in MastersGrid and ClientsGrid to improve data handling.
- Adjusted customer query logic to ensure proper handling of telegramId.
2025-09-09 11:23:35 +03:00
vchikalkin
de7cdefcd5 feat(contacts): add DataNotFound component for empty states in contacts and services grids
- Integrated DataNotFound component to display a message when no contacts or services are found in the respective grids.
- Enhanced loading state handling in ServicesSelect and ScheduleCalendar components to improve user experience during data fetching.
2025-09-08 18:35:52 +03:00
vchikalkin
92035a4ff8 feat(contacts): add showServices prop to ContactRow for conditional rendering
- Updated ContactsList to pass showServices prop to ContactRow.
- Modified ContactRow to conditionally render services based on the showServices prop, enhancing the display of contact information.
2025-09-08 14:50:31 +03:00
vchikalkin
4e37146214 feat(order): enhance order initialization logic with additional client selection step
- Added a new step for client selection in the order initialization process when only a masterId is present.
- Disabled cognitive complexity checks for improved code maintainability.
2025-09-08 14:44:36 +03:00
vchikalkin
c9a4c23564 refactor(contacts): update grid components and improve customer role handling
- Renamed InvitedByGrid to MastersGrid and InvitedGrid to ClientsGrid for clarity.
- Enhanced customer role checks by using documentId for identifying the current user.
- Updated the contacts passed to grid components to reflect the new naming and role structure.
- Adjusted titles in grid components for better user experience.
2025-09-08 14:36:12 +03:00
vchikalkin
201ccaeea5 feat(profile): enhance user role checks in subscription and links components
- Added conditional rendering in SubscriptionInfoBar and LinksCard to hide components for users with the Client role.
- Updated ProfileDataCard to use Enum_Customer_Role for role management.
- Improved error handling in OrdersService to differentiate between master and client order limit errors.
2025-09-08 14:14:45 +03:00
vchikalkin
0b188ee5ed refactor(contacts): rename masters to invited and update related functionality
- Changed the terminology from "masters" to "invited" and "invitedBy" across the codebase for clarity and consistency.
- Updated the `addContact` function to reflect the new naming convention.
- Refactored API actions and server methods to support the new invited structure.
- Adjusted components and hooks to utilize the updated invited data, enhancing user experience and simplifying logic.
2025-09-08 13:42:30 +03:00
vchikalkin
5dfef524e2 refactor(contact): remove customer master role checks and simplify contact addition
- Updated the `addContact` function to allow all users to add contacts, removing the previous restriction that only masters could do so.
- Deleted the `become-master` feature and related utility functions, streamlining the codebase.
- Adjusted command settings to reflect the removal of the master role functionality.
- Refactored components and hooks to eliminate dependencies on the master role, enhancing user experience and simplifying logic.
2025-09-08 12:51:35 +03:00
vchikalkin
49c43296e4 fix(typo): rename updateSlot to updateOrder for clarity 2025-09-07 20:28:50 +03:00
vchikalkin
3eb302a5d9 fix tests 2025-09-07 19:33:09 +03:00
vchikalkin
7af67b1910 feat(profile): conditionally render SubscriptionInfoBar based on user role
- Updated ProfilePage to check if the user is a master and conditionally render the SubscriptionInfoBar component.
- Refactored customer fetching logic to include a utility function for determining user role.
2025-09-07 16:57:48 +03:00
vchikalkin
04739612ca feat(profile): add MasterServicesList component to display services for profile masters
- Introduced the MasterServicesList component to show services associated with a master profile.
- Updated ProfilePage to conditionally render MasterServicesList based on user role.
- Refactored services fetching logic into a new useMasterServices hook for better reusability.
2025-09-07 16:57:48 +03:00
vchikalkin
d870fa5a21 fix(docker-compose): update healthcheck endpoint to include API path 2025-09-07 16:57:48 +03:00
vchikalkin
4cff4c8bbe order-card: fix order_number badge overlays navigation bar 2025-09-07 16:57:47 +03:00
vchikalkin
b94937b706 feat(orders): implement order limit checks for clients and masters
- Added order limit validation in the `OrdersService` to check if a master has reached their monthly order limit.
- Introduced new error messages for exceeding order limits, enhancing user feedback for both clients and masters.
- Integrated `SubscriptionsService` to manage subscription status and remaining order counts effectively.
2025-09-07 16:57:47 +03:00
vchikalkin
db9788132d feat(subscriptions): enhance error handling with centralized error messages
- Introduced a centralized `ERRORS` object in the `subscriptions.ts` file to standardize error messages related to trial subscriptions.
- Updated error handling in the `createSubscription` method to utilize the new error messages, improving maintainability and clarity for subscription-related errors.
2025-09-07 16:57:47 +03:00
vchikalkin
f2ad3dff17 feat(orders, subscriptions): implement banned user checks and improve remaining orders calculation
- Added `checkIsBanned` method calls in the `createOrder`, `getOrder`, `getOrders`, and `updateOrder` methods of the `OrdersService` to prevent actions from banned users.
- Updated the calculation of `remainingOrdersCount` in the `SubscriptionsService` to ensure it does not go below zero, enhancing subscription management accuracy.
2025-09-07 16:57:47 +03:00
vchikalkin
fd3785a436 style(pro-page, subscription-bar): enhance dark mode support and improve styling consistency
- Updated gradient backgrounds in ProPage and SubscriptionInfoBar to support dark mode variations.
- Refactored class names for better conditional styling based on subscription activity.
- Improved text color handling for better readability in both active and inactive states.
2025-09-07 16:57:46 +03:00
vchikalkin
da51d45882 feat(subscriptions): add trial period validation for subscriptions
- Implemented a check to verify if a user has already utilized their trial period before allowing access to subscription services.
- Enhanced error handling to provide a clear message when a trial period has been previously used, improving user experience and subscription management.
2025-09-07 16:57:46 +03:00
vchikalkin
9903fe4233 refactor(pro-page): streamline ProPage layout and improve bottom navigation visibility
- Consolidated the main container for the ProPage to enhance layout consistency.
- Updated the BottomNav component to conditionally hide on the Pro page, improving navigation clarity for users.
2025-09-07 16:57:46 +03:00
vchikalkin
81e223c69b feat(subscriptions): add trial subscription functionality
- Implemented `createTrialSubscription` action in the API for initiating trial subscriptions.
- Enhanced the Pro page to include a `TryFreeButton` for users to activate their trial.
- Updated GraphQL operations and types to support trial subscription features.
- Improved subscription messaging and user experience across relevant components.
2025-09-07 16:57:45 +03:00
vchikalkin
63ff021916 fix(pro-page): adjust hero section layout for improved visual consistency
- Reduced margin in the hero section to enhance alignment and overall aesthetics of the Pro page.
2025-09-07 16:57:45 +03:00
vchikalkin
10d47d260a feat(pro-page): enhance subscription messaging and add benefits section
- Updated subscription status messaging for clarity and conciseness.
- Improved button styling based on trial availability.
- Added a new benefits section for non-active subscribers, highlighting key features of the Pro subscription.
2025-09-07 16:57:45 +03:00
vchikalkin
ef5e509d6a feat(pro-page): use next/link 2025-09-07 16:57:44 +03:00
vchikalkin
4336cf5e60 feat(env): add BOT_URL to environment variables and update related configurations
- Introduced BOT_URL to the environment schema for enhanced configuration management.
- Updated turbo.json to include BOT_URL in the environment variables list.
- Modified subscription-bar.tsx to improve user messaging for subscription availability.
2025-09-07 16:57:44 +03:00
vchikalkin
812a77406c refactor(tests): remove BOT_TOKEN from environment mocks in order and slots tests
- Eliminated the hardcoded BOT_TOKEN from the environment mock in both orders.test.js and slots.test.js to streamline test configurations and improve security practices.
2025-09-07 16:57:44 +03:00
vchikalkin
38251cd0e8 feat(profile): add subscription information to profile page
- Integrated `SubscriptionInfoBar` component into the profile page for displaying subscription details.
- Updated GraphQL types to include subscription-related fields and filters.
- Enhanced the profile data management by adding subscription handling capabilities.
- Added a new utility function `getRemainingDays` to calculate remaining days until a specified date.
2025-09-07 16:57:43 +03:00
85 changed files with 2412 additions and 762 deletions

View File

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

View File

@ -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,9 +79,11 @@ msg-share-bot =
📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
Нажмите кнопку ниже, чтобы начать
# Системные сообщения
msg-cancel = ❌ Операция отменена
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
msg-cancel-operation = Для отмены операции используйте команду /cancel
# Ошибки
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
@ -103,3 +91,20 @@ err-banned = 🚫 Ваш аккаунт заблокирован
err-with-details = ❌ Произошла ошибка
{ $error }
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 }

View File

@ -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",
"radashi": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"zod": "catalog:"

View File

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

View File

@ -1 +1,2 @@
export * from './add-contact';
export * from './subscription';

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export const formatMoney = Intl.NumberFormat('ru-RU', {
currency: 'RUB',
style: 'currency',
}).format;

View File

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

View File

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

View File

@ -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']>) {

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export function GET() {
return new NextResponse('OK');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,73 +1,101 @@
/* 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) => {
{!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}
key={contact.documentId}
>
<input
checked={selected === contact?.documentId}
checked={selected === contact.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact?.documentId)}
onChange={() => onSelect(contact.documentId)}
onClick={onClick}
type="radio"
value={contact?.documentId}
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',
'rounded-full border-2 transition-all duration-75',
selected === contact.documentId ? 'border-primary' : 'border-transparent',
)}
>
<div
className={cn(
'size-full rounded-full p-1',
isCurrentUser
? 'bg-gradient-to-r from-purple-500 to-pink-500'
: 'bg-transparent',
)}
>
<Image
alt={contact?.name}
className="size-full rounded-full object-cover"
height={80}
src={contact?.photoUrl || AvatarPlaceholder}
width={80}
/>
</div>
<UserAvatar {...contact} size="md" />
</div>
<span
className={cn(
@ -75,55 +103,65 @@ export function ContactsGridBase({ contacts, onSelect, selected, title }: Contac
isCurrentUser && 'font-bold',
)}
>
{contact?.name}
{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],
};
}

View File

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

View File

@ -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(() => {

View File

@ -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()}` : 'Все записи'}
/>
);
}

View File

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

View File

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

View File

@ -2,3 +2,4 @@ export * from './data-card';
export * from './links-card';
export * from './orders-list';
export * from './person-card';
export * from './subscription-bar';

View File

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

View File

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

View File

@ -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,
},
},
slot: {
master: {
documentId: {
eq: isMaster ? customer?.documentId : profile?.documentId,
},
// Показываем все записи между текущим пользователем и профилем
or: [
{
client: { documentId: { eq: customer?.documentId } },
slot: { master: { documentId: { eq: 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">
Загрузить еще

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1 @@
export * from './try-free-button';

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './query';
export * from './use-customer-contacts';

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -12,3 +12,9 @@ declare module 'next-auth' {
telegramId?: null | number;
}
}
declare module 'next-auth/jwt' {
interface JWT {
telegramId?: number;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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

View File

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

View File

@ -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 удален - больше не нужен при равенстве пользователей

View File

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

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

View File

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