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
121 changed files with 1865 additions and 8021 deletions

View File

@ -1,10 +1,9 @@
name: Build & Deploy Web, Bot & Cache Proxy name: Build & Deploy Web & Bot
on: on:
push: push:
branches: branches:
- main - main
workflow_dispatch:
jobs: jobs:
build-and-push: build-and-push:
@ -13,31 +12,11 @@ jobs:
outputs: outputs:
web_tag: ${{ steps.vars.outputs.web_tag }} web_tag: ${{ steps.vars.outputs.web_tag }}
bot_tag: ${{ steps.vars.outputs.bot_tag }} bot_tag: ${{ steps.vars.outputs.bot_tag }}
cache_proxy_tag: ${{ steps.vars.outputs.cache_proxy_tag }}
# Добавляем output-ы для отслеживания, какие проекты были собраны
web_built: ${{ steps.filter.outputs.web }}
bot_built: ${{ steps.filter.outputs.bot }}
cache_proxy_built: ${{ steps.filter.outputs.cache_proxy }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
# --- НОВОЕ: Шаг 1: dorny/paths-filter для условной сборки --- - name: Create fake .env file for build
- name: Filter changed paths
uses: dorny/paths-filter@v2
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/**'
bot:
- 'apps/bot/**'
- 'packages/**'
cache_proxy:
- 'apps/cache-proxy/**'
# -----------------------------------------------------------
- name: Create .env file for build
run: | run: |
echo "BOT_TOKEN=fake" > .env echo "BOT_TOKEN=fake" > .env
echo "LOGIN_GRAPHQL=fake" >> .env echo "LOGIN_GRAPHQL=fake" >> .env
@ -48,53 +27,32 @@ jobs:
echo "BOT_URL=http://localhost:3000" >> .env echo "BOT_URL=http://localhost:3000" >> .env
echo "REDIS_PASSWORD=fake" >> .env echo "REDIS_PASSWORD=fake" >> .env
echo "BOT_PROVIDER_TOKEN=fake" >> .env echo "BOT_PROVIDER_TOKEN=fake" >> .env
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
- name: Set image tags - name: Set image tags
id: vars id: vars
run: | run: |
echo "web_tag=web-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT echo "web_tag=web-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "bot_tag=bot-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT echo "bot_tag=bot-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "cache_proxy_tag=cache-proxy-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Login to Docker Hub - name: Login to Docker Hub
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
# --- ИЗМЕНЕНО: Условное выполнение Build/Push ---
- name: Build web image - name: Build web image
if: steps.filter.outputs.web == 'true'
run: | run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} -f ./apps/web/Dockerfile . docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} -f ./apps/web/Dockerfile .
- name: Push web image to Docker Hub - name: Push web image to Docker Hub
if: steps.filter.outputs.web == 'true'
run: | run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }}
- name: Build bot image - name: Build bot image
if: steps.filter.outputs.bot == 'true'
run: | run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} -f ./apps/bot/Dockerfile . docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} -f ./apps/bot/Dockerfile .
- name: Push bot image to Docker Hub - name: Push bot image to Docker Hub
if: steps.filter.outputs.bot == 'true'
run: | run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }}
- name: Build cache-proxy image
if: steps.filter.outputs.cache_proxy == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }} -f ./apps/cache-proxy/Dockerfile .
- name: Push cache-proxy image to Docker Hub
if: steps.filter.outputs.cache_proxy == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }}
# -------------------------------------------------
deploy: deploy:
name: Deploy to VPS name: Deploy to VPS
needs: build-and-push needs: build-and-push
@ -115,10 +73,8 @@ jobs:
run: | run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p /home/${{ secrets.VPS_USER }}/zapishis" ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p /home/${{ secrets.VPS_USER }}/zapishis"
# --- НОВОЕ: Шаг 2: Создание основного .env БЕЗ ТЕГОВ --- - name: Create real .env file for production
- name: Create .env file for deploy
run: | run: |
# Включаем все секреты, КРОМЕ тегов
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
@ -126,26 +82,13 @@ jobs:
echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env
echo "BOT_URL=${{ secrets.BOT_URL }}" >> .env echo "BOT_URL=${{ secrets.BOT_URL }}" >> .env
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" >> .env
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" >> .env
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
# --- НОВОЕ: Шаг 3: Создание файлов тегов (.project.env) --- - name: Copy .env to VPS via SCP
- name: Create Project Tag Env Files
run: |
# Создаем файлы, которые будут содержать только одну переменную с тегом
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" > .env.web
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" > .env.bot
echo "CACHE_PROXY_IMAGE_TAG=${{ needs.build-and-push.outputs.cache_proxy_tag }}" > .env.cache-proxy
# --- Шаг 4: Копирование .env и УСЛОВНОЕ копирование тегов ---
# Копируем основной .env всегда
- name: Copy .env to VPS via SCP (Always)
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
host: ${{ secrets.VPS_HOST }} host: ${{ secrets.VPS_HOST }}
@ -155,42 +98,6 @@ jobs:
source: '.env' source: '.env'
target: '/home/${{ secrets.VPS_USER }}/zapishis/' target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.web ТОЛЬКО, если web был собран (обновляем тег на VPS)
- name: Copy .env.web to VPS
if: ${{ needs.build-and-push.outputs.web_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.web'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.bot ТОЛЬКО, если bot был собран
- name: Copy .env.bot to VPS
if: ${{ needs.build-and-push.outputs.bot_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.bot'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.cache-proxy ТОЛЬКО, если cache-proxy был собран
- name: Copy .env.cache-proxy to VPS
if: ${{ needs.build-and-push.outputs.cache_proxy_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.cache-proxy'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
- name: Copy docker-compose.yml to VPS via SCP - name: Copy docker-compose.yml to VPS via SCP
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
@ -201,27 +108,12 @@ jobs:
source: 'docker-compose.yml' source: 'docker-compose.yml'
target: '/home/${{ secrets.VPS_USER }}/zapishis/' target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# --- ФИНАЛЬНЫЙ ДЕПЛОЙ ---
- name: Login and deploy on VPS - name: Login and deploy on VPS
run: | run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} " ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
cd /home/${{ secrets.VPS_USER }}/zapishis && \ cd /home/${{ secrets.VPS_USER }}/zapishis && \
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
# 1. Объединение ВСЕХ ENV-файлов в один основной .env docker compose pull && \
# Теги из .env.web/.env.bot переопределят любые старые/пустые значения,
# и .env станет полным и актуальным.
echo \"Merging environment files into .env...\" && \
cat .env .env.web .env.bot .env.cache-proxy > .temp_env && \
mv .temp_env .env && \
# 2. Логин
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
# 3. Pull ВСЕХ сервисов (Docker Compose автоматически использует обновленный .env)
echo \"Pulling all services...\" && \
docker compose pull
# 4. Перезапуск
docker compose down && \ docker compose down && \
docker compose up -d docker compose up -d
" "

View File

@ -25,7 +25,7 @@ description =
start = start =
.description = Запуск бота .description = Запуск бота
addcontact = addcontact =
.description = Добавить контакт .description = Добавить контакт клиента
sharebot = sharebot =
.description = Поделиться ботом .description = Поделиться ботом
subscribe = subscribe =
@ -36,7 +36,7 @@ help =
.description = Список команд и поддержка .description = Список команд и поддержка
commands-list = commands-list =
📋 Доступные команды: 📋 Доступные команды:
• /addcontact — добавить контакт • /addcontact — добавить контакт клиента
• /sharebot — поделиться ботом • /sharebot — поделиться ботом
• /subscribe — приобрести Pro доступ • /subscribe — приобрести Pro доступ
• /pro — информация о вашем Pro доступе • /pro — информация о вашем Pro доступе
@ -45,35 +45,6 @@ commands-list =
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
support = support =
{ -support-contact } { -support-contact }
documents =
.description = Документы
# Кнопки
btn-add-contact = 👤 Добавить контакт
btn-share-bot = 🤝 Поделиться ботом
btn-pro = 👑 Pro доступ
btn-subscribe = 👑 Приобрести Pro
btn-pro-info = Мой Pro доступ
btn-open-app = 📱 Открыть приложение
btn-faq = 📖 Инструкция
btn-documents = 📋 Документы
btn-back = ◀️ Назад
# Согласие
share-phone-agreement =
<i> Нажимая кнопку <b>«Отправить номер телефона»</b></i>,
<i>вы:
- соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
- подтверждаете согласие на обработку персональных данных согласно <a href='{ $privacyUrl }'>Политике конфиденциальности</a></i>
share-contact-agreement =
<i> Отправляя контакт, имя и номер телефона, вы подтверждаете, что имеете согласие этого человека на передачу его контактных данных и на их обработку в рамках нашего сервиса.
(Пункт 4.5 <a href='{ $privacyUrl }'>Политики конфиденциальности</a>)</i>
payment-agreement =
Совершая оплату, вы соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
agreement-links =
<a href='{ $offerUrl }'>Публичная оферта</a>
<a href='{ $privacyUrl }'>Политика конфиденциальности</a>
# Приветственные сообщения # Приветственные сообщения
@ -84,28 +55,24 @@ msg-welcome-back = 👋 С возвращением, { $name }!
# Сообщения о телефоне # Сообщения о телефоне
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона. msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона
msg-phone-saved = msg-phone-saved =
✅ Спасибо! Мы сохранили ваш номер телефона ✅ Спасибо! Мы сохранили ваш номер телефона
Теперь вы можете открыть приложение или воспользоваться командами бота Теперь вы можете открыть приложение или воспользоваться командами бота
msg-already-registered = msg-already-registered =
✅ Вы уже зарегистрированы в системе ✅ Вы уже зарегистрированы в системе
<i>Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)</i> Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)
msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999 msg-invalid-phone = ❌ Некорректный номер телефона
# Сообщения о контактах # Сообщения о контактах
msg-send-client-contact = 👤 Отправьте контакт пользователя, которого вы хотите добавить. msg-send-client-contact = 👤 Отправьте контакт клиента, которого вы хотите добавить.
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
msg-contact-added = msg-contact-added =
✅ Добавили { $fullname } в список ваших контактов ✅ Добавили { $name } в список ваших клиентов
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи Пригласите клиента в приложение, чтобы вы могли добавлять с ним записи
msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i> msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️
# Сообщения для шаринга # Сообщения для шаринга
msg-share-bot = msg-share-bot =
@ -116,7 +83,7 @@ msg-share-bot =
# Системные сообщения # Системные сообщения
msg-cancel = ❌ Операция отменена msg-cancel = ❌ Операция отменена
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
msg-cancel-operation = <i>Для отмены операции используйте команду /cancel</i> msg-cancel-operation = Для отмены операции используйте команду /cancel
# Ошибки # Ошибки
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
@ -124,8 +91,6 @@ err-banned = 🚫 Ваш аккаунт заблокирован
err-with-details = ❌ Произошла ошибка err-with-details = ❌ Произошла ошибка
{ $error } { $error }
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного
err-missing-telegram-id = ❌ Telegram ID не найден
err-cannot-add-self = ❌ Нельзя добавить свой номер телефона как контакт
# Сообщения о доступе # Сообщения о доступе
@ -134,15 +99,12 @@ msg-subscribe =
• Разблокирует неограниченное количество заказов • Разблокирует неограниченное количество заказов
msg-subscribe-success = ✅ Платеж успешно обработан! msg-subscribe-success = ✅ Платеж успешно обработан!
msg-subscribe-error = ❌ Произошла ошибка при обработке платежа msg-subscribe-error = ❌ Произошла ошибка при обработке платежа
msg-subscription-inactive = 🔴 Pro доступ неактивен
msg-subscription-active = 🟢 Ваш Pro доступ активен
msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date } msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date }
msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days } msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days }
msg-subscription-active-days-short = Осталось дней: { $days }
msg-subscription-expired = msg-subscription-expired =
Ваш Pro доступ истек. Ваш Pro доступ истек.
Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов
msg-subscribe-disabled = 🟢 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉 msg-subscribe-disabled = 🚫 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉
# Информация о лимитах # Информация о лимитах
msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count } msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count }

View File

@ -17,7 +17,6 @@
"@grammyjs/conversations": "^2.1.0", "@grammyjs/conversations": "^2.1.0",
"@grammyjs/hydrate": "^1.6.0", "@grammyjs/hydrate": "^1.6.0",
"@grammyjs/i18n": "^1.1.2", "@grammyjs/i18n": "^1.1.2",
"@grammyjs/menu": "^1.3.1",
"@grammyjs/parse-mode": "^2.2.0", "@grammyjs/parse-mode": "^2.2.0",
"@grammyjs/ratelimiter": "^1.2.1", "@grammyjs/ratelimiter": "^1.2.1",
"@grammyjs/runner": "^2.0.3", "@grammyjs/runner": "^2.0.3",
@ -29,9 +28,8 @@
"dayjs": "catalog:", "dayjs": "catalog:",
"grammy": "^1.38.1", "grammy": "^1.38.1",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"libphonenumber-js": "^1.12.24",
"pino": "^9.9.0", "pino": "^9.9.0",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"radashi": "catalog:", "radashi": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "catalog:", "typescript": "catalog:",

View File

@ -1,14 +1,11 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable id-length */ /* eslint-disable id-length */
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { env } from '@/config/env'; import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact';
import { combine } from '@/utils/messages'; import { combine } from '@/utils/messages';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { type Conversation } from '@grammyjs/conversations'; import { type Conversation } from '@grammyjs/conversations';
import { CustomersService } from '@repo/graphql/api/customers'; import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration'; import { RegistrationService } from '@repo/graphql/api/registration';
import parsePhoneNumber from 'libphonenumber-js';
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) { export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
// Все пользователи могут добавлять контакты // Все пользователи могут добавлять контакты
@ -17,115 +14,49 @@ export async function addContact(conversation: Conversation<Context, Context>, c
return ctx.reply(await conversation.external(({ t }) => t('err-generic'))); return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
} }
const registrationService = new RegistrationService(); const customerService = new CustomersService({ telegramId });
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) { if (!customer) {
return ctx.reply( return ctx.reply(
await conversation.external(({ t }) => await conversation.external(({ t }) => t('msg-need-phone')),
combine( KEYBOARD_SHARE_PHONE,
t('msg-need-phone'),
t('share-phone-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
),
{ ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' },
); );
} }
// Просим отправить контакт или номер телефона // Просим отправить контакт клиента
await ctx.reply( await ctx.reply(
await conversation.external(({ t }) => await conversation.external(({ t }) =>
combine( combine(t('msg-send-client-contact'), t('msg-cancel-operation')),
t('msg-send-client-contact-or-phone'),
t('msg-cancel-operation'),
t('share-contact-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
), ),
{
parse_mode: 'HTML',
},
); );
// Ждём первое сообщение: контакт или текст с номером // Ждем любое сообщение от пользователя
const firstCtx = await conversation.wait(); const waitCtx = await conversation.wait();
let name = ''; // Проверяем, что отправлен контакт
let surname = ''; if (!waitCtx.message?.contact) {
let phone = ''; return ctx.reply(await conversation.external(({ t }) => t('msg-send-contact')));
}
if (firstCtx.message?.contact) { const { contact } = waitCtx.message;
/** const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
* Отправлен контакт const phone = normalizePhoneNumber(contact.phone_number);
*/
const { contact } = firstCtx.message;
const parsedContact = parseContact(contact);
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
name = parsedContact.name; // Проверяем валидность номера телефона
surname = parsedContact.surname; if (!isValidPhoneNumber(phone)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
if (!parsedPhone?.isValid() || !parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
phone = parsedPhone.number;
} else if (firstCtx.message?.text) {
/**
* Номер в тексте сообщения
*/
const parsedPhone = parsePhoneNumber(firstCtx.message.text, 'RU');
if (!parsedPhone?.isValid() || !parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
// Нельзя добавлять свой собственный номер телефона
if (customer.phone && customer.phone === parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('err-cannot-add-self')));
}
phone = parsedPhone.number;
// Просим ввести имя клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-name')));
const nameCtx = await conversation.wait();
const typedName = nameCtx.message?.text?.trim() || '';
if (!typedName) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-name')));
}
name = typedName;
// Просим ввести фамилию клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-surname')));
const surnameCtx = await conversation.wait();
const typedSurname = surnameCtx.message?.text?.trim() || '';
if (!typedSurname) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-surname')));
}
surname = typedSurname;
} else {
return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone')));
} }
try { try {
// Проверяем, есть ли клиент с таким номером // Проверяем, есть ли клиент с таким номером
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({ const { customer: existingCustomer } = await customerService.getCustomer({ phone });
phone,
});
let documentId = existingCustomer?.documentId; let documentId = existingCustomer?.documentId;
// Если клиента нет, создаём нового // Если клиента нет, создаём нового
if (!documentId) { if (!documentId) {
const createCustomerResult = await registrationService.createCustomer({ const registrationService = new RegistrationService();
data: { name, phone, surname }, const createCustomerResult = await registrationService.createCustomer({ name, phone });
});
documentId = createCustomerResult?.createCustomer?.documentId; documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) throw new Error('Клиент не создан'); if (!documentId) throw new Error('Клиент не создан');
@ -133,24 +64,19 @@ export async function addContact(conversation: Conversation<Context, Context>, c
// Добавляем текущего пользователя к приглашенному // Добавляем текущего пользователя к приглашенному
const invitedBy = [customer.documentId]; const invitedBy = [customer.documentId];
const customerService = new CustomersService({ telegramId });
await customerService.addInvitedBy({ data: { invitedBy }, documentId }); await customerService.addInvitedBy({ data: { invitedBy }, documentId });
// Отправляем подтверждения и инструкции // Отправляем подтверждения и инструкции
await ctx.reply( await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
await conversation.external(({ t }) => await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')));
t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }),
),
);
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')), {
parse_mode: 'HTML',
});
await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT); await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT);
} catch (error) { } catch (error) {
await ctx.reply( await ctx.reply(
await conversation.external(({ t }) => t('err-with-details', { error: String(error) })), await conversation.external(({ t }) => t('err-with-details', { error: String(error) })),
); );
} finally {
await ctx.reply(await conversation.external(({ t }) => t('commands-list')), KEYBOARD_REMOVE);
} }
return conversation.halt(); return ctx.reply(await conversation.external(({ t }) => t('err-generic')), KEYBOARD_REMOVE);
} }

View File

@ -5,7 +5,6 @@ import { formatMoney } from '@/utils/format';
import { combine } from '@/utils/messages'; import { combine } from '@/utils/messages';
import { type Conversation } from '@grammyjs/conversations'; import { type Conversation } from '@grammyjs/conversations';
import { fmt, i } from '@grammyjs/parse-mode'; import { fmt, i } from '@grammyjs/parse-mode';
import { CustomersService } from '@repo/graphql/api/customers';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions'; import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import * as GQL from '@repo/graphql/types'; import * as GQL from '@repo/graphql/types';
import { InlineKeyboard } from 'grammy'; import { InlineKeyboard } from 'grammy';
@ -23,6 +22,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
hasActiveSubscription, hasActiveSubscription,
remainingDays, remainingDays,
subscription: currentSubscription, subscription: currentSubscription,
usedTrialSubscription,
} = await subscriptionsService.getSubscription({ } = await subscriptionsService.getSubscription({
telegramId, telegramId,
}); });
@ -33,7 +33,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
eq: true, eq: true,
}, },
period: { period: {
ne: GQL.Enum_Subscriptionprice_Period.Trial, ne: usedTrialSubscription ? GQL.Enum_Subscriptionprice_Period.Trial : undefined,
}, },
}, },
}); });
@ -41,11 +41,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
const prices = sift(subscriptionPrices); const prices = sift(subscriptionPrices);
// строим клавиатуру с указанием даты окончания после покупки // строим клавиатуру с указанием даты окончания после покупки
const keyboard = buildPricesKeyboard( const keyboard = buildPricesKeyboard(prices, currentSubscription?.expiresAt);
prices,
currentSubscription?.expiresAt,
hasActiveSubscription,
);
// сообщение с выбором плана // сообщение с выбором плана
const messageWithPrices = await ctx.reply( const messageWithPrices = await ctx.reply(
@ -67,7 +63,7 @@ export async function subscription(conversation: Conversation<Context, Context>,
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text); return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
}), }),
), ),
{ parse_mode: 'HTML', reply_markup: keyboard }, { reply_markup: keyboard },
); );
// ждём выбора // ждём выбора
@ -96,21 +92,6 @@ export async function subscription(conversation: Conversation<Context, Context>,
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
}); });
const agreementText = await conversation.external(({ t }) => {
return t('payment-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
});
});
await ctx.reply(agreementText, {
parse_mode: 'HTML',
});
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
return ctx.replyWithInvoice( return ctx.replyWithInvoice(
'Оплата Pro доступа', 'Оплата Pro доступа',
combine( combine(
@ -126,28 +107,6 @@ export async function subscription(conversation: Conversation<Context, Context>,
}, },
], ],
{ {
protect_content: true,
provider_data: JSON.stringify({
receipt: {
customer: {
phone: customer?.phone.replaceAll(/\D/gu, ''),
},
items: [
{
amount: {
currency: 'RUB',
value: selectedPrice.amount,
},
description: selectedPrice.description || 'Pro доступ',
payment_mode: 'full_payment',
payment_subject: 'payment',
quantity: 1,
vat_code: 1,
},
],
tax_system_code: 1,
},
}),
provider_token: env.BOT_PROVIDER_TOKEN, provider_token: env.BOT_PROVIDER_TOKEN,
start_parameter: 'get_access', start_parameter: 'get_access',
}, },
@ -165,7 +124,6 @@ function addDays(date: Date, days: number) {
function buildPricesKeyboard( function buildPricesKeyboard(
prices: GQL.SubscriptionPriceFieldsFragment[], prices: GQL.SubscriptionPriceFieldsFragment[],
currentExpiresAt?: string, currentExpiresAt?: string,
hasActiveSubscription = false,
) { ) {
const keyboard = new InlineKeyboard(); const keyboard = new InlineKeyboard();
const baseTime = currentExpiresAt const baseTime = currentExpiresAt
@ -181,7 +139,7 @@ function buildPricesKeyboard(
keyboard.row({ keyboard.row({
callback_data: price.period, callback_data: price.period,
pay: true, pay: true,
text: `${hasActiveSubscription ? 'Продлить' : 'Доступ'} до ${targetDateRu} (${formatMoney(price.amount)})`, text: `Продлить до ${targetDateRu} (${formatMoney(price.amount)})`,
}); });
} }

View File

@ -1,4 +1,3 @@
import { handleAddContact } from '../handlers/add-contact';
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
@ -7,6 +6,8 @@ const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('addcontact', logHandle('command-add-contact'), handleAddContact); feature.command('addcontact', logHandle('command-add-contact'), async (ctx) => {
await ctx.conversation.enter('addContact');
});
export { composer as addContact }; export { composer as addContact };

View File

@ -1,12 +0,0 @@
import { handleDocuments } from '../handlers/documents';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('documents', logHandle('command-documents'), handleDocuments);
export { composer as documents };

View File

@ -1,6 +1,7 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { mainMenu } from '@/config/keyboards'; import { KEYBOARD_REMOVE } from '@/config/keyboards';
import { combine } from '@/utils/messages';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -8,7 +9,10 @@ const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('help', logHandle('command-help'), async (ctx) => { feature.command('help', logHandle('command-help'), async (ctx) => {
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); return ctx.reply(combine(ctx.t('commands-list'), ctx.t('support')), {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
});
}); });
export { composer as help }; export { composer as help };

View File

@ -1,5 +1,4 @@
export * from './add-contact'; export * from './add-contact';
export * from './documents';
export * from './help'; export * from './help';
export * from './pro'; export * from './pro';
export * from './registration'; export * from './registration';

View File

@ -1,11 +1,39 @@
import { handlePro } from '../handlers/pro';
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { combine } from '@/utils/messages';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('pro', logHandle('command-pro'), handlePro); 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 }; export { composer as pro };

View File

@ -1,10 +1,10 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards'; import { KEYBOARD_REMOVE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact'; import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration'; import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
import parsePhoneNumber from 'libphonenumber-js';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -14,13 +14,11 @@ const feature = composer.chatType('private');
feature.on(':contact', logHandle('contact-registration'), async (ctx) => { feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
const telegramId = ctx.from.id; const telegramId = ctx.from.id;
const { contact } = ctx.message; const { contact } = ctx.message;
const { name, surname } = parseContact(contact); const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
// Проверяем, не зарегистрирован ли уже пользователь // Проверяем, не зарегистрирован ли уже пользователь
const registrationService = new RegistrationService(); const customerService = new CustomersService({ telegramId });
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({ const { customer: existingCustomer } = await customerService.getCustomer({ telegramId });
telegramId,
});
if (existingCustomer) { if (existingCustomer) {
return ctx.reply(ctx.t('msg-already-registered'), { return ctx.reply(ctx.t('msg-already-registered'), {
@ -35,32 +33,31 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
} }
// Нормализация и валидация номера // Нормализация и валидация номера
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU'); const phone = normalizePhoneNumber(contact.phone_number);
if (!parsedPhone?.isValid() || !parsedPhone?.number) { if (!isValidPhoneNumber(phone)) {
return ctx.reply(ctx.t('msg-invalid-phone')); return ctx.reply(ctx.t('msg-invalid-phone'));
} }
const registrationService = new RegistrationService();
try { try {
const { customer } = await registrationService._NOCACHE_GetCustomer({ const { customer } = await registrationService.getCustomer({ phone });
phone: parsedPhone.number,
});
if (customer && !customer.telegramId) { if (customer && !customer.telegramId) {
// Пользователь добавлен ранее мастером — обновляем данные // Пользователь добавлен ранее мастером — обновляем данные
await registrationService.updateCustomer({ await registrationService.updateCustomer({
data: { active: true, name, surname, telegramId }, data: { active: true, name, telegramId },
documentId: customer.documentId, documentId: customer.documentId,
}); });
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE }); return ctx.reply(ctx.t('msg-phone-saved') + '\n\n' + ctx.t('commands-list'), {
...KEYBOARD_REMOVE,
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); parse_mode: 'HTML',
});
} }
// Новый пользователь — создаём и активируем // Новый пользователь — создаём и активируем
const response = await registrationService.createCustomer({ const response = await registrationService.createCustomer({ name, phone, telegramId });
data: { name, phone: parsedPhone.number, surname, telegramId },
});
const documentId = response?.createCustomer?.documentId; const documentId = response?.createCustomer?.documentId;
if (!documentId) return ctx.reply(ctx.t('err-generic')); if (!documentId) return ctx.reply(ctx.t('err-generic'));
@ -70,9 +67,10 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
documentId, documentId,
}); });
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE }); return ctx.reply(ctx.t('msg-phone-saved') + '\n\n' + ctx.t('commands-list'), {
...KEYBOARD_REMOVE,
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); parse_mode: 'HTML',
});
} catch (error) { } catch (error) {
return ctx.reply(ctx.t('err-with-details', { error: String(error) })); return ctx.reply(ctx.t('err-with-details', { error: String(error) }));
} }

View File

@ -1,12 +1,15 @@
import { handleShareBot } from '../handlers/share-bot';
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_SHARE_BOT } from '@/config/keyboards';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
const feature = composer.chatType('private'); const feature = composer.chatType('private');
feature.command('sharebot', logHandle('command-share-bot'), handleShareBot); feature.command('sharebot', logHandle('command-share-bot'), async (ctx) => {
await ctx.reply(ctx.t('msg-contact-forward'), { parse_mode: 'HTML' });
await ctx.reply(ctx.t('msg-share-bot'), { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
});
export { composer as shareBot }; export { composer as shareBot };

View File

@ -1,4 +1,3 @@
import { handleSubscribe } from '../handlers/subscription';
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@ -9,13 +8,25 @@ const composer = new Composer<Context>();
// Telegram требует отвечать на pre_checkout_query // Telegram требует отвечать на pre_checkout_query
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => { composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
console.log('🚀 ~ ctx:', ctx);
await ctx.answerPreCheckoutQuery(true); await ctx.answerPreCheckoutQuery(true);
}); });
const feature = composer.chatType('private'); const feature = composer.chatType('private');
// команда для входа в flow подписки // команда для входа в flow подписки
feature.command('subscribe', logHandle('command-subscribe'), handleSubscribe); 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) => { feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
@ -28,12 +39,7 @@ feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) =
const payload = JSON.parse(rawPayload); const payload = JSON.parse(rawPayload);
const provider_payment_charge_id = ctx.message?.successful_payment?.provider_payment_charge_id; const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(payload);
const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(
payload,
provider_payment_charge_id,
);
await ctx.reply(ctx.t('msg-subscribe-success')); await ctx.reply(ctx.t('msg-subscribe-success'));
await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate })); await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate }));

View File

@ -1,9 +1,8 @@
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging'; import { logHandle } from '@/bot/helpers/logging';
import { env } from '@/config/env'; import { KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards';
import { combine } from '@/utils/messages'; import { combine } from '@/utils/messages';
import { RegistrationService } from '@repo/graphql/api/registration'; import { CustomersService } from '@repo/graphql/api/customers';
import { Composer } from 'grammy'; import { Composer } from 'grammy';
const composer = new Composer<Context>(); const composer = new Composer<Context>();
@ -13,30 +12,22 @@ const feature = composer.chatType('private');
feature.command('start', logHandle('command-start'), async (ctx) => { feature.command('start', logHandle('command-start'), async (ctx) => {
const telegramId = ctx.from.id; const telegramId = ctx.from.id;
const registrationService = new RegistrationService(); const customerService = new CustomersService({ telegramId });
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId });
if (customer) { if (customer) {
// Пользователь уже зарегистрирован — приветствуем // Пользователь уже зарегистрирован — приветствуем
return ctx.reply(ctx.t('msg-welcome-back', { name: customer.name }), { return ctx.reply(
reply_markup: mainMenu, combine(ctx.t('msg-welcome-back', { name: customer.name }), ctx.t('commands-list')),
}); {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
},
);
} }
// Новый пользователь — просим поделиться номером // Новый пользователь — просим поделиться номером
return ctx.reply( return ctx.reply(ctx.t('msg-welcome'), { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
combine(
ctx.t('msg-welcome'),
ctx.t('share-phone-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
{
...KEYBOARD_SHARE_PHONE,
parse_mode: 'HTML',
},
);
}); });
export { composer as welcome }; export { composer as welcome };

View File

@ -1,7 +0,0 @@
import { type Context } from '@/bot/context';
async function handler(ctx: Context) {
await ctx.conversation.enter('addContact');
}
export { handler as handleAddContact };

View File

@ -1,18 +0,0 @@
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { KEYBOARD_REMOVE } from '@/config/keyboards';
async function handler(ctx: Context) {
await ctx.reply(
ctx.t('agreement-links', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
{
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
},
);
}
export { handler as handleDocuments };

View File

@ -1,5 +0,0 @@
export * from './add-contact';
export * from './documents';
export * from './pro';
export * from './share-bot';
export * from './subscription';

View File

@ -1,41 +0,0 @@
import { type Context } from '@/bot/context';
import { combine } from '@/utils/messages';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
async function handler(ctx: Context) {
const telegramId = ctx?.from?.id;
if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id'));
const subscriptionsService = new SubscriptionsService({ telegramId });
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
const proEnabled = subscriptionSetting?.proEnabled;
if (!proEnabled) {
await ctx.reply(ctx.t('msg-subscribe-disabled'));
}
const { hasActiveSubscription, remainingDays, remainingOrdersCount } =
await subscriptionsService.getSubscription({ telegramId });
if (hasActiveSubscription && remainingDays > 0) {
await ctx.reply(
combine(
ctx.t('msg-subscription-active'),
ctx.t('msg-subscription-active-days-short', { days: remainingDays }),
remainingDays === 0 ? ctx.t('msg-subscription-expired') : '',
),
);
} else {
await ctx.reply(
combine(
ctx.t('msg-subscription-inactive'),
ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }),
remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '',
),
);
}
}
export { handler as handlePro };

View File

@ -1,9 +0,0 @@
import { type Context } from '@/bot/context';
import { KEYBOARD_SHARE_BOT } from '@/config/keyboards';
async function handler(ctx: Context) {
await ctx.reply(ctx.t('msg-contact-forward'), { parse_mode: 'HTML' });
await ctx.reply(ctx.t('msg-share-bot'), { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
}
export { handler as handleShareBot };

View File

@ -1,22 +0,0 @@
import { type Context } from '@/bot/context';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
async function handler(ctx: Context) {
const telegramId = ctx?.from?.id;
if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id'));
const subscriptionsService = new SubscriptionsService({ telegramId });
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
const proEnabled = subscriptionSetting?.proEnabled;
if (proEnabled) {
await ctx.conversation.enter('subscription');
} else {
await ctx.reply(ctx.t('msg-subscribe-disabled'));
}
}
export { handler as handleSubscribe };

View File

@ -5,9 +5,9 @@ import { unhandledFeature } from './features/unhandled';
import { errorHandler } from './handlers/errors'; import { errorHandler } from './handlers/errors';
import { i18n } from './i18n'; import { i18n } from './i18n';
import * as middlewares from './middlewares'; import * as middlewares from './middlewares';
import { setCommands, setInfo } from './settings'; import { setCommands } from './settings/commands';
import { setInfo } from './settings/info';
import { env } from '@/config/env'; import { env } from '@/config/env';
import { mainMenu } from '@/config/keyboards';
import { getRedisInstance } from '@/utils/redis'; import { getRedisInstance } from '@/utils/redis';
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action'; import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action';
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations'; import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
@ -51,8 +51,6 @@ export function createBot({ token }: Parameters_) {
bot.use(createConversation(conversation)); bot.use(createConversation(conversation));
} }
bot.use(mainMenu);
setInfo(bot); setInfo(bot);
setCommands(bot); setCommands(bot);

View File

@ -5,15 +5,7 @@ import { type LanguageCode } from '@grammyjs/types';
import { type Api, type Bot, type RawApi } from 'grammy'; import { type Api, type Bot, type RawApi } from 'grammy';
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) { export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
const commands = createCommands([ const commands = createCommands(['start', 'addcontact', 'sharebot', 'help', 'subscribe', 'pro']);
'start',
'addcontact',
'sharebot',
'help',
'subscribe',
'pro',
'documents',
]);
for (const command of commands) { for (const command of commands) {
addLocalizations(command); addLocalizations(command);

View File

@ -18,9 +18,6 @@ export const envSchema = z.object({
.string() .string()
.transform((value) => Number.parseInt(value, 10)) .transform((value) => Number.parseInt(value, 10))
.default('6379'), .default('6379'),
URL_FAQ: z.string(),
URL_OFFER: z.string(),
URL_PRIVACY: z.string(),
}); });
export const env = envSchema.parse(process.env); export const env = envSchema.parse(process.env);

View File

@ -1,13 +1,3 @@
import { env } from './env';
import { type Context } from '@/bot/context';
import {
handleAddContact,
handleDocuments,
handlePro,
handleShareBot,
handleSubscribe,
} from '@/bot/handlers';
import { Menu } from '@grammyjs/menu';
import { import {
type InlineKeyboardMarkup, type InlineKeyboardMarkup,
type ReplyKeyboardMarkup, type ReplyKeyboardMarkup,
@ -40,34 +30,9 @@ export const KEYBOARD_SHARE_BOT = {
[ [
{ {
text: ' Воспользоваться ботом', text: ' Воспользоваться ботом',
url: env.BOT_URL + '?start=new', url: process.env.BOT_URL as string,
}, },
], ],
], ],
} as InlineKeyboardMarkup, } as InlineKeyboardMarkup,
}; };
// Главное меню
export const mainMenu = new Menu<Context>('main-menu', { autoAnswer: true })
.text((ctx) => ctx.t('btn-add-contact'), handleAddContact)
.row()
.text((ctx) => ctx.t('btn-subscribe'), handleSubscribe)
.text((ctx) => ctx.t('btn-pro-info'), handlePro)
.row()
.text((ctx) => ctx.t('btn-share-bot'), handleShareBot)
.row()
.text((ctx) => ctx.t('btn-documents'), handleDocuments)
.row()
.url(
(ctx) => ctx.t('btn-faq'),
() => env.URL_FAQ,
)
.row()
.url(
(ctx) => ctx.t('btn-open-app'),
() => {
const botUrl = new URL(env.BOT_URL);
botUrl.searchParams.set('startapp', '');
return botUrl.toString();
},
);

View File

@ -1,9 +0,0 @@
import { type Contact } from '@grammyjs/types';
export function parseContact(contact: Contact) {
return {
name: contact?.first_name?.trim() || '',
phone: contact?.phone_number?.trim() || '',
surname: contact?.last_name?.trim() || '',
};
}

View File

@ -0,0 +1,9 @@
export function isValidPhoneNumber(phone: string) {
return /^\+7\d{10}$/u.test(phone);
}
export function normalizePhoneNumber(phone: string): string {
const digitsOnly = phone.replaceAll(/\D/gu, '');
return `+${digitsOnly}`;
}

View File

@ -1,7 +0,0 @@
.git
Dockerfile
.dockerignore
node_modules
*.log
dist
README.md

View File

@ -1,13 +0,0 @@
import { typescript } from '@repo/eslint-config/typescript';
/** @type {import("eslint").Linter.Config} */
export default [
...typescript,
{
ignores: ['**/types/**', '*.config.*', '*.config.js', '.eslintrc.js'],
rules: {
'import/no-duplicates': 'off',
'import/consistent-type-specifier-style': 'off',
},
},
];

View File

@ -1,56 +0,0 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View File

@ -1,51 +0,0 @@
ARG NODE_VERSION=22
ARG PROJECT=cache-proxy
# Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat
FROM alpine as base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache libc6-compat && \
corepack enable && \
pnpm install turbo@2.3.2 dotenv-cli --global
FROM base AS pruner
ARG PROJECT
WORKDIR /app
COPY . .
RUN turbo prune --scope=${PROJECT} --docker
FROM base AS builder
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --prod --frozen-lockfile
COPY --from=pruner /app/out/full/ .
COPY turbo.json turbo.json
COPY .env .env
RUN dotenv -e .env turbo run build --filter=${PROJECT}...
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm prune --prod --no-optional
RUN rm -rf ./**/*/src
FROM alpine AS runner
ARG PROJECT
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
USER appuser
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app .
WORKDIR /app/apps/${PROJECT}
CMD ["node", "dist/main.js"]

View File

@ -1,73 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ pnpm install
```
## Running the app
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Test
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"monorepo": true,
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -1,75 +0,0 @@
{
"name": "cache-proxy",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "dotenv -e ../../.env.local nest start -- --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/cache-manager": "^2.2.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-fastify": "^10.3.3",
"@types/node": "catalog:",
"fastify": "^4.26.1",
"dotenv-cli": "catalog:",
"cache-manager": "^5.4.0",
"cache-manager-ioredis": "^2.1.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"source-map-support": "^0.5.21",
"tsconfig-paths": "^4.2.0",
"typescript": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@nestjs/testing": "^10.0.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/jest": "^29.5.2",
"@types/supertest": "^6.0.0",
"eslint": "catalog:",
"jest": "^29.5.0",
"prettier": "catalog:",
"supertest": "^6.3.3",
"ts-jest": "29.1.1",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -1,18 +0,0 @@
import { ProxyModule } from './proxy/proxy.module';
import { HealthController } from './health/health.controller';
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
ProxyModule,
],
controllers: [HealthController],
providers: [],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class AppModule {}

View File

@ -1,3 +0,0 @@
import { seconds } from 'src/utils/time';
export const DEFAULT_CACHE_TTL = seconds().fromMinutes(5);

View File

@ -1,3 +0,0 @@
import envSchema from './schema/env';
export const env = envSchema.parse(process.env);

View File

@ -1,22 +0,0 @@
import { DEFAULT_CACHE_TTL } from '../constants';
import { z } from 'zod';
const envSchema = z.object({
CACHE_TTL: z
.string()
.transform((val) => Number.parseInt(val, 10))
.default(DEFAULT_CACHE_TTL.toString()),
PORT: z
.string()
.transform((val) => Number.parseInt(val, 10))
.default('5000'),
REDIS_HOST: z.string().default('redis'),
REDIS_PASSWORD: z.string(),
REDIS_PORT: z
.string()
.transform((value) => Number.parseInt(value, 10))
.default('6379'),
URL_GRAPHQL: z.string(),
});
export default envSchema;

View File

@ -1,11 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('api')
export class HealthController {
@Get('health')
public health() {
return { status: 'ok' };
}
}

View File

@ -1,15 +0,0 @@
import { AppModule } from './app.module';
import { env } from './config/env';
import { NestFactory } from '@nestjs/core';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { FastifyAdapter } from '@nestjs/platform-fastify';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(env.PORT, '0.0.0.0');
}
bootstrap();

View File

@ -1,16 +0,0 @@
import { seconds } from 'src/utils/time';
export const queryTTL: Record<string, number | false> = {
GetCustomer: seconds().fromHours(12),
GetCustomers: false,
GetInvited: false,
GetInvitedBy: false,
GetOrders: false,
GetServices: false,
GetSlots: false,
GetSlotsOrders: false,
GetSubscriptionHistory: false,
GetSubscriptions: false,
GetSubscriptionSettings: seconds().fromHours(12),
Login: false,
};

View File

@ -1,8 +0,0 @@
import { env } from 'src/config/env';
import { queryTTL } from './config';
export function getQueryTTL(operationName: string) {
if (operationName.includes('NOCACHE')) return false;
return queryTTL[operationName] ?? env.CACHE_TTL;
}

View File

@ -1,138 +0,0 @@
import type { GQLRequest, GQLResponse } from './types';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import {
All,
Controller,
Delete,
Get,
HttpException,
HttpStatus,
Inject,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Cache } from 'cache-manager';
import { FastifyReply, FastifyRequest } from 'fastify';
import { env } from 'src/config/env';
import { extractDocumentId, getQueryType } from 'src/utils/query';
import { getQueryTTL } from './lib/utils';
type RedisStore = Omit<Cache, 'set'> & {
set: (key: string, value: unknown, { ttl }: { ttl: number }) => Promise<void>;
};
@Controller('api')
export class ProxyController {
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: RedisStore) {}
@All('/graphql')
public async graphql(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
const { operationName, query, variables } = req.body as GQLRequest;
const queryType = getQueryType(query);
const key = `${operationName} ${JSON.stringify(variables)}`;
if (queryType.action === 'query') {
const cached = await this.cacheManager.get(key);
if (cached) return reply.send(cached);
}
const response = await fetch(env.URL_GRAPHQL, {
body: JSON.stringify({ operationName, query, variables }),
headers: {
Authorization: req.headers.authorization,
'Content-Type': 'application/json',
Cookie: req.headers.cookie,
},
method: req.method,
});
const data = (await response.json()) as GQLResponse;
if (!response.ok || data?.error || data?.errors?.length)
throw new HttpException(
response.statusText,
response.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
if (queryType.action === 'mutation' && queryType.entity) {
const documentId = extractDocumentId(data);
const keys = await this.cacheManager.store.keys(`*${queryType.entity}*`);
for (const key of keys) {
if (key.includes(documentId)) {
await this.cacheManager.del(key);
// console.log(`🗑 Cache invalidated (by key): ${key}`);
continue;
}
const value = await this.cacheManager.get(key);
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
if (serialized?.includes(documentId)) {
await this.cacheManager.del(key);
// console.log(`🗑 Cache invalidated (by value): ${key}`);
}
}
}
const ttl = getQueryTTL(operationName);
if (queryType.action === 'query' && data && ttl !== false)
await this.cacheManager.set(key, data, { ttl });
return reply.send(data);
}
@Get('/get-queries')
public async getQueriesList(@Res() reply: FastifyReply) {
const keys: string[] = await this.cacheManager.store.keys('*');
const entries = await Promise.all(
keys.map(async (key) => {
try {
const value = await this.cacheManager.get(key);
return { key, value };
} catch (e) {
return { key, error: e.message };
}
}),
);
return reply.send(entries);
}
@Delete('/delete-query')
public async deleteQuery(@Query('queryKey') queryKey: string, @Res() reply: FastifyReply) {
try {
await this.cacheManager.del(queryKey);
return reply.send('ok');
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Delete('/reset')
public async reset(@Res() reply: FastifyReply) {
try {
await this.cacheManager.reset();
return reply.send('ok');
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('/get-query')
public async getQueryValue(@Query('queryKey') queryKey: string, @Res() reply: FastifyReply) {
try {
const value = await this.cacheManager.get(queryKey);
return reply.send(value);
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}

View File

@ -1,22 +0,0 @@
import { ProxyController } from './proxy.controller';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-ioredis';
import type { RedisOptions } from 'ioredis';
import { env } from 'src/config/env';
@Module({
controllers: [ProxyController],
imports: [
CacheModule.register<RedisOptions>({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
store: redisStore,
ttl: env.CACHE_TTL,
password: env.REDIS_PASSWORD,
db: 1,
}),
],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class ProxyModule {}

View File

@ -1,16 +0,0 @@
export type GQLRequest = {
operationName: string;
query: string;
variables: string;
};
export type GQLResponse = {
data: unknown;
error?: unknown;
errors?: unknown[];
};
export type QueryItem = {
queries: string[];
ttl: number | false;
};

View File

@ -1,22 +0,0 @@
import { GQLResponse } from 'src/proxy/types';
export function getQueryType(query: string) {
const actionMatch = query.match(/\b(query|mutation)\b/u);
const action = actionMatch ? (actionMatch[1] as 'query' | 'mutation') : null;
const entityMatch = query.match(
/\b(mutation|query)\s+\w*([A-Z][A-Za-z0-9_]+)/u,
);
const entity = entityMatch ? entityMatch[2] : null;
return { action, entity };
}
export function extractDocumentId(data: GQLResponse) {
if (!data?.data) return null;
const firstKey = Object.keys(data.data)[0];
if (!firstKey) return null;
return data.data[firstKey]?.documentId || null;
}

View File

@ -1,13 +0,0 @@
export function seconds() {
return {
fromDays(days: number) {
return days * 24 * 60 * 60;
},
fromHours(hours: number) {
return hours * 60 * 60;
},
fromMinutes(minutes: number) {
return minutes * 60;
},
};
}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -1,23 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -51,14 +51,6 @@ export async function getSubscriptionPrices(
return wrapServerAction(() => service.getSubscriptionPrices(...variables)); return wrapServerAction(() => service.getSubscriptionPrices(...variables));
} }
export async function getSubscriptions(
...variables: Parameters<SubscriptionsService['getSubscriptions']>
) {
const service = await getService();
return wrapServerAction(() => service.getSubscriptions(...variables));
}
export async function getSubscriptionSettings( export async function getSubscriptionSettings(
...variables: Parameters<SubscriptionsService['getSubscriptionSettings']> ...variables: Parameters<SubscriptionsService['getSubscriptionSettings']>
) { ) {

View File

@ -2,7 +2,6 @@ import * as subscriptions from './server/subscriptions';
import { wrapClientAction } from '@/utils/actions'; import { wrapClientAction } from '@/utils/actions';
export const getSubscription = wrapClientAction(subscriptions.getSubscription); export const getSubscription = wrapClientAction(subscriptions.getSubscription);
export const getSubscriptions = wrapClientAction(subscriptions.getSubscriptions);
export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings); export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings);
export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices); export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices);
export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory); export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory);

View File

@ -2,15 +2,12 @@
import { authOptions } from '@/config/auth'; import { authOptions } from '@/config/auth';
import { getServerSession } from 'next-auth/next'; import { getServerSession } from 'next-auth/next';
import { redirect } from 'next/navigation';
export async function getSessionUser() { export async function getSessionUser() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const user = session?.user; const user = session?.user;
if (!user?.telegramId) { if (!user?.telegramId) throw new Error('Missing session');
return redirect('/');
}
return user; return user;
} }

View File

@ -10,6 +10,4 @@ export default function Page() {
redirect(isTG ? '/telegram' : '/browser'); redirect(isTG ? '/telegram' : '/browser');
}); });
return 'Redirecting...';
} }

View File

@ -1,6 +1,6 @@
import { TelegramProvider } from '@/providers/telegram'; import { TelegramProvider } from '@/providers/telegram';
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) { export default async function Layout({ children }: Readonly<PropsWithChildren>) {
return <TelegramProvider>{children}</TelegramProvider>; return <TelegramProvider>{children}</TelegramProvider>;
} }

View File

@ -1,79 +1,50 @@
/* eslint-disable promise/prefer-await-to-then */ /* eslint-disable promise/prefer-await-to-then */
'use client'; 'use client';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react'; import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react';
import { signIn, type SignInResponse, useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation'; import { redirect } from 'next/navigation';
import { useCallback, useEffect } from 'react'; import { useEffect } from 'react';
export default function Auth() { export default function Auth() {
useTelegramTheme(); useTelegramTheme();
useAuth();
useTelegramAuth(); return null;
return <LoadingSpinner />;
} }
/** function useAuth() {
* Хук для авторизации пользователя через NextAuth
*/
function useTelegramAuth() {
const initDataUser = useSignal(initData.user); const initDataUser = useSignal(initData.user);
const { status } = useSession();
const { data: session, status } = useSession();
const router = useRouter();
const handleSignInResult = useCallback(
(result: SignInResponse | undefined) => {
if (!result) return;
if (
result.error &&
(result.error.includes('CredentialsSignin') || result.error.includes('UNREGISTERED'))
) {
router.replace('/unregistered');
} else if (result.ok) {
router.replace('/profile');
}
},
[router],
);
useEffect(() => { useEffect(() => {
const telegramId = initDataUser?.id; if (!initDataUser?.id) return;
if (!telegramId) return;
if (status === 'authenticated') { if (status === 'authenticated') {
// Если telegramId есть в сессии — редирект redirect('/profile');
if (session?.user?.telegramId) {
router.replace('/profile');
} else {
// Если telegramId отсутствует — пробуем заново signIn
void signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: telegramId.toString(),
}).then(handleSignInResult);
}
return;
} }
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
void signIn('telegram', { signIn('telegram', {
callbackUrl: '/profile', callbackUrl: '/profile',
redirect: false, redirect: false,
telegramId: telegramId.toString(), telegramId: initDataUser.id.toString(),
}).then(handleSignInResult); }).then((result) => {
if (
result?.error &&
(result?.error?.includes('CredentialsSignin') || result?.error?.includes('UNREGISTERED'))
) {
// Пользователь не зарегистрирован
redirect('/unregistered');
} else if (result?.ok) {
redirect('/profile');
}
});
} }
}, [initDataUser?.id, status, session?.user?.telegramId, router, handleSignInResult]); }, [initDataUser?.id, status]);
} }
/**
* Хук для установки темы из Telegram Mini App
*/
function useTelegramTheme() { function useTelegramTheme() {
const isDark = isMiniAppDark(); const isDark = isMiniAppDark();
const { setTheme } = useTheme(); const { setTheme } = useTheme();

View File

@ -1,6 +0,0 @@
import { DocumentsLayout } from '@/components/documents/layout';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <DocumentsLayout title="Публичная оферта">{children}</DocumentsLayout>;
}

View File

@ -1,99 +0,0 @@
import { env } from '@/config/env';
export const metadata = {
title: 'Публичная оферта',
description: 'Публичная оферта бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
};
### Договор-оферта на использование сервиса «Запишись.онлайн» (@zapishis_online_bot)
Настоящий документ является публичной офертой в соответствии с пунктом 2 статьи 437 Гражданского кодекса Российской Федерации и представляет собой предложение индивидуального предпринимателя (самозанятого) — далее именуемого «Администрация», заключить Договор на использование Сервиса (далее «Договор», «Оферта») с любым физическим лицом, принявшим условия настоящей Оферты (далее «Пользователь»).
#### 1. Термины и определения
1.1. Оферта — настоящий документ, постоянно размещенный в сети Интернет по адресу <a href={env.URL_OFFER}>{env.URL_OFFER}</a>.
1.2. Акцепт — полное и безоговорочное принятие условий Оферты Пользователем путем оплаты доступа через встроенный платежный бот ЮKassa в Telegram.
1.3. Сервис — Telegram-бот и мини-приложение, позволяющее пользователям создавать и принимать заказы, управлять расписанием и взаимодействовать друг с другом без необходимости регистрации.
1.4. Администрация — самозанятое лицо, являющееся разработчиком и правообладателем Сервиса.
1.5. Пользователь — любое физическое лицо, использующее Сервис в личных или профессиональных целях.
1.6. Доступ — право использования функционала Сервиса на определённый оплаченный период (например, неделя, месяц, год).
1.7. Оплата — денежные средства, перечисленные Пользователем через платёжный бот ЮKassa в Telegram.
#### 2. Акцепт оферты и заключение договора
2.1. Акцептом настоящей Оферты считается оплата Пользователем доступа к Сервису любым доступным способом.
2.2. С момента совершения оплаты Пользователь считается заключившим Договор с Администрацией на условиях, изложенных в настоящей Оферте.
2.3. Пользователь подтверждает, что ему понятны все условия настоящей Оферты и он принимает их без ограничений.
#### 3. Предмет договора
3.1. Администрация предоставляет Пользователю неисключительное право (доступ) на использование функционала Сервиса в пределах оплаченного периода времени.
3.2. Сервис предоставляется в онлайн-формате через Telegram-бота без установки дополнительного программного обеспечения.
3.3. Пользователь получает право использовать функционал Сервиса в личных целях, в том числе для организации и планирования заказов, встреч и тренировок.
#### 4. Порядок оплаты и использование
4.1. Оплата производится через встроенные инструменты Telegram-бота с использованием платёжной системы ЮKassa.
4.2. Комиссия платёжной системы включена в итоговую стоимость. Администрация не взимает дополнительных платежей.
4.3. Доступ активируется автоматически после успешного подтверждения оплаты.
4.4. Пользователь может продлить доступ путём повторной оплаты. Автоматическое продление не применяется.
4.5. Возврат денежных средств возможен только в случае технических ошибок, по письменному обращению на адрес поддержки.
#### 5. Права и обязанности сторон
5.1. Пользователь обязуется:
- не использовать Сервис в противоправных целях;
- не вмешиваться в работу Сервиса и не предпринимать действий, направленных на нарушение его функционирования;
- предоставлять достоверную информацию при оплате и использовании Сервиса;
- при добавлении контактов других лиц (например, клиентов, мастеров) гарантировать, что у него есть согласие этих лиц на передачу и обработку их персональных данных в рамках Сервиса;
5.2. Администрация обязуется:
- обеспечивать бесперебойную работу Сервиса, за исключением периодов технического обслуживания;
- обрабатывать персональные данные Пользователей в соответствии с Политикой конфиденциальности;
- принимать обращения и запросы Пользователей по вопросам работы Сервиса. 6. Ответственность сторон;
6.1. Сервис предоставляется «как есть». Администрация не несёт ответственности за временные сбои, потерю данных или недоступность Сервиса, возникшие по причинам, не зависящим от неё.
6.2. Пользователь несёт полную ответственность за корректность совершаемых платежей и действий, совершаемых через свой Telegram-аккаунт.
#### 7. Обработка персональных данных
7.1. Администрация обрабатывает персональные данные Пользователя в соответствии с Федеральным законом №152-ФЗ «О персональных данных» и Политикой конфиденциальности.
7.2. Использование Сервиса означает согласие Пользователя на обработку его персональных данных.
#### 8. Срок действия и расторжение договора
8.1. Договор вступает в силу с момента оплаты доступа и действует в течение оплаченного периода.
8.2. Пользователь может прекратить использование Сервиса в любое время без возврата оплаченных средств.
8.3. Администрация вправе приостановить доступ в случае нарушения Пользователем условий настоящей Оферты.
#### 9. Заключительные положения
9.1. Настоящий Договор регулируется законодательством Российской Федерации.
9.2. Все споры и разногласия решаются путём переговоров, а при недостижении соглашения — в судебном порядке по месту нахождения Администрации.
9.3. Администрация оставляет за собой право изменять условия Оферты с размещением новой редакции на сайте.
#### 10. Контакты
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -1,6 +0,0 @@
import { DocumentsLayout } from '@/components/documents/layout';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <DocumentsLayout title="Политика конфиденциальности">{children}</DocumentsLayout>;
}

View File

@ -1,131 +0,0 @@
export const metadata = {
title: 'Политика конфиденциальности',
description:
'Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
};
### Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)
#### 1. Термины и определения
- **Telegram** Telegram Messenger Inc. (платформа, на которой работает бот и мини-приложение).
- **Платформа** экосистема ботов и мини-приложений Telegram.
- **Разработчик** физическое лицо, самозанятый, владелец и оператор сервиса «Запишись.онлайн» (@zapishis_online_bot) - (далее — «Разработчик»).
- **Сторонний сервис** бот/мини-приложение Разработчика, предоставляемое в Платформе.
- **Пользователь** лицо, использующее Сторонний сервис через свою учетную запись Telegram (далее — «Вы»).
- **Политика** настоящий документ, регулирующий отношения между Разработчиком и Пользователем в части сбора и - обработки персональных данных.
#### 2. Общие положения
2.1. Настоящая Политика регулирует исключительно отношения между Разработчиком и Пользователем. Она не заменяет и не изменяет Политику конфиденциальности Telegram: [https://telegram.org/privacy](https://telegram.org/privacy).
2.2. Разработчик соблюдает применимые требования платформы Telegram к конфиденциальности и защите данных.
2.3. Использование Сервиса Пользователем и/или активация платного доступа означает согласие Пользователя с условиями настоящей Политики.
2.4. Если Вы не согласны с условиями Политики — прекратите использование Сервиса.
#### 3. Отказ от ответственности
3.1. Сторонний сервис является независимым приложением и не поддерживается, не одобряется и не аффилирован с Telegram (за исключением использования API и инфраструктуры Telegram).
3.2. Разработчик вправе изменять настоящую Политику — изменения вступают в силу с момента их публикации. Вы обязаны самостоятельно отслеживать обновления.
3.3. Используя Сервис, Вы подтверждаете, что ознакомлены и согласны с условиями использования Telegram для ботов и мини-приложений: [https://telegram.org/tos/bots](https://telegram.org/tos/bots), [https://telegram.org/tos/mini-apps](https://telegram.org/tos/mini-apps).
3.4. Вы гарантируете, что используете Сервис в соответствии с действующим законодательством и обладаете правом взаимодействовать с ним (например, достигли возраста, необходимого для использования услуг).
3.5. Вы обязуетесь предоставлять точную и актуальную информацию, если Сервис запрашивает её.
3.6. Любая информация, которую Вы делаете общедоступной самостоятельно (через профиль Telegram, публичные сообщения и т.п.), может стать доступна другим пользователям и не подпадает под защиту настоящей Политики в части конфиденциальности этой общедоступной информации.
#### 4. Сбор персональных данных
4.1. Telegram по умолчанию предоставляет сторонним сервисам ограниченный набор данных о Пользователе — подробнее: [https://telegram.org/privacy#6-bot-messages](https://telegram.org/privacy#6-bot-messages).
4.2. Сторонний сервис может дополнительно получать данные, которые Вы передаёте в чате бота или в мини-приложении (например, контакт, телефон), если Вы явно их отправляете.
4.3. В случае мини-приложения дополнительно могут передаваться данные в соответствии с правилами мини-приложений Telegram: [https://telegram.org/tos/mini-apps#4-privacy](https://telegram.org/tos/mini-apps#4-privacy).
4.4. Сторонний сервис может собирать также анонимную статистику использования (диагностика, события взаимодействия), не связываемую напрямую с персоной.
4.5. Пользователь может передавать данные третьих лиц (например, контактные данные клиентов или мастеров) для использования в Сервисе. При этом пользователь гарантирует, что эти лица дали согласие на обработку их персональных данных в рамках Сервиса.
#### 5. Какие данные мы собираем и как используем
5.1. Разработчик запрашивает, собирает и обрабатывает только те данные, которые необходимы для корректной работы функций Сервиса, в частности:
- Telegram ID и (опционально) отображаемое имя пользователя;
- телефон, только если Вы предоставили его добровольно (например, при регистрации);
- данные о заказах: дата/время, описание заказа, статус;
- информация о факте покупки Pro-доступа: период доступа, тип покупки (детали платёжной транзакции обрабатывает платёжный оператор — ЮKassa);
5.2. Цели обработки:
- предоставление и поддержка работы Сервиса (создание заказов, напоминания, управление доступом);
- подтверждение и учет оплат (взаимодействие с платёжным оператором для актуализации статуса доступа);
- реализация реферальной программы (хранение связей «кто пригласил/кого пригласили»);
- анализ использования и улучшение сервиса;
- выполнение юридических обязательств (хранение информации о транзакциях и др.);
> **Важно:** детальные платёжные данные (реквизиты карт и т.д.) не хранятся у Разработчика — их обрабатывает платёжный оператор (ЮKassa) и Telegram-платежный бот.
#### 6. Передача данных третьим лицам
6.1. Разработчик не передаёт персональные данные третьим лицам, за исключением следующих случаев:
- платёжному оператору (ЮKassa) и связанным службам для обработки платежей;
- Telegram как платформе для функционирования бота и мини-приложения;
- в случае необходимости — исполнителям, оказывающим техническую поддержку, при условии подписания ими обязательств о конфиденциальности;
- если передача требуется по закону (запросы уполномоченных органов и т.п.);
6.2. Разработчик не продаёт и не передаёт персональные данные для рекламных целей третьим лицам без Вашего отдельного согласия.
#### 7. Защита и хранение данных
7.1. Разработчик применяет разумные технические и организационные меры для защиты персональных данных (использование надежного VPS, ограничения доступа, резервное копирование и т.п.).
7.2. Доступ к персональным данным имеет только Разработчик (и/или доверенные исполнители технической поддержки при необходимости).
7.3. Данные хранятся на серверах, указанных Разработчиком. Если используются внешние сервисы/облачные хранилища — это будет указано в соответствующих местах Политики или сообщения при сборе данных.
#### 8. Права и обязанности сторон
8.1. Права Разработчика:
- вносить изменения в Политику с публикацией новой версии;
- ограничивать доступ к API/сервису при подозрении в злоупотреблениях;
- запросить подтверждение личности при необходимости обработки привилегированных запросов;
8.2. Обязанности Разработчика:
- обеспечивать доступность Политики и исполнять её условия;
- обрабатывать законные запросы пользователей о доступе, изменении или удалении данных в разумные сроки (не позднее 30 дней, если иное не установлено законом);
- соблюдать применимое законодательство о защите персональных данных;
8.3. Права Пользователя:
- запросить копию своих персональных данных, хранящихся у Разработчика;
- потребовать исправления неточных данных;
- потребовать удаления персональных данных в пределах, допустимых законом (с сохранением данных, необходимых для выполнения юридических обязательств, например, по учёту платежей);
- отозвать согласие на обработку персональных данных, если такое согласие предоставлялось добровольно;
- подать жалобу в уполномоченные органы по защите персональных данных, если считает, что его права нарушены;
8.4. Обязанности Пользователя:
- предоставлять точную и актуальную информацию;
- не использовать Сервис в нарушении законодательства и условий Telegram.
#### 9. Реклама и использование данных для аналитики
9.1. На текущем этапе Разработчик не использует персональные данные для демонстрации таргетированной рекламы третьих лиц без явного согласия Пользователя.
9.2. Разработчик может собирать агрегированную (анонимную) статистику использования Сервиса для улучшения функционала.
#### 10. Изменения Политики
10.1. Разработчик вправе вносить изменения в настоящую Политику. Все изменения публикуются на этой странице и вступают в силу с момента публикации.
#### 11. Контакты
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -1,3 +1,4 @@
import { getOrder } from '@/actions/api/orders';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { import {
@ -8,14 +9,23 @@ import {
OrderStatus, OrderStatus,
} from '@/components/orders'; } from '@/components/orders';
import { type OrderPageParameters } from '@/components/orders/types'; import { type OrderPageParameters } from '@/components/orders/types';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<OrderPageParameters> }; type Props = { params: Promise<OrderPageParameters> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params; const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Запись" /> <PageHeader title="Запись" />
<Container> <Container>
<OrderDateTime {...parameters} /> <OrderDateTime {...parameters} />
@ -25,6 +35,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
<div className="pb-24" /> <div className="pb-24" />
<OrderButtons {...parameters} /> <OrderButtons {...parameters} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -2,7 +2,7 @@ import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { OrderForm } from '@/components/orders'; import { OrderForm } from '@/components/orders';
export default function AddOrdersPage() { export default async function AddOrdersPage() {
return ( return (
<> <>
<PageHeader title="Новая запись" /> <PageHeader title="Новая запись" />

View File

@ -1,44 +1,25 @@
import { import { getSubscription } from '@/actions/api/subscriptions';
getSubscription,
getSubscriptionPrices,
getSubscriptions,
} from '@/actions/api/subscriptions';
import { getSessionUser } from '@/actions/session'; import { getSessionUser } from '@/actions/session';
import { PageHeader } from '@/components/navigation';
import { TryFreeButton } from '@/components/subscription'; import { TryFreeButton } from '@/components/subscription';
import { env } from '@/config/env'; import { env } from '@/config/env';
import { Enum_Subscriptionprice_Period as SubscriptionPricePeriod } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button'; import { Button } from '@repo/ui/components/ui/button';
import { formatMoney } from '@repo/utils/money';
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react'; import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
export default async function ProPage() { export default async function ProPage() {
const { telegramId } = await getSessionUser(); const { telegramId } = await getSessionUser();
const { hasActiveSubscription, usedTrialSubscription } = await getSubscription({
const { subscriptions } = await getSubscriptions({ telegramId,
filters: { customer: { telegramId: { eq: telegramId } } },
}); });
const hasActiveSubscription = subscriptions?.length const canUseTrial = !usedTrialSubscription;
? ((await getSubscription({ telegramId }))?.hasActiveSubscription ?? false)
: false;
const canUseTrial = !subscriptions?.length;
const { subscriptionPrices = [] } = await getSubscriptionPrices({
filters: {
active: { eq: true },
period: { ne: SubscriptionPricePeriod.Trial },
},
});
const botUrl = new URL(env.BOT_URL);
botUrl.searchParams.set('start', 'pro');
return ( 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"> <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 */} {/* Hero Section */}
<div className="px-4 py-8 sm:px-6 lg:px-8"> <div className="px-4 py-16 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl text-center"> <div className="mx-auto max-w-4xl text-center">
<div className="mb-2 flex justify-center"> <div className="mb-2 flex justify-center">
<div className="relative"> <div className="relative">
@ -49,14 +30,14 @@ export default async function ProPage() {
</div> </div>
</div> </div>
<h1 className="mb-4 text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl"> <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"> <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 Pro
</span>{' '} </span>
Доступ
</h1> </h1>
<p className="mx-auto mb-6 max-w-2xl text-xl text-gray-600 dark:text-gray-300"> <p className="mx-auto mb-8 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
{hasActiveSubscription {hasActiveSubscription
? 'Ваш Pro доступ активен!' ? 'Ваш Pro доступ активен!'
: 'Разблокируйте больше возможностей'} : 'Разблокируйте больше возможностей'}
@ -76,7 +57,7 @@ export default async function ProPage() {
size="lg" size="lg"
variant={canUseTrial ? 'outline' : 'default'} variant={canUseTrial ? 'outline' : 'default'}
> >
<Link href={botUrl.toString()} rel="noopener noreferrer" target="_blank"> <Link href={env.BOT_URL} rel="noopener noreferrer" target="_blank">
Приобрести Pro доступ через бота Приобрести Pro доступ через бота
<ArrowRight className="ml-2 size-5" /> <ArrowRight className="ml-2 size-5" />
</Link> </Link>
@ -85,7 +66,7 @@ export default async function ProPage() {
)} )}
<div className="mx-auto mt-12 max-w-2xl"> <div className="mx-auto mt-12 max-w-2xl">
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="mb-6 text-center text-2xl font-bold text-gray-900 dark:text-white">
Преимущества Преимущества
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
@ -108,47 +89,6 @@ export default async function ProPage() {
</div> */} </div> */}
</div> </div>
</div> </div>
{subscriptionPrices?.length > 0 && (
<div className="mx-auto mt-12 max-w-2xl">
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white">
Цены
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{subscriptionPrices.map((price) => (
<div
className={`relative rounded-xl border bg-white/60 p-4 text-left dark:bg-slate-800/60 ${
price?.period === SubscriptionPricePeriod.Month
? 'border-2 border-purple-400'
: 'border-gray-200 dark:border-gray-700'
}`}
key={price?.documentId}
>
{price?.period === SubscriptionPricePeriod.Month && (
<div className="absolute -top-2 right-3 rounded-full bg-purple-600 px-2 py-0.5 text-xs font-semibold text-white dark:bg-purple-500">
Популярный
</div>
)}
<div className="flex items-baseline justify-between">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{formatMoney(price?.amount ?? 0)}
</div>
{typeof price?.days === 'number' && (
<div className="text-sm text-gray-600 dark:text-gray-300">
{price.days} дн.
</div>
)}
</div>
{price?.description && (
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{price.description}
</div>
)}
</div>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,10 @@
import { getCustomer } from '@/actions/api/customers';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile'; import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
import { ProfileButtons } from '@/components/profile/profile-buttons';
import { ReadonlyServicesList } from '@/components/profile/services'; import { ReadonlyServicesList } from '@/components/profile/services';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
// Тип параметров страницы // Тип параметров страницы
type Props = { params: Promise<{ telegramId: string }> }; type Props = { params: Promise<{ telegramId: string }> };
@ -10,18 +12,33 @@ type Props = { params: Promise<{ telegramId: string }> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const { telegramId } = await props.params; const { telegramId } = await props.params;
const contactTelegramId = Number(telegramId); const contactTelegramId = Number(telegramId);
const queryClient = new QueryClient();
// Получаем профиль контакта
const { customer: profile } = await queryClient.fetchQuery({
queryFn: () => getCustomer({ telegramId: contactTelegramId }),
queryKey: ['customer', contactTelegramId],
});
// Получаем текущего пользователя
const sessionUser = await getSessionUser();
const { customer: currentUser } = await queryClient.fetchQuery({
queryFn: () => getCustomer({ telegramId: sessionUser.telegramId }),
queryKey: ['customer', sessionUser.telegramId],
});
// Проверка наличия данных
if (!profile || !currentUser) return null;
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Профиль контакта" /> <PageHeader title="Профиль контакта" />
<Container className="px-0"> <Container className="px-0">
<PersonCard telegramId={contactTelegramId} /> <PersonCard telegramId={contactTelegramId} />
<ContactDataCard telegramId={contactTelegramId} /> <ContactDataCard telegramId={contactTelegramId} />
<ReadonlyServicesList telegramId={contactTelegramId} /> <ReadonlyServicesList masterId={profile.documentId} />
<ProfileOrdersList telegramId={contactTelegramId} /> <ProfileOrdersList telegramId={contactTelegramId} />
<div className="pb-24" />
<ProfileButtons telegramId={contactTelegramId} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -1,13 +1,34 @@
import { getCustomer } from '@/actions/api/customers';
import { getSubscriptionSettings } from '@/actions/api/subscriptions';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { LinksCard, PersonCard, ProfileDataCard, SubscriptionInfoBar } 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({
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
const { subscriptionSetting } = await queryClient.fetchQuery({
queryFn: getSubscriptionSettings,
queryKey: ['customer', telegramId],
});
const proEnabled = subscriptionSetting?.proEnabled;
export default function ProfilePage() {
return ( return (
<Container className="px-0"> <HydrationBoundary state={dehydrate(queryClient)}>
<PersonCard /> <Container className="px-0">
<SubscriptionInfoBar /> <PersonCard />
<ProfileDataCard /> {proEnabled && <SubscriptionInfoBar />}
<LinksCard /> <ProfileDataCard />
</Container> <LinksCard />
</Container>
</HydrationBoundary>
); );
} }

View File

@ -1,16 +1,26 @@
import { getSlot } from '@/actions/api/slots';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule'; import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
import { type SlotPageParameters } from '@/components/schedule/types'; import { type SlotPageParameters } from '@/components/schedule/types';
import { BookButton } from '@/components/shared/book-button'; import { BookButton } from '@/components/shared/book-button';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<SlotPageParameters> }; type Props = { params: Promise<SlotPageParameters> };
export default async function SlotPage(props: Readonly<Props>) { export default async function SlotPage(props: Readonly<Props>) {
const parameters = await props.params; const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getSlot({ documentId }),
queryKey: ['slot', documentId],
});
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Слот" /> <PageHeader title="Слот" />
<Container> <Container>
<SlotDateTime {...parameters} /> <SlotDateTime {...parameters} />
@ -19,6 +29,6 @@ export default async function SlotPage(props: Readonly<Props>) {
<div className="pb-24" /> <div className="pb-24" />
<SlotButtons {...parameters} /> <SlotButtons {...parameters} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -1,20 +1,28 @@
import { getService } from '@/actions/api/services';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { ServiceButtons, ServiceDataCard } from '@/components/profile/services'; import { ServiceButtons, ServiceDataCard } from '@/components/profile/services';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
// Тип параметров страницы // Тип параметров страницы
type Props = { params: Promise<{ serviceId: string }> }; type Props = { params: Promise<{ serviceId: string }> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const { serviceId } = await props.params; const { serviceId } = await props.params;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getService({ documentId: serviceId }),
queryKey: ['service', serviceId],
});
return ( return (
<> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Услуга" /> <PageHeader title="Услуга" />
<Container className="px-0"> <Container className="px-0">
<ServiceDataCard serviceId={serviceId} /> <ServiceDataCard serviceId={serviceId} />
<ServiceButtons serviceId={serviceId} /> <ServiceButtons serviceId={serviceId} />
</Container> </Container>
</> </HydrationBoundary>
); );
} }

View File

@ -2,9 +2,11 @@ import { AuthProvider } from '@/providers/auth';
import { ErrorProvider } from '@/providers/error'; import { ErrorProvider } from '@/providers/error';
import { QueryProvider } from '@/providers/query'; import { QueryProvider } from '@/providers/query';
import { ThemeProvider } from '@/providers/theme-provider'; import { ThemeProvider } from '@/providers/theme-provider';
import { I18nProvider } from '@/utils/i18n/provider';
import '@repo/ui/globals.css'; import '@repo/ui/globals.css';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { type Metadata } from 'next'; import { type Metadata } from 'next';
import { getLocale } from 'next-intl/server';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
@ -15,15 +17,19 @@ export const metadata: Metadata = {
}; };
export default async function RootLayout({ children }: Readonly<PropsWithChildren>) { export default async function RootLayout({ children }: Readonly<PropsWithChildren>) {
const locale = await getLocale();
return ( return (
<html lang="ru"> <html lang={locale}>
<body className={cn(inter.className, 'flex min-h-screen flex-col bg-app-background')}> <body className={cn(inter.className, 'flex min-h-screen flex-col bg-app-background')}>
<ErrorProvider> <ErrorProvider>
<ThemeProvider> <I18nProvider>
<AuthProvider> <ThemeProvider>
<QueryProvider>{children}</QueryProvider> <AuthProvider>
</AuthProvider> <QueryProvider>{children}</QueryProvider>
</ThemeProvider> </AuthProvider>
</ThemeProvider>
</I18nProvider>
</ErrorProvider> </ErrorProvider>
</body> </body>
</html> </html>

View File

@ -1,27 +1,25 @@
'use client'; 'use client';
import { useCustomerMutation } from '@/hooks/api/customers'; import { useCustomerMutation } from '@/hooks/api/customers';
import { useClientOnce } from '@/hooks/telegram';
import { initData, useSignal } from '@telegram-apps/sdk-react'; import { initData, useSignal } from '@telegram-apps/sdk-react';
import { useEffect, useState } from 'react';
export function UpdateProfile() { export function UpdateProfile() {
const initDataUser = useSignal(initData.user); const initDataUser = useSignal(initData.user);
const { mutate: updateProfile } = useCustomerMutation(); const { mutate: updateProfile } = useCustomerMutation();
const [hasUpdated, setHasUpdated] = useState(false);
useClientOnce(() => { useEffect(() => {
if ( if (!hasUpdated) {
localStorage.getItem('firstLogin') === null ||
localStorage.getItem('firstLogin') === 'true'
) {
updateProfile({ updateProfile({
data: { data: {
active: true, active: true,
photoUrl: initDataUser?.photoUrl || undefined, photoUrl: initDataUser?.photoUrl || undefined,
}, },
}); });
localStorage.setItem('firstLogin', 'false'); setHasUpdated(true);
} }
}); }, [hasUpdated, initDataUser?.photoUrl, updateProfile]);
return null; return null;
} }

View File

@ -1,18 +0,0 @@
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { type PropsWithChildren } from 'react';
export function DocumentsLayout({
children,
title,
}: Readonly<PropsWithChildren> & { readonly title: string }) {
return (
<>
<PageHeader title={title} />
<Container className="prose prose-neutral md:prose-lg dark:prose-invert max-w-none">
{children}
</Container>
<div className="h-10" />
</>
);
}

View File

@ -1,17 +0,0 @@
import { env } from '@/config/env';
export function OfferLink() {
return (
<a href={env.URL_OFFER} rel="noreferrer" target="_blank">
{env.URL_OFFER}
</a>
);
}
export function SupportLink() {
return (
<a href={env.SUPPORT_TELEGRAM_URL} rel="noreferrer" target="_blank">
{env.SUPPORT_TELEGRAM_URL}
</a>
);
}

View File

@ -1,5 +1,4 @@
'use client'; 'use client';
import { BackButton } from './back-button'; import { BackButton } from './back-button';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { isTMA } from '@telegram-apps/sdk-react'; import { isTMA } from '@telegram-apps/sdk-react';
@ -7,16 +6,16 @@ import { isTMA } from '@telegram-apps/sdk-react';
type Props = { title: string | undefined }; type Props = { title: string | undefined };
export function PageHeader(props: Readonly<Props>) { export function PageHeader(props: Readonly<Props>) {
const hideBackButton = process.env.NODE_ENV === 'production' || isTMA('simple'); const isTG = isTMA('simple');
return ( return (
<div <div
className={cn( className={cn(
'sticky top-0 z-50 flex h-12 items-center rounded-b-lg bg-transparent font-bold tracking-wide backdrop-blur-md', 'sticky top-0 z-50 flex h-12 items-center rounded-b-lg bg-transparent font-bold tracking-wide backdrop-blur-md',
hideBackButton ? 'px-4' : 'px-2', isTG ? 'px-4' : 'px-2',
)} )}
> >
{!hideBackButton && <BackButton />} {!isTG && <BackButton />}
{props.title} {props.title}
</div> </div>
); );

View File

@ -1,29 +1,25 @@
'use client'; 'use client';
import { DataNotFound } from '../shared/alert';
import { ContactRow } from '../shared/contact-row'; import { ContactRow } from '../shared/contact-row';
import { type OrderComponentProps } from './types'; import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders'; import { useOrderQuery } from '@/hooks/api/orders';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) { export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
const noContacts = !order?.slot?.master && !order?.client; if (!order) return null;
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<h1 className="font-bold">Участники</h1> <h1 className="font-bold">Участники</h1>
<div className="space-y-2"> <div className="space-y-2">
{isLoading && <LoadingSpinner />} {order.slot?.master && (
{!isLoading && noContacts ? <DataNotFound title="Пользователи не найдены" /> : null}
{order?.slot?.master && (
<ContactRow <ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5" className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
description="Мастер" description="Мастер"
{...order.slot?.master} {...order.slot?.master}
/> />
)} )}
{order?.client && ( {order.client && (
<ContactRow <ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5" className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
description="Клиент" description="Клиент"

View File

@ -6,16 +6,7 @@ import { useOrderQuery } from '@/hooks/api/orders';
import { formatDate } from '@repo/utils/datetime-format'; import { formatDate } from '@repo/utils/datetime-format';
export function OrderDateTime({ documentId }: Readonly<OrderComponentProps>) { export function OrderDateTime({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
if (isLoading) {
return (
<div className="flex animate-pulse flex-col space-y-1">
<div className="h-5 w-28 rounded bg-muted" />
<div className="h-9 w-48 rounded bg-muted" />
</div>
);
}
if (!order) return null; if (!order) return null;

View File

@ -12,7 +12,6 @@ import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label'; import { Label } from '@repo/ui/components/ui/label';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import { sift } from 'radashi'; import { sift } from 'radashi';
type ContactsGridProps = { type ContactsGridProps = {
@ -26,12 +25,8 @@ type ContactsGridProps = {
readonly title: string; readonly title: string;
}; };
type UseContactsProps = Partial<{
showInactive: boolean;
}>;
export function ClientsGrid() { export function ClientsGrid() {
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts({ showInactive: true }); const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
const clientId = useOrderStore((store) => store.clientId); const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId); const setClientId = useOrderStore((store) => store.setClientId);
@ -108,7 +103,7 @@ export function ContactsGridBase({
isCurrentUser && 'font-bold', isCurrentUser && 'font-bold',
)} )}
> >
{getCustomerFullName(contact)} {contact.name}
</span> </span>
</Label> </Label>
); );
@ -149,7 +144,7 @@ export function MastersGrid() {
); );
} }
function useContacts({ showInactive = false }: UseContactsProps = {}) { function useContacts() {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery(); const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
const { const {
@ -160,13 +155,13 @@ function useContacts({ showInactive = false }: UseContactsProps = {}) {
const isLoading = isLoadingContacts || isLoadingCustomer; const isLoading = isLoadingContacts || isLoadingCustomer;
const contacts = sift(pages.flatMap((page) => page.customers)); const contacts = sift(
pages.flatMap((page) => page.customers).filter((contact) => Boolean(contact && contact.active)),
);
return { return {
isLoading, isLoading,
...query, ...query,
contacts: [{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment].concat( contacts: [{ ...customer, name: 'Я' } as CustomerFieldsFragment, ...contacts],
showInactive ? contacts : contacts.filter((contact) => contact.active),
),
}; };
} }

View File

@ -1,20 +1,18 @@
'use client'; 'use client';
import { DataNotFound } from '../shared/alert';
import { ServiceCard } from '../shared/service-card'; import { ServiceCard } from '../shared/service-card';
import { type OrderComponentProps } from './types'; import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders'; import { useOrderQuery } from '@/hooks/api/orders';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function OrderServices({ documentId }: Readonly<OrderComponentProps>) { export function OrderServices({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
if (!order) return null;
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<h1 className="font-bold">Услуги</h1> <h1 className="font-bold">Услуги</h1>
{isLoading && <LoadingSpinner />} {order.services?.map(
{!isLoading && !order?.services?.length ? <DataNotFound title="Услуги не найдены" /> : null}
{order?.services?.map(
(service) => service && <ServiceCard key={service.documentId} {...service} />, (service) => service && <ServiceCard key={service.documentId} {...service} />,
)} )}
</div> </div>

View File

@ -5,14 +5,7 @@ import { getAlert } from '@/components/shared/status';
import { useOrderQuery } from '@/hooks/api/orders'; import { useOrderQuery } from '@/hooks/api/orders';
export function OrderStatus({ documentId }: Readonly<OrderComponentProps>) { export function OrderStatus({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {}, isLoading } = useOrderQuery({ documentId }); const { data: { order } = {} } = useOrderQuery({ documentId });
if (isLoading)
return (
<div className="flex animate-pulse flex-col space-y-1">
<div className="h-10 w-full rounded bg-muted" />
</div>
);
return order?.state && getAlert(order.state); return order?.state && getAlert(order.state);
} }

View File

@ -12,18 +12,7 @@ import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) { export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId }); const { data: { customer } = {} } = useCustomerQuery({ telegramId });
if (isLoading)
return (
<Card className="p-4">
<div className="flex animate-pulse flex-col gap-4">
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-6 w-32 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
</div>
</Card>
);
if (!customer) return null; if (!customer) return null;
@ -44,26 +33,10 @@ export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
} }
export function ProfileDataCard() { export function ProfileDataCard() {
const { data: { customer } = {}, isLoading } = useCustomerQuery(); const { data: { customer } = {} } = useCustomerQuery();
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } = const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
useProfileEdit(); useProfileEdit();
if (isLoading)
return (
<Card className="p-4">
<div className="flex animate-pulse flex-col gap-4">
<div className="h-6 w-32 rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-5 w-60 rounded bg-muted" />
</div>
</Card>
);
if (!customer) return null; if (!customer) return null;
return ( return (
@ -77,13 +50,6 @@ export function ProfileDataCard() {
onChange={(value) => updateField('name', value)} onChange={(value) => updateField('name', value)}
value={customer?.name ?? ''} value={customer?.name ?? ''}
/> />
<TextField
id="surname"
key={`surname-${resetTrigger}`}
label="Фамилия"
onChange={(value) => updateField('surname', value)}
value={customer?.surname ?? ''}
/>
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} /> <TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
<CheckboxWithText <CheckboxWithText
checked={customer.role !== 'client'} checked={customer.role !== 'client'}

View File

@ -4,19 +4,16 @@ import { type ProfileProps } from './types';
import { UserAvatar } from '@/components/shared/user-avatar'; import { UserAvatar } from '@/components/shared/user-avatar';
import { useCustomerQuery } from '@/hooks/api/customers'; import { useCustomerQuery } from '@/hooks/api/customers';
import { Card } from '@repo/ui/components/ui/card'; import { Card } from '@repo/ui/components/ui/card';
import { getCustomerFullName } from '@repo/utils/customer'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function PersonCard({ telegramId }: Readonly<ProfileProps>) { export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId }); const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
if (isLoading) if (isLoading)
return ( return (
<Card className="bg-transparent p-4 shadow-none"> <div className="p-4">
<div className="flex animate-pulse flex-col items-center space-y-2"> <LoadingSpinner />
<div className="size-32 rounded-full bg-muted" /> </div>
<div className="h-6 w-40 rounded bg-muted" />
</div>
</Card>
); );
if (!customer) return null; if (!customer) return null;
@ -25,7 +22,7 @@ export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
<Card className="bg-transparent p-4 shadow-none"> <Card className="bg-transparent p-4 shadow-none">
<div className="flex flex-col items-center space-y-2"> <div className="flex flex-col items-center space-y-2">
<UserAvatar {...customer} size="lg" /> <UserAvatar {...customer} size="lg" />
<h2 className="text-2xl font-bold">{getCustomerFullName(customer)}</h2> <h2 className="text-2xl font-bold">{customer?.name}</h2>
</div> </div>
</Card> </Card>
); );

View File

@ -1,35 +0,0 @@
/* eslint-disable canonical/id-match */
'use client';
import FloatingActionPanel from '@/components/shared/action-panel';
import { useCustomerQuery } from '@/hooks/api/customers';
import { usePushWithData } from '@/hooks/url';
import { Enum_Customer_Role } from '@repo/graphql/types';
type QuickAppointmentProps = {
readonly telegramId: number;
};
export function ProfileButtons({ telegramId }: Readonly<QuickAppointmentProps>) {
const push = usePushWithData();
const { data: { customer: profile } = {}, isLoading: isLoadingProfile } = useCustomerQuery({
telegramId,
});
const { data: { customer: currentUser } = {}, isLoading: isLoadingCurrentUser } =
useCustomerQuery();
const isLoading = isLoadingProfile || isLoadingCurrentUser;
const handleBook = () => {
if (profile?.role === Enum_Customer_Role.Client) {
push('/orders/add', { client: profile, slot: { master: currentUser } });
} else {
push('/orders/add', { client: currentUser, slot: { master: profile } });
}
};
if (!telegramId) return null;
return <FloatingActionPanel isLoading={isLoading} onQuickBook={handleBook} />;
}

View File

@ -12,27 +12,10 @@ type Props = {
}; };
export function ServiceDataCard({ serviceId }: Readonly<Props>) { export function ServiceDataCard({ serviceId }: Readonly<Props>) {
const { data: { service } = {}, isLoading } = useServiceQuery({ documentId: serviceId }); const { data: { service } = {} } = useServiceQuery({ documentId: serviceId });
const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } = const { cancelChanges, hasChanges, isPending, resetTrigger, saveChanges, updateField } =
useServiceEdit(serviceId); useServiceEdit(serviceId);
if (isLoading) {
return (
<Card className="p-4">
<div className="flex animate-pulse flex-col gap-4">
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-10 w-full rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
<div className="h-28 w-full rounded bg-muted" />
</div>
</Card>
);
}
if (!service) return null; if (!service) return null;
return ( return (

View File

@ -1,21 +1,19 @@
/* eslint-disable canonical/id-match */
'use client'; 'use client';
import { type ProfileProps } from '../types';
import { DataNotFound } from '@/components/shared/alert'; import { DataNotFound } from '@/components/shared/alert';
import { ServiceCard } from '@/components/shared/service-card'; import { ServiceCard } from '@/components/shared/service-card';
import { useCustomerQuery } from '@/hooks/api/customers'; import { useCustomerQuery } from '@/hooks/api/customers';
import { useServicesQuery } from '@/hooks/api/services'; import { useServicesQuery } from '@/hooks/api/services';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import Link from 'next/link'; import Link from 'next/link';
// Компонент для отображения услуг мастера (без ссылок, только просмотр) type MasterServicesListProps = {
export function ReadonlyServicesList({ telegramId }: Readonly<ProfileProps>) { masterId: string;
const { data: { customer } = {} } = useCustomerQuery({ telegramId }); };
const { isLoading, services } = useServices(telegramId);
if (customer?.role === Enum_Customer_Role.Client) return null; // Компонент для отображения услуг мастера (без ссылок, только просмотр)
export function ReadonlyServicesList({ masterId }: Readonly<MasterServicesListProps>) {
const { isLoading, services } = useServices(masterId);
return ( return (
<div className="space-y-2 px-4"> <div className="space-y-2 px-4">
@ -56,17 +54,17 @@ export function ServicesList() {
); );
} }
function useServices(telegramId?: Readonly<ProfileProps>['telegramId']) { function useServices(masterId?: string) {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery(); const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
// Используем переданный masterId или текущего пользователя // Используем переданный masterId или текущего пользователя
const targetTelegramId = telegramId || customer?.telegramId; const targetMasterId = masterId || customer?.documentId;
const { data: { services } = {}, isLoading: isLoadingServices } = useServicesQuery({ const { data: { services } = {}, isLoading: isLoadingServices } = useServicesQuery({
filters: { filters: {
master: { master: {
telegramId: { documentId: {
eq: targetTelegramId, eq: targetMasterId,
}, },
}, },
}, },

View File

@ -2,28 +2,26 @@
'use client'; 'use client';
import { useCustomerQuery } from '@/hooks/api/customers'; import { useCustomerQuery } from '@/hooks/api/customers';
import { useSubscriptionQuery, useSubscriptionSettingQuery } from '@/hooks/api/subscriptions'; import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
import { Enum_Customer_Role } from '@repo/graphql/types'; import { Enum_Customer_Role } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
export function SubscriptionInfoBar() { export function SubscriptionInfoBar() {
const { data: { subscriptionSetting } = {}, isLoading: isLoadingSubscriptionSetting } = const { data, error, isLoading } = useSubscriptionQuery();
useSubscriptionSettingQuery();
const { data, isLoading: isLoadingSubscription } = useSubscriptionQuery();
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery(); const { data: { customer } = {} } = useCustomerQuery();
if (customer?.role === Enum_Customer_Role.Client) return null;
const isLoading = isLoadingCustomer || isLoadingSubscription || isLoadingSubscriptionSetting;
const isActive = data?.hasActiveSubscription; const isActive = data?.hasActiveSubscription;
const remainingOrdersCount = data?.remainingOrdersCount; const remainingOrdersCount = data?.remainingOrdersCount;
const remainingDays = data?.remainingDays; const remainingDays = data?.remainingDays;
const maxOrdersPerMonth = data?.maxOrdersPerMonth; const maxOrdersPerMonth = data?.maxOrdersPerMonth;
if (customer?.role === Enum_Customer_Role.Client) return null;
if (error) return null;
const title = isActive ? 'Pro доступ активен' : 'Pro доступ неактивен'; const title = isActive ? 'Pro доступ активен' : 'Pro доступ неактивен';
let description = 'Попробуйте бесплатно'; let description = 'Попробуйте бесплатно';
@ -36,8 +34,6 @@ export function SubscriptionInfoBar() {
description = `Осталось ${remainingDays} дней`; description = `Осталось ${remainingDays} дней`;
} }
if (!subscriptionSetting?.proEnabled) return null;
return ( return (
<Link href="/pro" rel="noopener noreferrer"> <Link href="/pro" rel="noopener noreferrer">
<div className={cn('px-4', isLoading && 'animate-pulse')}> <div className={cn('px-4', isLoading && 'animate-pulse')}>

View File

@ -3,24 +3,12 @@
import { type SlotComponentProps } from '../types'; import { type SlotComponentProps } from '../types';
import { SlotDate } from './slot-date'; import { SlotDate } from './slot-date';
import { SlotTime } from './slot-time'; import { SlotTime } from './slot-time';
import { useSlotQuery } from '@/hooks/api/slots';
import { ScheduleStoreProvider } from '@/stores/schedule'; import { ScheduleStoreProvider } from '@/stores/schedule';
import { withContext } from '@/utils/context'; import { withContext } from '@/utils/context';
export const SlotDateTime = withContext(ScheduleStoreProvider)(function ( export const SlotDateTime = withContext(ScheduleStoreProvider)(function (
props: Readonly<SlotComponentProps>, props: Readonly<SlotComponentProps>,
) { ) {
const { isLoading } = useSlotQuery(props);
if (isLoading) {
return (
<div className="flex animate-pulse flex-col space-y-1">
<div className="h-5 w-28 rounded bg-muted" />
<div className="h-9 w-48 rounded bg-muted" />
</div>
);
}
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<SlotDate {...props} /> <SlotDate {...props} />

View File

@ -2,7 +2,7 @@
import { Button } from '@repo/ui/components/ui/button'; import { Button } from '@repo/ui/components/ui/button';
import { Card } from '@repo/ui/components/ui/card'; import { Card } from '@repo/ui/components/ui/card';
import { Ban, Check, Lock, Plus, RotateCcw, Save, Trash2, Undo, Unlock } from 'lucide-react'; import { Ban, Check, Lock, RotateCcw, Save, Trash2, Undo, Unlock } from 'lucide-react';
type FloatingActionPanelProps = { type FloatingActionPanelProps = {
readonly isLoading?: boolean; readonly isLoading?: boolean;
@ -11,7 +11,6 @@ type FloatingActionPanelProps = {
readonly onComplete?: () => void; readonly onComplete?: () => void;
readonly onConfirm?: () => void; readonly onConfirm?: () => void;
readonly onDelete?: () => void; readonly onDelete?: () => void;
readonly onQuickBook?: () => void;
readonly onRepeat?: () => void; readonly onRepeat?: () => void;
readonly onReturn?: () => void; readonly onReturn?: () => void;
readonly onSave?: () => void; readonly onSave?: () => void;
@ -25,7 +24,6 @@ export default function FloatingActionPanel({
onComplete, onComplete,
onConfirm, onConfirm,
onDelete, onDelete,
onQuickBook,
onRepeat, onRepeat,
onReturn, onReturn,
onSave, onSave,
@ -38,7 +36,6 @@ export default function FloatingActionPanel({
!onDelete && !onDelete &&
!onComplete && !onComplete &&
!onRepeat && !onRepeat &&
!onQuickBook &&
!onToggle && !onToggle &&
!onReturn && !onReturn &&
!onSave !onSave
@ -48,18 +45,6 @@ export default function FloatingActionPanel({
return ( return (
<Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6"> <Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6">
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4"> <div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
{/* Кнопка записать */}
{onQuickBook && (
<Button
className="w-full rounded-2xl bg-gradient-to-r from-purple-600 to-blue-600 text-sm text-white transition-all duration-200 hover:bg-primary/90 dark:from-purple-700 dark:to-blue-700 sm:w-auto"
disabled={isLoading}
onClick={onQuickBook}
size="sm"
>
<Plus className="mr-2 size-4" />
<span>Быстрая запись</span>
</Button>
)}
{/* Кнопка закрыть/открыть */} {/* Кнопка закрыть/открыть */}
{onToggle && ( {onToggle && (
<Button <Button

View File

@ -2,9 +2,8 @@ import { UserAvatar } from './user-avatar';
import type * as GQL from '@repo/graphql/types'; import type * as GQL from '@repo/graphql/types';
import { Badge } from '@repo/ui/components/ui/badge'; import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import Link from 'next/link'; import Link from 'next/link';
import { memo, type PropsWithChildren } from 'react'; import { memo } from 'react';
type ContactRowProps = GQL.CustomerFieldsFragment & { type ContactRowProps = GQL.CustomerFieldsFragment & {
readonly className?: string; readonly className?: string;
@ -12,30 +11,13 @@ type ContactRowProps = GQL.CustomerFieldsFragment & {
readonly showServices?: boolean; readonly showServices?: boolean;
}; };
function Wrapper({
children,
contact,
}: PropsWithChildren<{ readonly contact: GQL.CustomerFieldsFragment }>) {
const isActive = contact.active && contact.telegramId;
if (isActive) {
return (
<Link
className="block"
href={contact.active ? `/profile/${contact.telegramId}` : ''}
key={contact.telegramId}
>
{children}
</Link>
);
}
return <>{children}</>;
}
export const ContactRow = memo(function ({ className, description, ...contact }: ContactRowProps) { export const ContactRow = memo(function ({ className, description, ...contact }: ContactRowProps) {
return ( return (
<Wrapper contact={contact}> <Link
className="block"
href={contact.active ? `/profile/${contact.telegramId}` : ''}
key={contact.telegramId}
>
<div <div
className={cn( className={cn(
'flex items-center justify-between', 'flex items-center justify-between',
@ -46,7 +28,7 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}> <div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
<UserAvatar {...contact} size="sm" /> <UserAvatar {...contact} size="sm" />
<div> <div>
<p className="font-medium">{getCustomerFullName(contact)}</p> <p className="font-medium">{contact.name}</p>
{description && ( {description && (
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p> <p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
)} )}
@ -54,6 +36,6 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
</div> </div>
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>} {contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
</div> </div>
</Wrapper> </Link>
); );
}); });

View File

@ -6,7 +6,6 @@ import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png'; import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { type CustomerFieldsFragment } from '@repo/graphql/types'; import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import Image from 'next/image'; import Image from 'next/image';
type Sizes = 'lg' | 'md' | 'sm' | 'xs'; type Sizes = 'lg' | 'md' | 'sm' | 'xs';
@ -38,7 +37,7 @@ export function UserAvatar({ className, size = 'sm', telegramId = null }: UserAv
)} )}
> >
<Image <Image
alt={customer ? getCustomerFullName(customer) : 'contact-avatar'} alt={customer?.name || 'contact-avatar'}
className="size-full rounded-full object-cover" className="size-full rounded-full object-cover"
height={80} height={80}
src={customer?.photoUrl || AvatarPlaceholder} src={customer?.photoUrl || AvatarPlaceholder}

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
export const envSchema = z.object({ export const envSchema = z.object({
__DEV_TELEGRAM_ID: z.string().default(''), __DEV_TELEGRAM_ID: z.string().default(''),
BOT_URL: z.string(), BOT_URL: z.string(),
SUPPORT_TELEGRAM_URL: z.string(),
URL_OFFER: z.string(),
}); });
export const env = envSchema.parse(process.env); export const env = envSchema.parse(process.env);

View File

@ -4,8 +4,6 @@ import { backButton } from '@telegram-apps/sdk-react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
const exclude = ['/pro'];
export function useBackButton() { export function useBackButton() {
const { back } = useRouter(); const { back } = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@ -33,8 +31,6 @@ export function useBackButton() {
}, [pathname]); }, [pathname]);
} }
function isRootLevelPage(pathname: string) { function isRootLevelPage(path: string) {
if (exclude.includes(pathname)) return false; return path.split('/').filter(Boolean).length === 1;
return pathname.split('/').filter(Boolean).length === 1;
} }

View File

@ -1,7 +0,0 @@
import { type MDXComponents } from 'mdx/types';
const components = {} satisfies MDXComponents;
export function useMDXComponents(): MDXComponents {
return components;
}

View File

@ -3,7 +3,7 @@ import { withAuth } from 'next-auth/middleware';
export default withAuth({ export default withAuth({
callbacks: { callbacks: {
authorized: ({ token }) => Boolean(token?.telegramId), authorized: ({ token }) => Boolean(token),
}, },
pages: { pages: {
signIn: '/', signIn: '/',
@ -11,7 +11,5 @@ export default withAuth({
}); });
export const config = { export const config = {
matcher: [ matcher: ['/((?!auth|browser|telegram|unregistered|api|_next/static|_next/image|favicon.ico).*)'],
'/((?!auth|browser|telegram|unregistered|privacy|offer|api|_next/static|_next/image|favicon.ico).*)',
],
}; };

View File

@ -1,16 +1,12 @@
import createMDX from '@next/mdx'; import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig = createMDX({ const withNextIntl = createNextIntlPlugin('./utils/i18n/i18n.ts');
extension: /\.mdx?$/u,
})({ const nextConfig = withNextIntl({
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
experimental: {
mdxRs: true,
},
output: 'standalone', output: 'standalone',
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ['@repo/ui'], transpilePackages: ['@repo/ui'],
}); });

View File

@ -14,9 +14,6 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.5",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@repo/graphql": "workspace:*", "@repo/graphql": "workspace:*",
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
@ -25,10 +22,9 @@
"@tanstack/react-query": "^5.64.1", "@tanstack/react-query": "^5.64.1",
"@telegram-apps/sdk-react": "^2.0.19", "@telegram-apps/sdk-react": "^2.0.19",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@types/mdx": "^2.0.13",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"@types/react": "catalog:",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "catalog:", "autoprefixer": "catalog:",
"dayjs": "catalog:", "dayjs": "catalog:",
@ -36,13 +32,14 @@
"graphql": "catalog:", "graphql": "catalog:",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"next": "^15.5.9",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"next-intl": "^3.26.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"next": "^15.5.0",
"postcss": "catalog:", "postcss": "catalog:",
"radashi": "catalog:", "radashi": "catalog:",
"react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"react": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",

View File

@ -2,8 +2,10 @@
'use client'; 'use client';
import { useBackButton, useClientOnce, useDidMount, useViewport } from '@/hooks/telegram'; import { useBackButton, useClientOnce, useDidMount, useViewport } from '@/hooks/telegram';
import { setLocale } from '@/utils/i18n/locale';
import { init } from '@/utils/telegram/init'; import { init } from '@/utils/telegram/init';
import { type PropsWithChildren } from 'react'; import { initData, useSignal } from '@telegram-apps/sdk-react';
import { type PropsWithChildren, useEffect } from 'react';
export function TelegramProvider(props: Readonly<PropsWithChildren>) { export function TelegramProvider(props: Readonly<PropsWithChildren>) {
// Unfortunately, Telegram Mini Apps does not allow us to use all features of // Unfortunately, Telegram Mini Apps does not allow us to use all features of
@ -11,7 +13,7 @@ export function TelegramProvider(props: Readonly<PropsWithChildren>) {
// side. // side.
const didMount = useDidMount(); const didMount = useDidMount();
if (!didMount) return null; if (!didMount) return <div>Loading</div>;
return <RootInner {...props} />; return <RootInner {...props} />;
} }
@ -29,5 +31,12 @@ function RootInner({ children }: PropsWithChildren) {
useViewport(); useViewport();
useBackButton(); useBackButton();
const initDataUser = useSignal(initData.user);
// Set the user locale.
useEffect(() => {
if (initDataUser) setLocale(initDataUser.languageCode);
}, [initDataUser]);
return children; return children;
} }

View File

@ -0,0 +1,6 @@
{
"i18n": {
"header": "Application supports i18n",
"footer": "You can select a different language from the dropdown menu."
}
}

View File

@ -0,0 +1,6 @@
{
"i18n": {
"header": "Поддержка i18n",
"footer": "Вы можете выбрать другой язык в выпадающем меню."
}
}

View File

@ -0,0 +1,10 @@
export const defaultLocale = 'ru';
export const timeZone = 'Europe/Moscow';
export const locales = [defaultLocale, 'ru'] as const;
export const localesMap = [
{ key: 'en', title: 'English' },
{ key: 'ru', title: 'Русский' },
];

View File

@ -0,0 +1,18 @@
import { defaultLocale, locales } from './config';
import { getLocale } from './locale';
import { type Locale } from './types';
import { getRequestConfig } from 'next-intl/server';
const requestConfig = getRequestConfig(async () => {
const locale = (await getLocale()) as Locale;
return {
locale,
messages:
locale === defaultLocale || !locales.includes(locale)
? (await import(`@/public/locales/${defaultLocale}.json`)).default
: (await import(`@/public/locales/${locale}.json`)).default,
};
});
export default requestConfig;

View File

@ -0,0 +1,20 @@
// use server is required
'use server';
import { defaultLocale } from './config';
import { type Locale } from './types';
import { cookies } from 'next/headers';
// In this example the locale is read from a cookie. You could alternatively
// also read it from a database, backend service, or any other source.
const COOKIE_NAME = 'NEXT_LOCALE';
const getLocale = async () => {
return (await cookies()).get(COOKIE_NAME)?.value || defaultLocale;
};
const setLocale = async (locale?: string) => {
(await cookies()).set(COOKIE_NAME, (locale as Locale) || defaultLocale);
};
export { getLocale, setLocale };

View File

@ -0,0 +1,14 @@
/* eslint-disable canonical/id-match */
import { timeZone } from './config';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { type PropsWithChildren } from 'react';
export async function I18nProvider({ children }: Readonly<PropsWithChildren>) {
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages} timeZone={timeZone}>
{children}
</NextIntlClientProvider>
);
}

View File

@ -0,0 +1,5 @@
import { type locales } from './config';
type Locale = (typeof locales)[number];
export type { Locale };

View File

@ -1,13 +1,4 @@
services: services:
cache-proxy:
build:
context: .
dockerfile: ./apps/cache-proxy/Dockerfile
env_file:
- .env
depends_on:
- redis
restart: always
web: web:
env_file: env_file:
- .env - .env
@ -15,8 +6,6 @@ services:
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile
restart: always restart: always
depends_on:
- cache-proxy
ports: ports:
- 3000:3000 - 3000:3000
bot: bot:
@ -27,7 +16,6 @@ services:
- .env - .env
depends_on: depends_on:
- redis - redis
- cache-proxy
restart: always restart: always
redis: redis:

Some files were not shown because too many files have changed in this diff Show More