From 363fce449978b1451dace486480762e77fd638f2 Mon Sep 17 00:00:00 2001 From: Vlad Chikalkin Date: Wed, 17 Sep 2025 14:46:17 +0300 Subject: [PATCH] Feature/pro subscription (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * 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. * 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. * feat(pro-page): use next/link * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * order-card: fix order_number badge overlays navigation bar * fix(docker-compose): update healthcheck endpoint to include API path * 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. * 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. * fix tests * fix(typo): rename updateSlot to updateOrder for clarity * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * test payment * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * update support contact * update bot description * .github\workflows\deploy.yml: add BOT_PROVIDER_TOKEN --- .github/workflows/deploy.yml | 2 + apps/bot/locales/ru.ftl | 63 +-- apps/bot/package.json | 4 +- apps/bot/src/bot/conversations/add-contact.ts | 20 +- apps/bot/src/bot/conversations/index.ts | 1 + .../bot/src/bot/conversations/subscription.ts | 151 ++++++ apps/bot/src/bot/features/become-master.ts | 42 -- apps/bot/src/bot/features/index.ts | 3 +- apps/bot/src/bot/features/pro.ts | 39 ++ apps/bot/src/bot/features/subscription.ts | 54 +++ apps/bot/src/bot/i18n.ts | 2 +- apps/bot/src/bot/index.ts | 7 +- apps/bot/src/bot/settings/commands.ts | 2 +- apps/bot/src/config/env.ts | 1 + apps/bot/src/utils/customer.ts | 5 - apps/bot/src/utils/format.ts | 4 + apps/bot/src/utils/messages.ts | 4 +- apps/web/actions/api/customers.ts | 7 +- apps/web/actions/api/server/customers.ts | 26 +- apps/web/actions/api/server/subscriptions.ts | 76 +++ apps/web/actions/api/subscriptions.ts | 12 + apps/web/app/(auth)/browser/page.tsx | 13 +- apps/web/app/(auth)/telegram/page.tsx | 14 +- apps/web/app/(auth)/unregistered/page.tsx | 54 +++ .../unregistered/unregistered-client.tsx | 37 ++ apps/web/app/(main)/contacts/page.tsx | 4 +- apps/web/app/(main)/pro/page.tsx | 96 ++++ .../app/(main)/profile/[telegramId]/page.tsx | 16 +- apps/web/app/(main)/profile/page.tsx | 12 +- .../schedule/slots/[documentId]/page.tsx | 13 +- apps/web/app/api/health/route.ts | 5 + .../web/components/contacts/contacts-list.tsx | 36 +- .../components/contacts/dropdown-filter.tsx | 14 +- .../navigation/bottom-nav/index.tsx | 4 + apps/web/components/orders/order-buttons.tsx | 38 +- apps/web/components/orders/order-contacts.tsx | 4 +- .../orders/order-form/contacts-grid.tsx | 198 ++++---- .../orders/order-form/services-select.tsx | 5 +- .../orders/orders-list/date-select.tsx | 24 +- .../components/orders/orders-list/index.tsx | 18 +- .../orders/orders-list/orders-list.tsx | 11 +- .../components/profile/data-card/index.tsx | 10 +- apps/web/components/profile/index.ts | 1 + .../components/profile/links-card/index.tsx | 14 +- .../profile/links-card/link-button.tsx | 5 +- apps/web/components/profile/orders-list.tsx | 41 +- apps/web/components/profile/person-card.tsx | 11 +- .../profile/services/services-list.tsx | 68 ++- .../components/profile/subscription-bar.tsx | 78 ++++ apps/web/components/schedule/calendar.tsx | 3 +- .../schedule/day-slots-list/index.tsx | 26 +- .../components/schedule/slot-orders-list.tsx | 7 +- apps/web/components/shared/book-button.tsx | 16 +- apps/web/components/shared/contact-row.tsx | 20 +- apps/web/components/shared/order-card.tsx | 37 +- apps/web/components/shared/user-avatar.tsx | 49 ++ apps/web/components/subscription/index.ts | 1 + .../subscription/try-free-button.tsx | 28 ++ apps/web/config/auth.ts | 39 +- apps/web/config/env.ts | 2 +- apps/web/context/contacts.tsx | 2 +- apps/web/hooks/api/contacts/index.ts | 2 - apps/web/hooks/api/contacts/query.ts | 23 - .../api/contacts/use-customer-contacts.ts | 47 -- apps/web/hooks/api/customers.ts | 82 +++- apps/web/hooks/api/subscriptions.ts | 138 ++++++ apps/web/middleware.ts | 2 +- apps/web/stores/order/hooks.tsx | 25 +- apps/web/types/next-auth.d.ts | 6 + docker-compose.yml | 2 +- packages/graphql/api/base.ts | 14 +- packages/graphql/api/customers.ts | 70 ++- packages/graphql/api/orders.test.js | 76 ++- packages/graphql/api/orders.ts | 75 +-- packages/graphql/api/slots.test.js | 51 +- packages/graphql/api/slots.ts | 2 +- packages/graphql/api/subscriptions.ts | 370 +++++++++++++++ packages/graphql/operations/customers.graphql | 30 +- .../graphql/operations/subscriptions.graphql | 101 ++++ .../graphql/types/operations.generated.ts | 434 +++++++++++++----- packages/ui/src/components/ui/spinner.tsx | 2 +- packages/utils/src/customer.ts | 4 +- packages/utils/src/datetime-format.ts | 3 +- pnpm-lock.yaml | 6 + turbo.json | 10 +- 85 files changed, 2412 insertions(+), 762 deletions(-) create mode 100644 apps/bot/src/bot/conversations/subscription.ts delete mode 100644 apps/bot/src/bot/features/become-master.ts create mode 100644 apps/bot/src/bot/features/pro.ts create mode 100644 apps/bot/src/bot/features/subscription.ts delete mode 100644 apps/bot/src/utils/customer.ts create mode 100644 apps/bot/src/utils/format.ts create mode 100644 apps/web/actions/api/server/subscriptions.ts create mode 100644 apps/web/actions/api/subscriptions.ts create mode 100644 apps/web/app/(auth)/unregistered/page.tsx create mode 100644 apps/web/app/(auth)/unregistered/unregistered-client.tsx create mode 100644 apps/web/app/(main)/pro/page.tsx create mode 100644 apps/web/app/api/health/route.ts create mode 100644 apps/web/components/profile/subscription-bar.tsx create mode 100644 apps/web/components/shared/user-avatar.tsx create mode 100644 apps/web/components/subscription/index.ts create mode 100644 apps/web/components/subscription/try-free-button.tsx delete mode 100644 apps/web/hooks/api/contacts/index.ts delete mode 100644 apps/web/hooks/api/contacts/query.ts delete mode 100644 apps/web/hooks/api/contacts/use-customer-contacts.ts create mode 100644 apps/web/hooks/api/subscriptions.ts create mode 100644 packages/graphql/api/subscriptions.ts create mode 100644 packages/graphql/operations/subscriptions.graphql diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 184d828..79a8f8e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/apps/bot/locales/ru.ftl b/apps/bot/locales/ru.ftl index a663c8b..84f8adc 100644 --- a/apps/bot/locales/ru.ftl +++ b/apps/bot/locales/ru.ftl @@ -1,11 +1,13 @@ +# Общие +-support-contact = ℹ️ По всем вопросам и обратной связи: @v_dev_support + # Описание бота short-description = Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅 - ℹ️ По всем вопросам и обратной связи: @vchikalkin - + { -support-contact } description = - 📲 Запишись.онлайн — это бесплатное Telegram-приложение для мастеров и тренеров в вашем смартфоне. + 📲 Запишись.онлайн — это встроенное в Telegram приложение + бот для мастеров и тренеров в вашем смартфоне. Возможности: • 📅 Ведение графика и запись клиентов @@ -17,75 +19,59 @@ description = ✨ Всё, что нужно — ваш смартфон. - ℹ️ По всем вопросам и обратной связи: @vchikalkin + { -support-contact } # Команды start = .description = Запуск бота addcontact = .description = Добавить контакт клиента -becomemaster = - .description = Стать мастером sharebot = .description = Поделиться ботом +subscribe = + .description = Приобрести Pro доступ +pro = + .description = Информация о вашем Pro доступе help = .description = Список команд и поддержка - commands-list = 📋 Доступные команды: • /addcontact — добавить контакт клиента - • /becomemaster — стать мастером • /sharebot — поделиться ботом + • /subscribe — приобрести Pro доступ + • /pro — информация о вашем Pro доступе • /help — список команд Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись - support = - ℹ️ По всем вопросам и обратной связи: @vchikalkin + { -support-contact } # Приветственные сообщения msg-welcome = 👋 Добро пожаловать! Пожалуйста, поделитесь своим номером телефона для регистрации - msg-welcome-back = 👋 С возвращением, { $name }! -# Сообщения о статусе мастера -msg-not-master = - ⛔️ Только мастер может добавлять контакты - Стать мастером можно на странице профиля в приложении или с помощью команды /becomemaster - -msg-already-master = 🎉 Вы уже являетесь мастером! - -msg-become-master = 🥳 Поздравляем! Теперь вы мастер # Сообщения о телефоне msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона - msg-phone-saved = ✅ Спасибо! Мы сохранили ваш номер телефона Теперь вы можете открыть приложение или воспользоваться командами бота - msg-already-registered = ✅ Вы уже зарегистрированы в системе Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота) - msg-invalid-phone = ❌ Некорректный номер телефона # Сообщения о контактах -msg-send-client-contact = - 👤 Отправьте контакт клиента, которого вы хотите добавить. - Для отмены операции используйте команду /cancel - +msg-send-client-contact = 👤 Отправьте контакт клиента, которого вы хотите добавить. msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram - msg-contact-added = ✅ Добавили { $name } в список ваших клиентов Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи - msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️ # Сообщения для шаринга @@ -93,13 +79,32 @@ msg-share-bot = 📅 Воспользуйтесь этим ботом для записи к вашему мастеру! Нажмите кнопку ниже, чтобы начать + # Системные сообщения msg-cancel = ❌ Операция отменена msg-unhandled = ❓ Неизвестная команда. Попробуйте /start +msg-cancel-operation = Для отмены операции используйте команду /cancel # Ошибки err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд err-banned = 🚫 Ваш аккаунт заблокирован err-with-details = ❌ Произошла ошибка { $error } -err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного \ No newline at end of file +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 } \ No newline at end of file diff --git a/apps/bot/package.json b/apps/bot/package.json index 723aa97..1979430 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -25,10 +25,12 @@ "@repo/graphql": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "catalog:", + "dayjs": "catalog:", "grammy": "^1.38.1", "ioredis": "^5.7.0", "pino": "^9.9.0", - "pino-pretty": "^13.1.1", + "pino-pretty": "^13.1.1", + "radashi": "catalog:", "tsup": "^8.5.0", "typescript": "catalog:", "zod": "catalog:" diff --git a/apps/bot/src/bot/conversations/add-contact.ts b/apps/bot/src/bot/conversations/add-contact.ts index aa37d6f..e3f7e3b 100644 --- a/apps/bot/src/bot/conversations/add-contact.ts +++ b/apps/bot/src/bot/conversations/add-contact.ts @@ -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, 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, 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, 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 }))); diff --git a/apps/bot/src/bot/conversations/index.ts b/apps/bot/src/bot/conversations/index.ts index cb0d2ef..59877af 100644 --- a/apps/bot/src/bot/conversations/index.ts +++ b/apps/bot/src/bot/conversations/index.ts @@ -1 +1,2 @@ export * from './add-contact'; +export * from './subscription'; diff --git a/apps/bot/src/bot/conversations/subscription.ts b/apps/bot/src/bot/conversations/subscription.ts new file mode 100644 index 0000000..e55c209 --- /dev/null +++ b/apps/bot/src/bot/conversations/subscription.ts @@ -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, 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) { + return ctx.reply(await conversation.external(({ t }) => t('err-generic'))); +} diff --git a/apps/bot/src/bot/features/become-master.ts b/apps/bot/src/bot/features/become-master.ts deleted file mode 100644 index 1dbca9d..0000000 --- a/apps/bot/src/bot/features/become-master.ts +++ /dev/null @@ -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(); - -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 }; diff --git a/apps/bot/src/bot/features/index.ts b/apps/bot/src/bot/features/index.ts index 6caad36..8d8908e 100644 --- a/apps/bot/src/bot/features/index.ts +++ b/apps/bot/src/bot/features/index.ts @@ -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'; diff --git a/apps/bot/src/bot/features/pro.ts b/apps/bot/src/bot/features/pro.ts new file mode 100644 index 0000000..aacd415 --- /dev/null +++ b/apps/bot/src/bot/features/pro.ts @@ -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(); +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 }; diff --git a/apps/bot/src/bot/features/subscription.ts b/apps/bot/src/bot/features/subscription.ts new file mode 100644 index 0000000..36585ee --- /dev/null +++ b/apps/bot/src/bot/features/subscription.ts @@ -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(); + +// 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 }; diff --git a/apps/bot/src/bot/i18n.ts b/apps/bot/src/bot/i18n.ts index d33ca01..5c05c52 100644 --- a/apps/bot/src/bot/i18n.ts +++ b/apps/bot/src/bot/i18n.ts @@ -3,7 +3,7 @@ import { I18n } from '@grammyjs/i18n'; import path from 'node:path'; export const i18n = new I18n({ - defaultLocale: 'en', + defaultLocale: 'ru', directory: path.resolve(process.cwd(), 'locales'), fluentBundleOptions: { useIsolating: false, diff --git a/apps/bot/src/bot/index.ts b/apps/bot/src/bot/index.ts index f3448e6..f45859a 100644 --- a/apps/bot/src/bot/index.ts +++ b/apps/bot/src/bot/index.ts @@ -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)) { diff --git a/apps/bot/src/bot/settings/commands.ts b/apps/bot/src/bot/settings/commands.ts index dbd78ca..08a8473 100644 --- a/apps/bot/src/bot/settings/commands.ts +++ b/apps/bot/src/bot/settings/commands.ts @@ -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>) { - const commands = createCommands(['start', 'addcontact', 'becomemaster', 'sharebot', 'help']); + const commands = createCommands(['start', 'addcontact', 'sharebot', 'help', 'subscribe', 'pro']); for (const command of commands) { addLocalizations(command); diff --git a/apps/bot/src/config/env.ts b/apps/bot/src/config/env.ts index 7f1bb32..8ccd57e 100644 --- a/apps/bot/src/config/env.ts +++ b/apps/bot/src/config/env.ts @@ -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 diff --git a/apps/bot/src/utils/customer.ts b/apps/bot/src/utils/customer.ts deleted file mode 100644 index 499e79c..0000000 --- a/apps/bot/src/utils/customer.ts +++ /dev/null @@ -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; -} diff --git a/apps/bot/src/utils/format.ts b/apps/bot/src/utils/format.ts new file mode 100644 index 0000000..0108c93 --- /dev/null +++ b/apps/bot/src/utils/format.ts @@ -0,0 +1,4 @@ +export const formatMoney = Intl.NumberFormat('ru-RU', { + currency: 'RUB', + style: 'currency', +}).format; diff --git a/apps/bot/src/utils/messages.ts b/apps/bot/src/utils/messages.ts index 35f578d..ec75f2b 100644 --- a/apps/bot/src/utils/messages.ts +++ b/apps/bot/src/utils/messages.ts @@ -1,3 +1,3 @@ -export function combine(...messages: string[]) { - return messages.join('\n\n'); +export function combine(...messages: Array) { + return messages.filter(Boolean).join('\n\n'); } diff --git a/apps/web/actions/api/customers.ts b/apps/web/actions/api/customers.ts index f39b0e1..dbdaec3 100644 --- a/apps/web/actions/api/customers.ts +++ b/apps/web/actions/api/customers.ts @@ -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); diff --git a/apps/web/actions/api/server/customers.ts b/apps/web/actions/api/server/customers.ts index 6739e23..de169d8 100644 --- a/apps/web/actions/api/server/customers.ts +++ b/apps/web/actions/api/server/customers.ts @@ -6,16 +6,10 @@ import { CustomersService } from '@repo/graphql/api/customers'; const getService = useService(CustomersService); -export async function addMasters(...variables: Parameters) { +export async function addInvitedBy(...variables: Parameters) { const service = await getService(); - return wrapServerAction(() => service.addMasters(...variables)); -} - -export async function getClients(...variables: Parameters) { - const service = await getService(); - - return wrapServerAction(() => service.getClients(...variables)); + return wrapServerAction(() => service.addInvitedBy(...variables)); } export async function getCustomer(...variables: Parameters) { @@ -24,10 +18,22 @@ export async function getCustomer(...variables: Parameters service.getCustomer(...variables)); } -export async function getMasters(...variables: Parameters) { +export async function getCustomers(...variables: Parameters) { const service = await getService(); - return wrapServerAction(() => service.getMasters(...variables)); + return wrapServerAction(() => service.getCustomers(...variables)); +} + +export async function getInvited(...variables: Parameters) { + const service = await getService(); + + return wrapServerAction(() => service.getInvited(...variables)); +} + +export async function getInvitedBy(...variables: Parameters) { + const service = await getService(); + + return wrapServerAction(() => service.getInvitedBy(...variables)); } export async function updateCustomer(...variables: Parameters) { diff --git a/apps/web/actions/api/server/subscriptions.ts b/apps/web/actions/api/server/subscriptions.ts new file mode 100644 index 0000000..8c8c077 --- /dev/null +++ b/apps/web/actions/api/server/subscriptions.ts @@ -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 +) { + const service = await getService(); + + return wrapServerAction(() => service.createSubscription(...variables)); +} + +export async function createSubscriptionHistory( + ...variables: Parameters +) { + 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 +) { + const service = await getService(); + + return wrapServerAction(() => service.getSubscription(...variables)); +} + +export async function getSubscriptionHistory( + ...variables: Parameters +) { + const service = await getService(); + + return wrapServerAction(() => service.getSubscriptionHistory(...variables)); +} + +export async function getSubscriptionPrices( + ...variables: Parameters +) { + const service = await getService(); + + return wrapServerAction(() => service.getSubscriptionPrices(...variables)); +} + +export async function getSubscriptionSettings( + ...variables: Parameters +) { + const service = await getService(); + + return wrapServerAction(() => service.getSubscriptionSettings(...variables)); +} + +export async function updateSubscription( + ...variables: Parameters +) { + const service = await getService(); + + return wrapServerAction(() => service.updateSubscription(...variables)); +} + +export async function updateSubscriptionHistory( + ...variables: Parameters +) { + const service = await getService(); + + return wrapServerAction(() => service.updateSubscriptionHistory(...variables)); +} diff --git a/apps/web/actions/api/subscriptions.ts b/apps/web/actions/api/subscriptions.ts new file mode 100644 index 0000000..25ad537 --- /dev/null +++ b/apps/web/actions/api/subscriptions.ts @@ -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); diff --git a/apps/web/app/(auth)/browser/page.tsx b/apps/web/app/(auth)/browser/page.tsx index 99666e7..83115d1 100644 --- a/apps/web/app/(auth)/browser/page.tsx +++ b/apps/web/app/(auth)/browser/page.tsx @@ -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'); + } }); }); } diff --git a/apps/web/app/(auth)/telegram/page.tsx b/apps/web/app/(auth)/telegram/page.tsx index fbfcbd2..e3a09dc 100644 --- a/apps/web/app/(auth)/telegram/page.tsx +++ b/apps/web/app/(auth)/telegram/page.tsx @@ -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]); } diff --git a/apps/web/app/(auth)/unregistered/page.tsx b/apps/web/app/(auth)/unregistered/page.tsx new file mode 100644 index 0000000..eed47ee --- /dev/null +++ b/apps/web/app/(auth)/unregistered/page.tsx @@ -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 ( + +
+ + +
+ +
+ Давайте познакомимся + + Для использования приложения необходимо поделиться своим номером телефона с ботом + +
+ +
+
+
+ +
+

Как поделиться:

+
    +
  1. Вернитесь к Telegram боту
  2. +
  3. + Отправьте команду{' '} + /start +
  4. +
  5. Нажмите на появившуюся кнопку "Отправить номер телефона"
  6. +
  7. Закройте и откройте это приложение еще раз
  8. +
+
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/(auth)/unregistered/unregistered-client.tsx b/apps/web/app/(auth)/unregistered/unregistered-client.tsx new file mode 100644 index 0000000..5405052 --- /dev/null +++ b/apps/web/app/(auth)/unregistered/unregistered-client.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/apps/web/app/(main)/contacts/page.tsx b/apps/web/app/(main)/contacts/page.tsx index e2c4500..38520fd 100644 --- a/apps/web/app/(main)/contacts/page.tsx +++ b/apps/web/app/(main)/contacts/page.tsx @@ -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() {

Контакты

- + {/* */}
diff --git a/apps/web/app/(main)/pro/page.tsx b/apps/web/app/(main)/pro/page.tsx new file mode 100644 index 0000000..d068c65 --- /dev/null +++ b/apps/web/app/(main)/pro/page.tsx @@ -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 ( +
+ + {/* Hero Section */} +
+
+
+
+
+
+ +
+
+
+ +

+ Доступ{' '} + + Pro + +

+ +

+ {hasActiveSubscription + ? 'Ваш Pro доступ активен!' + : 'Разблокируйте больше возможностей'} +

+ + {!hasActiveSubscription && ( +
+ {canUseTrial && } + + +
+ )} + +
+

+ Преимущества +

+
+
+
+ +
+

+ Доступно неограниченное количество записей в месяц +

+
+ + {/*
+
+ +
+

+ Профиль и аватар выделяются цветом +

+
*/} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(main)/profile/[telegramId]/page.tsx b/apps/web/app/(main)/profile/[telegramId]/page.tsx index 8f440d5..c3a032c 100644 --- a/apps/web/app/(main)/profile/[telegramId]/page.tsx +++ b/apps/web/app/(main)/profile/[telegramId]/page.tsx @@ -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) { // Проверка наличия данных 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 ( + - {masterId && clientId && ( - - )} ); diff --git a/apps/web/app/(main)/profile/page.tsx b/apps/web/app/(main)/profile/page.tsx index 83d9f02..ceaf74e 100644 --- a/apps/web/app/(main)/profile/page.tsx +++ b/apps/web/app/(main)/profile/page.tsx @@ -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 ( + {proEnabled && } diff --git a/apps/web/app/(main)/profile/schedule/slots/[documentId]/page.tsx b/apps/web/app/(main)/profile/schedule/slots/[documentId]/page.tsx index e31e5a0..d248303 100644 --- a/apps/web/app/(main)/profile/schedule/slots/[documentId]/page.tsx +++ b/apps/web/app/(main)/profile/schedule/slots/[documentId]/page.tsx @@ -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) { 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 ( - {masterId && } +
diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..676c7c1 --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export function GET() { + return new NextResponse('OK'); +} diff --git a/apps/web/components/contacts/contacts-list.tsx b/apps/web/components/contacts/contacts-list.tsx index 5702157..5bb238f 100644 --- a/apps/web/components/contacts/contacts-list.tsx +++ b/apps/web/components/contacts/contacts-list.tsx @@ -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 ; - - if (!contacts.length) return ; + const contacts = pages?.flatMap((page) => page.customers); return ( -
- {contacts.map((contact) => ( - - ))} +
+ {isLoading && } + {!isLoading && !contacts?.length ? : null} + {contacts?.map( + (contact) => + contact && ( + service?.name).join(', ')} + key={contact.documentId} + {...contact} + /> + ), + )} + {hasNextPage && ( + + )}
); } diff --git a/apps/web/components/contacts/dropdown-filter.tsx b/apps/web/components/contacts/dropdown-filter.tsx index b6e9bbc..5d53502 100644 --- a/apps/web/components/contacts/dropdown-filter.tsx +++ b/apps/web/components/contacts/dropdown-filter.tsx @@ -13,8 +13,8 @@ import { use } from 'react'; const filterLabels: Record = { all: 'Все', - clients: 'Клиенты', - masters: 'Мастера', + invited: 'Приглашенные', + invitedBy: 'Пригласили вас', }; export function ContactsFilter() { @@ -29,9 +29,13 @@ export function ContactsFilter() { - setFilter('all')}>Все - setFilter('clients')}>Клиенты - setFilter('masters')}>Мастера + setFilter('all')}>{filterLabels['all']} + setFilter('invited')}> + {filterLabels['invited']} + + setFilter('invitedBy')}> + {filterLabels['invitedBy']} + ); diff --git a/apps/web/components/navigation/bottom-nav/index.tsx b/apps/web/components/navigation/bottom-nav/index.tsx index e0852ff..0bf7932 100644 --- a/apps/web/components/navigation/bottom-nav/index.tsx +++ b/apps/web/components/navigation/bottom-nav/index.tsx @@ -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 (