diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c9af671..b2fed1c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,7 @@ jobs: - name: Create fake .env file for build run: | echo "BOT_TOKEN=fake" > .env + echo "BOT_URL=fake" > .env echo "LOGIN_GRAPHQL=fake" >> .env echo "PASSWORD_GRAPHQL=fake" >> .env echo "URL_GRAPHQL=http://localhost/graphql" >> .env @@ -64,6 +65,7 @@ jobs: - name: Create real .env file for production run: | echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env + echo "BOT_URL=${{ secrets.BOT_URL }}" > .env echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env echo "URL_GRAPHQL=${{ secrets.URL_GRAPHQL }}" >> .env diff --git a/apps/bot/src/config/env.ts b/apps/bot/src/config/env.ts index b783b6b..62a53d6 100644 --- a/apps/bot/src/config/env.ts +++ b/apps/bot/src/config/env.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export const envSchema = z.object({ BOT_TOKEN: z.string(), + BOT_URL: z.string(), }); export const env = envSchema.parse(process.env); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index efd3c56..65f28a8 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -4,11 +4,15 @@ import { env as environment } from './config/env'; import { commandsList, KEYBOARD_REMOVE, + KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE, + MESSAGE_CANCEL, MESSAGE_NOT_MASTER, + MESSAGE_SHARE_BOT, MSG_ALREADY_MASTER, MSG_BECOME_MASTER, MSG_CONTACT_ADDED, + MSG_CONTACT_FORWARD, MSG_ERROR, MSG_NEED_PHONE, MSG_PHONE_SAVED, @@ -16,13 +20,91 @@ import { MSG_WELCOME, MSG_WELCOME_BACK, } from './message'; +import { isCustomerMaster } from './utils/customer'; import { normalizePhoneNumber } from './utils/phone'; import { CustomersService } from '@repo/graphql/api/customers'; import { Enum_Customer_Role } from '@repo/graphql/types'; -import { Telegraf } from 'telegraf'; +import { Scenes, session, Telegraf, type Context as TelegrafContext } from 'telegraf'; import { message } from 'telegraf/filters'; +import { + type SceneContextScene, + type SceneSession, + type WizardContextWizard, + type WizardSessionData, +} from 'telegraf/typings/scenes'; -const bot = new Telegraf(environment.BOT_TOKEN); +type BotContext = TelegrafContext & { + scene: SceneContextScene; + session: SceneSession; + wizard: WizardContextWizard; +}; + +const bot = new Telegraf(environment.BOT_TOKEN); + +const stage = new Scenes.Stage(); +bot.use(session({ defaultSession: () => ({ __scenes: { cursor: 0, state: {} } }) })); +bot.use(stage.middleware()); + +const addContactScene = new Scenes.WizardScene( + 'add-contact', + async (context) => { + await context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' }); + return context.wizard.next(); + }, + async (context) => { + if (!context.from) { + await context.reply('Ошибка: не удалось определить пользователя'); + return context.scene.leave(); + } + + if (context.message && 'text' in context.message && context.message.text === '/cancel') { + await context.reply(MESSAGE_CANCEL + commandsList, { parse_mode: 'HTML' }); + return context.scene.leave(); + } + + if (!('message' in context && context.message && 'contact' in context.message)) { + await context.reply('Пожалуйста, отправьте контакт клиента через кнопку Telegram'); + return; + } + + const telegramId = context.from.id; + const customerService = new CustomersService({ telegramId }); + const { customer } = await customerService.getCustomer({ telegramId }); + if (!customer || !isCustomerMaster(customer)) { + await context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' }); + return context.scene.leave(); + } + + const { contact } = context.message; + const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim(); + const phone = normalizePhoneNumber(contact.phone_number); + + try { + const { customer: existingCustomer } = await customerService.getCustomer({ phone }); + + let documentId = existingCustomer?.documentId; + + if (!documentId) { + const createCustomerResult = await customerService.createCustomer({ name, phone }); + documentId = createCustomerResult?.createCustomer?.documentId; + if (!documentId) throw new Error('Customer not created'); + } + + const masters = [customer.documentId]; + await customerService.addMasters({ data: { masters }, documentId }); + await context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' }); + await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' }); + await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' }); + } catch (error) { + await context.reply(MSG_ERROR(error), { parse_mode: 'HTML' }); + } finally { + await context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' }); + context.scene.leave(); + } + }, +); + +stage.register(addContactScene); bot.start(async (context) => { const telegramId = context.from.id; @@ -40,21 +122,23 @@ bot.start(async (context) => { return context.reply(MSG_WELCOME, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' }); }); +bot.command('help', async (context) => { + return context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' }); +}); + bot.command('addcontact', async (context) => { const telegramId = context.from.id; - const customerService = new CustomersService({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId }); - if (!customer) { return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' }); } - if (customer.role !== Enum_Customer_Role.Master) { + if (!isCustomerMaster(customer)) { return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' }); } - return context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' }); + return context.scene.enter('add-contact'); }); bot.command('becomemaster', async (context) => { @@ -67,7 +151,7 @@ bot.command('becomemaster', async (context) => { return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' }); } - if (customer.role === Enum_Customer_Role.Master) { + if (isCustomerMaster(customer)) { return context.reply(MSG_ALREADY_MASTER, { parse_mode: 'HTML' }); } @@ -86,60 +170,32 @@ bot.command('becomemaster', async (context) => { } }); +bot.command('sharebot', async (context) => { + await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' }); + await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' }); +}); + bot.on(message('contact'), async (context) => { const telegramId = context.from.id; - const customerService = new CustomersService({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId }); - const isRegistration = !customer; + if (!customer) { + const { contact } = context.message; + const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim(); + const phone = normalizePhoneNumber(contact.phone_number); - const { contact } = context.message; - const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim(); - const phone = normalizePhoneNumber(contact.phone_number); - - if (isRegistration) { const response = await customerService - .createCustomer({ - name, - phone, - telegramId: context.from.id, - }) + .createCustomer({ name, phone, telegramId: context.from.id }) .catch((error) => { context.reply(MSG_ERROR(error), { parse_mode: 'HTML' }); }); - if (response) { return context.reply(MSG_PHONE_SAVED + commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML', }); } - } else { - if (customer.role !== Enum_Customer_Role.Master) { - return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' }); - } - - try { - const createCustomerResult = await customerService.createCustomer({ name, phone }); - - const documentId = createCustomerResult?.createCustomer?.documentId; - - if (!documentId) { - throw new Error('Customer not created'); - } - - const masters = [customer.documentId]; - - await customerService.addMasters({ - data: { masters }, - documentId, - }); - - return context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' }); - } catch (error) { - context.reply(MSG_ERROR(error), { parse_mode: 'HTML' }); - } } }); diff --git a/apps/bot/src/message.ts b/apps/bot/src/message.ts index b270cf1..62ee74a 100644 --- a/apps/bot/src/message.ts +++ b/apps/bot/src/message.ts @@ -1,9 +1,12 @@ +import { env as environment } from './config/env'; import { type ReplyKeyboardRemove } from 'telegraf/types'; export const commandsList = ` \n📋 Доступные команды:/addcontact — добавить контакт клиента • /becomemaster — стать мастером +• /sharebot — поделиться ботом +• /help — список команд `; export const KEYBOARD_SHARE_PHONE = { @@ -26,30 +29,51 @@ export const KEYBOARD_REMOVE = { } as ReplyKeyboardRemove, }; +export const KEYBOARD_SHARE_BOT = { + reply_markup: { + inline_keyboard: [ + [ + { + text: '🤖Воспользоваться ботом', + url: environment.BOT_URL, + }, + ], + ], + }, +}; + export const MESSAGE_NOT_MASTER = - '⛔️ Только мастер может добавлять контакты.\nСтать мастером можно на странице профиля в приложении или с помощью команды /becomemaster.'; + '⛔️ Только мастер может добавлять контакты\nСтать мастером можно на странице профиля в приложении или с помощью команды /becomemaster'; export const MSG_WELCOME = - '👋 Добро пожаловать!\nПожалуйста, поделитесь своим номером телефона для регистрации.'; + '👋 Добро пожаловать!\nПожалуйста, поделитесь своим номером телефона для регистрации'; export const MSG_WELCOME_BACK = (name: string) => `👋 С возвращением, ${name}!\nЧтобы воспользоваться сервисом, откройте приложение.\n`; export const MSG_NEED_PHONE = - '📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона.'; + '📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона'; export const MSG_SEND_CLIENT_CONTACT = - '👤 Отправьте контакт клиента, которого вы хотите добавить.'; + '👤 Отправьте контакт клиента, которого вы хотите добавить. \nДля отмены операции используйте команду /cancel'; export const MSG_ALREADY_MASTER = '🎉 Вы уже являетесь мастером!'; -export const MSG_BECOME_MASTER = '🥳 Поздравляем! Теперь вы мастер.'; +export const MSG_BECOME_MASTER = '🥳 Поздравляем! Теперь вы мастер'; export const MSG_ERROR = (error?: unknown) => - `❌ Произошла ошибка.\n${error ? String(error) : ''}`; + `❌ Произошла ошибка\n${error ? String(error) : ''}`; export const MSG_PHONE_SAVED = - '✅ Спасибо! Мы сохранили ваш номер телефона.\nТеперь вы можете открыть приложение или воспользоваться командами бота.'; + '✅ Спасибо! Мы сохранили ваш номер телефона\nТеперь вы можете открыть приложение или воспользоваться командами бота'; export const MSG_CONTACT_ADDED = (name: string) => - `✅ Добавили контакт: ${name}\nПригласите пользователя в приложение, чтобы вы могли добавлять записи с этим контактом.`; + `✅ Добавили контакт в список ваших клиентов\n\nИмя: ${name}\n\nПригласите клиента в приложение, чтобы вы могли добавлять записи с этим контактом`; + +export const MSG_CONTACT_FORWARD = + 'Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️'; + +export const MESSAGE_SHARE_BOT = + '📅 Воспользуйтесь этим ботом для записи к вашему мастеру!\nНажмите кнопку ниже, чтобы начать'; + +export const MESSAGE_CANCEL = '❌ Отменена операции'; diff --git a/apps/bot/src/utils/customer.ts b/apps/bot/src/utils/customer.ts new file mode 100644 index 0000000..499e79c --- /dev/null +++ b/apps/bot/src/utils/customer.ts @@ -0,0 +1,5 @@ +import * as GQL from '@repo/graphql/types'; + +export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) { + return customer?.role === GQL.Enum_Customer_Role.Master; +} diff --git a/apps/web/app/(main)/profile/[telegramId]/page.tsx b/apps/web/app/(main)/profile/[telegramId]/page.tsx index 39aaab0..1a242b0 100644 --- a/apps/web/app/(main)/profile/[telegramId]/page.tsx +++ b/apps/web/app/(main)/profile/[telegramId]/page.tsx @@ -1,7 +1,12 @@ import { getCustomer } from '@/actions/api/customers'; import { Container } from '@/components/layout'; import { PageHeader } from '@/components/navigation'; -import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile'; +import { + BookContactButton, + ContactDataCard, + PersonCard, + ProfileOrdersList, +} from '@/components/profile'; import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; type Props = { params: Promise<{ telegramId: string }> }; @@ -24,6 +29,7 @@ export default async function ProfilePage(props: Readonly) { + ); diff --git a/apps/web/components/contacts/contacts-list.tsx b/apps/web/components/contacts/contacts-list.tsx index 78389e2..5702157 100644 --- a/apps/web/components/contacts/contacts-list.tsx +++ b/apps/web/components/contacts/contacts-list.tsx @@ -1,5 +1,6 @@ 'use client'; +import { DataNotFound } from '../shared/alert'; import { ContactRow } from '../shared/contact-row'; import { useCustomerContacts } from '@/hooks/api/contacts'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; @@ -9,7 +10,7 @@ export function ContactsList() { if (isLoading) return ; - if (!contacts.length) return
Контакты не найдены
; + if (!contacts.length) return ; return (
diff --git a/apps/web/components/orders/order-buttons.tsx b/apps/web/components/orders/order-buttons.tsx index 66fcf79..5e83856 100644 --- a/apps/web/components/orders/order-buttons.tsx +++ b/apps/web/components/orders/order-buttons.tsx @@ -19,6 +19,7 @@ export function OrderButtons({ documentId }: Readonly) { if (!order) return null; + const isCreated = order?.state === Enum_Order_State.Created; const isApproved = order?.state === Enum_Order_State.Approved; const isCompleted = order?.state === Enum_Order_State.Completed; const isCancelling = order?.state === Enum_Order_State.Cancelling; @@ -51,20 +52,11 @@ export function OrderButtons({ documentId }: Readonly) { return ( handleCancel() - } - onComplete={isApproved && isMaster ? handleOnComplete : undefined} - onConfirm={ - !isMaster || - isApproved || - (!isMaster && isCancelled) || - (!isMaster && isCancelling) || - isCompleted - ? undefined - : () => handleApprove() - } + onCancel={isCreated || (isMaster && isCancelling) || isApproved ? handleCancel : undefined} + onComplete={isMaster && isApproved ? handleOnComplete : undefined} + onConfirm={isMaster && isCreated ? handleApprove : undefined} onRepeat={isCancelled || isCompleted ? handleOnRepeat : undefined} + onReturn={isMaster && isCancelled ? handleApprove : undefined} /> ); } diff --git a/apps/web/components/orders/order-form/service-select.tsx b/apps/web/components/orders/order-form/service-select.tsx index a3f0ceb..5c4ecd9 100644 --- a/apps/web/components/orders/order-form/service-select.tsx +++ b/apps/web/components/orders/order-form/service-select.tsx @@ -1,33 +1,25 @@ 'use client'; +import { DataNotFound } from '@/components/shared/alert'; import { useServicesQuery } from '@/hooks/api/services'; import { useOrderStore } from '@/stores/order'; import { type ServiceFieldsFragment } from '@repo/graphql/types'; import { cn } from '@repo/ui/lib/utils'; +import { formatTime } from '@repo/utils/datetime-format'; export function ServiceSelect() { - const masterId = useOrderStore((store) => store.masterId); + const { data: { services } = {} } = useServicesQuery({}); - const { data: { services } = {} } = useServicesQuery({ - filters: { - master: { - documentId: { - eq: masterId, - }, - }, - }, - }); - - if (!services?.length) return null; + if (!services?.length) return ; return ( -
+
{services.map((service) => service && )}
); } -function ServiceCard({ documentId, name }: Readonly) { +function ServiceCard({ documentId, duration, name }: Readonly) { const serviceId = useOrderStore((store) => store.serviceId); const setServiceId = useOrderStore((store) => store.setServiceId); @@ -40,7 +32,7 @@ function ServiceCard({ documentId, name }: Readonly) { return (