Fix/bugs after first release (#26)
Some checks failed
Build & Deploy Web & Bot / Build and Push to Docker Hub (push) Has been cancelled
Build & Deploy Web & Bot / Deploy to VPS (push) Has been cancelled

* web/packages: upgrade next

* fix(api/orders): update master validation logic to handle optional masters

* fix(api/notify, api/orders): enhance notification messages and update order state handling for masters

* fix react typings

* refactor(order-buttons, action-panel): streamline button handlers and add return functionality

* fix(contacts, orders): replace empty state messages with DataNotFound component for better user feedback

* feat(bot): add share bot command and update environment configuration for BOT_URL

* fix: pnpm-lock.yaml

* feat(bot): implement add contact wizard scene and enhance contact handling logic

* feat(profile): add BookContactButton component to enhance booking functionality

* fix(order-buttons): update cancel and confirm button logic based on order state

* feat(service-select): share services list for all
enhance service card display with duration formatting and improve layout
This commit is contained in:
Vlad Chikalkin 2025-07-03 16:36:10 +03:00 committed by GitHub
parent 1772ea4ff8
commit 7bcae12d54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1622 additions and 1178 deletions

View File

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

View File

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

View File

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

View File

@ -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>\ригласите пользователя в приложение, чтобы вы могли добавлять записи с этим контактом.`;
`✅ <b>Добавили контакт в список ваших клиентов</b>\n\nИмя: <b>${name}</b>\n\ригласите клиента в приложение, чтобы вы могли добавлять записи с этим контактом`;
export const MSG_CONTACT_FORWARD =
'<em>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</em>';
export const MESSAGE_SHARE_BOT =
'📅 <b>Воспользуйтесь этим ботом для записи к вашему мастеру!</b>\nНажмите кнопку ниже, чтобы начать';
export const MESSAGE_CANCEL = '<b>❌ Отменена операции</b>';

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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