Compare commits
12 Commits
main
...
fix/bugs-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53a276540 | ||
|
|
e2d92ade16 | ||
|
|
76de850bf5 | ||
|
|
6eb421bfd4 | ||
|
|
0bfebce1e3 | ||
|
|
687142f9af | ||
|
|
b9371c34ad | ||
|
|
f6354d41f6 | ||
|
|
d587ea23b6 | ||
|
|
4983e7b36b | ||
|
|
7f86fc164d | ||
|
|
b7a217787d |
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<BotContext, WizardSessionData>;
|
||||
session: SceneSession<WizardSessionData>;
|
||||
wizard: WizardContextWizard<BotContext>;
|
||||
};
|
||||
|
||||
const bot = new Telegraf<BotContext>(environment.BOT_TOKEN);
|
||||
|
||||
const stage = new Scenes.Stage<BotContext>();
|
||||
bot.use(session({ defaultSession: () => ({ __scenes: { cursor: 0, state: {} } }) }));
|
||||
bot.use(stage.middleware());
|
||||
|
||||
const addContactScene = new Scenes.WizardScene<BotContext>(
|
||||
'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);
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { env as environment } from './config/env';
|
||||
import { type ReplyKeyboardRemove } from 'telegraf/types';
|
||||
|
||||
export const commandsList = `
|
||||
\n<b>📋 Доступные команды:</b>
|
||||
• <b>/addcontact</b> — добавить контакт клиента
|
||||
• <b>/becomemaster</b> — стать мастером
|
||||
• <b>/sharebot</b> — поделиться ботом
|
||||
• <b>/help</b> — список команд
|
||||
`;
|
||||
|
||||
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 =
|
||||
'⛔️ <b>Только мастер может добавлять контакты.</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>.';
|
||||
'⛔️ <b>Только мастер может добавлять контакты</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>';
|
||||
|
||||
export const MSG_WELCOME =
|
||||
'👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации.';
|
||||
'👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации';
|
||||
|
||||
export const MSG_WELCOME_BACK = (name: string) =>
|
||||
`👋 <b>С возвращением, ${name}!</b>\nЧтобы воспользоваться сервисом, откройте приложение.\n`;
|
||||
|
||||
export const MSG_NEED_PHONE =
|
||||
'📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона.</b>';
|
||||
'📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона</b>';
|
||||
|
||||
export const MSG_SEND_CLIENT_CONTACT =
|
||||
'👤 <b>Отправьте контакт клиента, которого вы хотите добавить.</b>';
|
||||
'👤 <b>Отправьте контакт клиента, которого вы хотите добавить. \n<em>Для отмены операции используйте команду /cancel</em></b>';
|
||||
|
||||
export const MSG_ALREADY_MASTER = '🎉 <b>Вы уже являетесь мастером!</b>';
|
||||
|
||||
export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер.</b>';
|
||||
export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер</b>';
|
||||
|
||||
export const MSG_ERROR = (error?: unknown) =>
|
||||
`❌ <b>Произошла ошибка.</b>\n${error ? String(error) : ''}`;
|
||||
`❌ <b>Произошла ошибка</b>\n${error ? String(error) : ''}`;
|
||||
|
||||
export const MSG_PHONE_SAVED =
|
||||
'✅ <b>Спасибо! Мы сохранили ваш номер телефона.</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота.';
|
||||
'✅ <b>Спасибо! Мы сохранили ваш номер телефона</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота';
|
||||
|
||||
export const MSG_CONTACT_ADDED = (name: string) =>
|
||||
`✅ <b>Добавили контакт:</b> <b>${name}</b>\nПригласите пользователя в приложение, чтобы вы могли добавлять записи с этим контактом.`;
|
||||
`✅ <b>Добавили контакт в список ваших клиентов</b>\n\nИмя: <b>${name}</b>\n\nПригласите клиента в приложение, чтобы вы могли добавлять записи с этим контактом`;
|
||||
|
||||
export const MSG_CONTACT_FORWARD =
|
||||
'<em>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</em>';
|
||||
|
||||
export const MESSAGE_SHARE_BOT =
|
||||
'📅 <b>Воспользуйтесь этим ботом для записи к вашему мастеру!</b>\nНажмите кнопку ниже, чтобы начать';
|
||||
|
||||
export const MESSAGE_CANCEL = '<b>❌ Отменена операции</b>';
|
||||
|
||||
5
apps/bot/src/utils/customer.ts
Normal file
5
apps/bot/src/utils/customer.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<Props>) {
|
||||
<PersonCard telegramId={telegramId} />
|
||||
<ContactDataCard telegramId={telegramId} />
|
||||
<ProfileOrdersList telegramId={telegramId} />
|
||||
<BookContactButton telegramId={telegramId} />
|
||||
</Container>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
|
||||
@ -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 <LoadingSpinner />;
|
||||
|
||||
if (!contacts.length) return <div>Контакты не найдены</div>;
|
||||
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -19,6 +19,7 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
|
||||
|
||||
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<OrderComponentProps>) {
|
||||
return (
|
||||
<FloatingActionPanel
|
||||
isLoading={isPending}
|
||||
onCancel={
|
||||
isCancelled || (!isMaster && isCancelling) || isCompleted ? undefined : () => 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 <DataNotFound title="Услуги не найдены" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2 px-6">
|
||||
{services.map((service) => service && <ServiceCard key={service.documentId} {...service} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
|
||||
function ServiceCard({ documentId, duration, name }: Readonly<ServiceFieldsFragment>) {
|
||||
const serviceId = useOrderStore((store) => store.serviceId);
|
||||
const setServiceId = useOrderStore((store) => store.setServiceId);
|
||||
|
||||
@ -40,7 +32,7 @@ function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center justify-between border-2 rounded-2xl bg-background p-4 px-6 cursor-pointer dark:bg-primary/5',
|
||||
'flex items-center justify-between border-2 rounded-2xl bg-background p-4 cursor-pointer dark:bg-primary/5',
|
||||
selected ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
@ -52,9 +44,23 @@ function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
|
||||
type="radio"
|
||||
value={documentId}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
{name}
|
||||
{/* <span className={cn('text-xs font-normal', 'text-muted-foreground')} /> */}
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-base font-semibold text-foreground">{name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-4 p-2 rounded-full bg-secondary dark:bg-background text-primary text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="size-4 opacity-70"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M8 1.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 0 0 0-13ZM2.5 8a5.5 5.5 0 1 1 11 0a5.5 5.5 0 0 1-11 0Zm6-2.75a.5.5 0 0 0-1 0v3c0 .28.22.5.5.5h2a.5.5 0 0 0 0-1H8.5V5.25Z" />
|
||||
</svg>
|
||||
{formatTime(duration).user()}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
@ -4,6 +4,62 @@ import { OrderCard } from '../shared/order-card';
|
||||
import { type ProfileProps } from './types';
|
||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
||||
import { useOrdersQuery } from '@/hooks/api/orders';
|
||||
import { usePushWithData } from '@/hooks/url';
|
||||
import { CalendarPlus } from 'lucide-react';
|
||||
|
||||
export function BookContactButton({ telegramId }: Readonly<ProfileProps>) {
|
||||
const { data: { customer } = {}, isLoading: isCustomerLoading } = useCustomerQuery();
|
||||
const isMaster = useIsMaster();
|
||||
|
||||
const { data: { customer: profile } = {}, isLoading: isProfileLoading } = useCustomerQuery({
|
||||
telegramId,
|
||||
});
|
||||
|
||||
const push = usePushWithData();
|
||||
|
||||
const handleBook = () => {
|
||||
if (!profile || !customer) return;
|
||||
|
||||
if (isMaster) {
|
||||
push('/orders/add', {
|
||||
client: {
|
||||
documentId: profile.documentId,
|
||||
},
|
||||
slot: {
|
||||
master: {
|
||||
documentId: customer.documentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
push('/orders/add', {
|
||||
client: {
|
||||
documentId: customer.documentId,
|
||||
},
|
||||
slot: {
|
||||
master: {
|
||||
documentId: profile.documentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isCustomerLoading || isProfileLoading || !profile || !customer) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
className="flex items-center justify-center gap-2 self-center rounded-xl bg-primary/5 px-6 py-2 font-semibold text-primary shadow-sm transition-colors hover:bg-primary/10"
|
||||
onClick={handleBook}
|
||||
type="button"
|
||||
>
|
||||
<CalendarPlus className="size-5 text-primary" />
|
||||
<span>{isMaster ? 'Записать' : 'Записаться'}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button';
|
||||
import { Card } from '@repo/ui/components/ui/card';
|
||||
import { Ban, Check, Lock, RotateCcw, Trash2, Unlock } from 'lucide-react';
|
||||
import { Ban, Check, Lock, RotateCcw, Trash2, Undo, Unlock } from 'lucide-react';
|
||||
|
||||
type FloatingActionPanelProps = {
|
||||
readonly isLoading?: boolean;
|
||||
@ -12,6 +12,7 @@ type FloatingActionPanelProps = {
|
||||
readonly onConfirm?: () => void;
|
||||
readonly onDelete?: () => void;
|
||||
readonly onRepeat?: () => void;
|
||||
readonly onReturn?: () => void;
|
||||
readonly onToggle?: () => void;
|
||||
};
|
||||
|
||||
@ -23,10 +24,12 @@ export default function FloatingActionPanel({
|
||||
onConfirm,
|
||||
onDelete,
|
||||
onRepeat,
|
||||
onReturn,
|
||||
onToggle,
|
||||
}: FloatingActionPanelProps) {
|
||||
// Если не переданы обработчики, скрываем панель
|
||||
if (!onCancel && !onConfirm && !onDelete && !onComplete && !onRepeat && !onToggle) return null;
|
||||
if (!onCancel && !onConfirm && !onDelete && !onComplete && !onRepeat && !onToggle && !onReturn)
|
||||
return null;
|
||||
|
||||
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">
|
||||
@ -124,6 +127,19 @@ export default function FloatingActionPanel({
|
||||
<span>Подтвердить</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Кнопка вернуть */}
|
||||
{onReturn && (
|
||||
<Button
|
||||
className="w-full rounded-2xl bg-blue-400 text-sm text-white transition-all duration-200 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600 sm:w-auto"
|
||||
disabled={isLoading}
|
||||
onClick={onReturn}
|
||||
size="sm"
|
||||
>
|
||||
<Undo className="mr-2 size-4" />
|
||||
<span>Вернуть</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
12
apps/web/components/shared/alert.tsx
Normal file
12
apps/web/components/shared/alert.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
|
||||
export function DataNotFound({ title }: { readonly title: string }) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="line-clamp-1 flex items-center justify-center text-sm font-medium tracking-tight text-secondary-foreground">
|
||||
<AlertCircleIcon className="mr-2 size-4" />
|
||||
<span className="line-clamp-1 font-medium tracking-tight">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -35,7 +35,7 @@
|
||||
"next-auth": "^4.24.11",
|
||||
"next-intl": "^3.26.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"next": "15.3.0",
|
||||
"next": "15.3.4",
|
||||
"postcss": "catalog:",
|
||||
"radashi": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@ -4,7 +4,7 @@ import { useOrderStore } from './context';
|
||||
import { type Steps } from './types';
|
||||
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
|
||||
import { type OrderFieldsFragment } from '@repo/graphql/types';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const STEPS: Steps[] = [
|
||||
'master-select',
|
||||
@ -18,7 +18,6 @@ export const CLIENT_STEPS: Steps[] = STEPS.filter((step) => step !== 'client-sel
|
||||
|
||||
export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
const initialized = useRef(false);
|
||||
|
||||
const { data: { customer } = {} } = useCustomerQuery();
|
||||
const isMaster = useIsMaster();
|
||||
|
||||
@ -29,19 +28,17 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
const setStepsSequence = useOrderStore((store) => store._setStepSequence);
|
||||
const step = useOrderStore((store) => store.step);
|
||||
|
||||
const setFirstStep = useCallback(() => {
|
||||
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
|
||||
setStepsSequence(steps);
|
||||
setStep(steps[0] as Steps);
|
||||
}, [isMaster, setStep, setStepsSequence]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current || !customer || step !== 'loading') return;
|
||||
|
||||
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
|
||||
setStepsSequence(steps);
|
||||
|
||||
// Инициализация из initData (например, для повторного заказа)
|
||||
if (initData) {
|
||||
const masterId = initData.slot?.master?.documentId;
|
||||
const clientId = initData.client?.documentId;
|
||||
const serviceId = initData.services[0]?.documentId;
|
||||
const serviceId = initData.services?.[0]?.documentId;
|
||||
|
||||
if (masterId) setMasterId(masterId);
|
||||
if (clientId) setClientId(clientId);
|
||||
@ -49,17 +46,20 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
|
||||
if (masterId && clientId && serviceId) {
|
||||
setStep('datetime-select');
|
||||
} else if (masterId && clientId) {
|
||||
setStep('service-select');
|
||||
} else {
|
||||
setFirstStep();
|
||||
setStep(steps[0] ?? 'loading');
|
||||
}
|
||||
} else {
|
||||
// Обычная инициализация (новый заказ)
|
||||
if (isMaster) {
|
||||
setMasterId(customer.documentId);
|
||||
} else {
|
||||
setClientId(customer.documentId);
|
||||
}
|
||||
|
||||
setFirstStep();
|
||||
setStep(steps[0] ?? 'loading');
|
||||
}
|
||||
|
||||
initialized.current = true;
|
||||
@ -68,7 +68,6 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
initData,
|
||||
isMaster,
|
||||
setClientId,
|
||||
setFirstStep,
|
||||
setMasterId,
|
||||
setServiceId,
|
||||
setStep,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type * as GQL from '../types';
|
||||
import * as GQL from '../types';
|
||||
import { notifyByTelegramId } from '../utils/notify';
|
||||
import { BaseService } from './base';
|
||||
import { CustomersService } from './customers';
|
||||
@ -29,6 +29,9 @@ export class NotifyService extends BaseService {
|
||||
const serviceId = String(variables.input.services?.[0] ?? '');
|
||||
const timeStart = String(variables.input.time_start ?? '');
|
||||
const clientId = String(variables.input.client ?? '');
|
||||
const state = String(variables.input.state ?? '');
|
||||
|
||||
const isApproved = state === GQL.Enum_Order_State.Approved;
|
||||
|
||||
const { slot } = await slotsService.getSlot({ documentId: slotId });
|
||||
const { service } = await servicesService.getService({ documentId: serviceId });
|
||||
@ -45,13 +48,13 @@ export class NotifyService extends BaseService {
|
||||
|
||||
// Мастеру
|
||||
if (master?.telegramId) {
|
||||
const message = `✅ <b>Запись создана!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Клиент:</b> ${client?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`;
|
||||
const message = `✅ <b>Запись создана${isApproved ? ' и подтверждена' : ''}!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Клиент:</b> ${client?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`;
|
||||
await notifyByTelegramId(String(master.telegramId), message);
|
||||
}
|
||||
|
||||
// Клиенту
|
||||
if (client?.telegramId) {
|
||||
const message = `✅ <b>Запись создана!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Мастер:</b> ${master?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`;
|
||||
const message = `✅ <b>Запись создана${isApproved ? ' и подтверждена' : ''}!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Мастер:</b> ${master?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`;
|
||||
await notifyByTelegramId(String(client.telegramId), message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* eslint-disable canonical/id-match */
|
||||
import { getClientWithToken } from '../apollo/client';
|
||||
import * as GQL from '../types';
|
||||
import { Enum_Customer_Role, Enum_Slot_State } from '../types';
|
||||
import { Enum_Slot_State } from '../types';
|
||||
import { BaseService } from './base';
|
||||
import { CustomersService } from './customers';
|
||||
import { NotifyService } from './notify';
|
||||
@ -41,29 +41,32 @@ export class OrdersService extends BaseService {
|
||||
const servicesService = new ServicesService(this.customer);
|
||||
|
||||
const { customer } = await customersService.getCustomer(this.customer);
|
||||
|
||||
if (!customer) throw new Error(ERRORS.MISSING_USER);
|
||||
|
||||
const { slot } = await slotsService.getSlot({ documentId: variables.input.slot });
|
||||
|
||||
if (slot?.state === Enum_Slot_State.Closed) {
|
||||
throw new Error(ERRORS.SLOT_CLOSED);
|
||||
}
|
||||
|
||||
if (customer?.role === Enum_Customer_Role.Client) {
|
||||
const isMaster = isCustomerMaster(customer);
|
||||
|
||||
if (!isMaster) {
|
||||
if (customer.documentId !== variables.input.client) {
|
||||
throw new Error(ERRORS.INVALID_CLIENT);
|
||||
}
|
||||
|
||||
const masters = await customersService.getMasters(this.customer);
|
||||
const { customers } = await customersService.getMasters(this.customer);
|
||||
const masters = customers.at(0)?.masters;
|
||||
|
||||
const masterId = slot?.master?.documentId;
|
||||
if (!masters.customers.some((master) => master?.documentId === masterId)) {
|
||||
if (!masters?.some((master) => master?.documentId === masterId)) {
|
||||
throw new Error(ERRORS.INVALID_MASTER);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
customer?.role === Enum_Customer_Role.Master &&
|
||||
slot?.master?.documentId !== customer.documentId
|
||||
) {
|
||||
if (isMaster && slot?.master?.documentId !== customer.documentId) {
|
||||
throw new Error(ERRORS.INVALID_MASTER);
|
||||
}
|
||||
|
||||
@ -83,6 +86,7 @@ export class OrdersService extends BaseService {
|
||||
...variables,
|
||||
input: {
|
||||
...variables.input,
|
||||
state: isMaster ? GQL.Enum_Order_State.Approved : GQL.Enum_Order_State.Created,
|
||||
time_end: formatTime(endTime).db(),
|
||||
},
|
||||
},
|
||||
|
||||
2385
pnpm-lock.yaml
generated
2385
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,8 @@ packages:
|
||||
catalog:
|
||||
"@apollo/client": ^3.12.4
|
||||
"@types/node": ^20
|
||||
"@types/react": ^19.1.2
|
||||
"@types/react-dom": ^19.1.2
|
||||
"@types/react": ^19
|
||||
"@types/react-dom": ^19
|
||||
"@vchikalkin/eslint-config-awesome": ^2.2.2
|
||||
autoprefixer: ^10.4.20
|
||||
dayjs: ^1.11.3
|
||||
@ -19,8 +19,8 @@ catalog:
|
||||
postcss: ^8.4.49
|
||||
postcss-load-config: ^6.0.1
|
||||
prettier: ^3.2.5
|
||||
react: ^19.1.0
|
||||
react-dom: ^19.1.0
|
||||
react: ^19
|
||||
react-dom: ^19
|
||||
radashi: ^12.5.1
|
||||
rimraf: ^6.0.1
|
||||
tailwindcss: ^3.4.15
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user