Compare commits

...

56 Commits

Author SHA1 Message Date
vchikalkin
5d1d79b241 new line after 'use client' & 'use server' directives 2025-05-20 14:17:03 +03:00
vchikalkin
7eac84cafb fix useClientsQuery & useMastersQuery query 2025-05-17 12:43:50 +03:00
vchikalkin
75b85a88e5 rename customers masters & clients query 2025-05-17 12:36:45 +03:00
vchikalkin
de05bc8cb6 fix bot with new api 2025-05-17 12:34:02 +03:00
vchikalkin
35134e9663 fix telegramId type (number) 2025-05-16 20:06:09 +03:00
vchikalkin
c91a188761 typo refactor hooks 2025-05-16 19:30:01 +03:00
vchikalkin
30001b993e refactor orders api 2025-05-16 19:14:00 +03:00
vchikalkin
f77e21b815 optimize hooks queryKey 2025-05-16 17:25:51 +03:00
vchikalkin
39498dbcff refactor services api 2025-05-16 16:47:12 +03:00
vchikalkin
7d368d9ef0 hooks/customers: use invalidateQueries 2025-05-16 16:05:09 +03:00
vchikalkin
b09283c33d refactor slots api 2025-05-16 15:12:18 +03:00
vchikalkin
fc94f373e9 refactor customer api 2025-05-16 12:55:50 +03:00
vchikalkin
fda1a0a531 packages/graphql: add eslint 2025-05-10 15:46:33 +03:00
vchikalkin
f2f7138c67 add result pages (success, error) 2025-05-09 18:26:18 +03:00
vchikalkin
0ed90d5451 split next-button into two buttons 2025-05-09 17:56:53 +03:00
vchikalkin
1528cc25b8 create order works! 2025-05-08 19:30:00 +03:00
vchikalkin
24fb2103f7 apps/web: rename actions/service -> actions/services 2025-05-08 16:47:02 +03:00
vchikalkin
3738c4e2a9 select time feature & get final order values 2025-05-07 17:33:35 +03:00
vchikalkin
7fcf67eece skip master select for master & client select for client 2025-05-06 13:14:48 +03:00
vchikalkin
b5306357c8 fix submit button not working 2025-05-06 10:57:46 +03:00
vchikalkin
7e886172f2 pass order store via context 2025-04-30 18:58:46 +03:00
vchikalkin
e6f2e6ccaf migrate from order context to zustand store 2025-04-29 17:48:11 +03:00
vchikalkin
2bc7607800 improve useSlots hook 2025-04-16 17:19:22 +03:00
vchikalkin
7e143b3054 split datetime-select into files 2025-04-16 16:03:58 +03:00
vchikalkin
1e6718508a fix steps for client & master 2025-04-16 15:22:25 +03:00
vchikalkin
68d2343e98 Revert "contacts: skip client step for client"
This reverts commit db9af07dab9df9428561a1952f5a2c91c5b9d88d.
2025-04-16 13:56:05 +03:00
vchikalkin
47144e8126 ServiceSelect: fix padding 2025-04-16 12:49:36 +03:00
vchikalkin
1883280dca fix react types 2025-04-16 12:47:36 +03:00
vchikalkin
db9af07dab contacts: skip client step for client 2025-04-16 12:45:01 +03:00
vchikalkin
bc974ffc40 back-button: fix steps using 2025-04-16 12:24:03 +03:00
vchikalkin
c09e79b024 .vscode: add launch.json 2025-04-16 11:23:36 +03:00
vchikalkin
2676e40df6 packages: upgrade next@15.3.0 2025-04-16 10:53:31 +03:00
vchikalkin
dd99e7d984 context/order: skip client-select in client steps 2025-04-10 14:36:27 +03:00
vchikalkin
ec32f56f8b hooks/profile: allow pass empty args to useProfileQuery/useProfileMutation 2025-04-10 12:46:07 +03:00
vchikalkin
8c8a588dfc context/order: split into files 2025-04-10 11:37:27 +03:00
vchikalkin
4143151cbb add self to masters list & border avatar 2025-04-09 19:12:01 +03:00
vchikalkin
8eece70ff4 add ClientsGrid & 'client-select' step 2025-04-09 16:51:25 +03:00
vchikalkin
2a830ceffb optimize useCustomerContacts 2025-04-09 16:10:27 +03:00
vchikalkin
0281e99403 create MastersGrid & master-select step 2025-04-09 13:48:20 +03:00
vchikalkin
461bca0a0b prepare for split contacts grid into masters/clients grid 2025-04-09 13:18:22 +03:00
vchikalkin
1e69802b82 Revert "add check icon for masters"
This reverts commit cc81a9a504918ebbffcca8d035c7c4984f109957.
2025-04-08 11:32:15 +03:00
vchikalkin
cc81a9a504 add check icon for masters 2025-04-07 18:27:51 +03:00
vchikalkin
687a5b66c0 fix step components rendering 2025-04-07 17:54:07 +03:00
vchikalkin
4f87d17e8e slot list: render immediately 2025-04-01 17:20:30 +03:00
vchikalkin
aacb7fa998 hooks/slot: rename index -> master 2025-03-14 17:58:44 +03:00
vchikalkin
5f0d707884 fix calendar padding 2025-03-14 14:37:59 +03:00
vchikalkin
79570efe1a save selected date to context 2025-03-14 13:56:36 +03:00
vchikalkin
cab23ac932 service component: comment span 2025-03-14 13:29:31 +03:00
vchikalkin
2bbe9731b1 disable submit button if no service selected 2025-03-13 13:47:44 +03:00
vchikalkin
3a649e5825 disable submit button if no customer selected 2025-03-12 17:18:06 +03:00
vchikalkin
cf5ceae115 components/order-form: add back button 2025-03-12 16:58:15 +03:00
vchikalkin
8931dfc69f Revert "context/order: add masterId"
This reverts commit d5d07d7b2f5b6673a621a30b00ad087c60675a3f.
2025-03-12 16:53:04 +03:00
vchikalkin
d5d07d7b2f context/order: add masterId 2025-03-12 14:19:36 +03:00
vchikalkin
4db10a7f63 add calendar & time picker 2025-03-10 18:58:04 +03:00
vchikalkin
b6d7fabba1 add service select 2025-03-10 18:18:23 +03:00
vchikalkin
fbc682b41f add contacts scroller 2025-03-07 16:13:58 +03:00
114 changed files with 2282 additions and 1081 deletions

15
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@ -3,12 +3,7 @@
import { env as environment } from './config/env';
import { commandsList, KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE, MESSAGE_NOT_MASTER } from './message';
import { normalizePhoneNumber } from './utils/phone';
import {
createOrUpdateUser,
getCustomer,
updateCustomerMaster,
updateCustomerProfile,
} from '@repo/graphql/api';
import { CustomersService } from '@repo/graphql/api/customers';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { Telegraf } from 'telegraf';
import { message } from 'telegraf/filters';
@ -16,8 +11,10 @@ import { message } from 'telegraf/filters';
const bot = new Telegraf(environment.BOT_TOKEN);
bot.start(async (context) => {
const data = await getCustomer({ telegramId: context.from.id });
const customer = data?.data?.customers?.at(0);
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (customer) {
return context.reply(
@ -34,8 +31,10 @@ bot.start(async (context) => {
});
bot.command('addcontact', async (context) => {
const data = await getCustomer({ telegramId: context.from.id });
const customer = data?.data?.customers?.at(0);
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return context.reply(
@ -52,8 +51,10 @@ bot.command('addcontact', async (context) => {
});
bot.command('becomemaster', async (context) => {
const data = await getCustomer({ telegramId: context.from.id });
const customer = data?.data?.customers?.at(0);
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return context.reply('Сначала поделитесь своим номером телефона.', KEYBOARD_SHARE_PHONE);
@ -63,12 +64,15 @@ bot.command('becomemaster', async (context) => {
return context.reply('Вы уже являетесь мастером.');
}
const response = await updateCustomerProfile({
data: { role: Enum_Customer_Role.Master },
documentId: customer.documentId,
}).catch((error) => {
context.reply('Произошла ошибка.\n' + error);
});
const response = await customerService
.updateCustomer({
data: {
role: Enum_Customer_Role.Master,
},
})
.catch((error) => {
context.reply('Произошла ошибка.\n' + error);
});
if (response) {
return context.reply('Вы стали мастером');
@ -76,8 +80,10 @@ bot.command('becomemaster', async (context) => {
});
bot.on(message('contact'), async (context) => {
const data = await getCustomer({ telegramId: context.from.id });
const customer = data?.data?.customers?.at(0);
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
const isRegistration = !customer;
@ -86,13 +92,15 @@ bot.on(message('contact'), async (context) => {
const phone = normalizePhoneNumber(contact.phone_number);
if (isRegistration) {
const response = await createOrUpdateUser({
name,
phone,
telegramId: context.from.id,
}).catch((error) => {
context.reply('Произошла ошибка.\n' + error);
});
const response = await customerService
.createCustomer({
name,
phone,
telegramId: context.from.id,
})
.catch((error) => {
context.reply('Произошла ошибка.\n' + error);
});
if (response) {
return context.reply(
@ -107,12 +115,19 @@ bot.on(message('contact'), async (context) => {
}
try {
await createOrUpdateUser({ name, phone });
const createCustomerResult = await customerService.createCustomer({ name, phone });
await updateCustomerMaster({
masterId: customer.documentId,
operation: 'add',
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(

View File

@ -0,0 +1,42 @@
'use server';
import { useService } from './lib/service';
import { CustomersService } from '@repo/graphql/api/customers';
const getService = useService(CustomersService);
export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
const service = await getService();
return service.addMasters(...variables);
}
export async function createCustomer(...variables: Parameters<CustomersService['createCustomer']>) {
const service = await getService();
return service.createCustomer(...variables);
}
export async function getClients(...variables: Parameters<CustomersService['getClients']>) {
const service = await getService();
return service.getClients(...variables);
}
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
const service = await getService();
return service.getCustomer(...variables);
}
export async function getMasters(...variables: Parameters<CustomersService['getMasters']>) {
const service = await getService();
return service.getMasters(...variables);
}
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {
const service = await getService();
return service.updateCustomer(...variables);
}

View File

@ -0,0 +1,14 @@
import { authOptions } from '@/config/auth';
import { type BaseService } from '@repo/graphql/api/base';
import { getServerSession } from 'next-auth';
export function useService<T extends typeof BaseService>(service: T) {
return async function () {
const session = await getServerSession(authOptions);
if (!session?.user?.telegramId) throw new Error('Unauthorized');
const customer = { telegramId: session.user.telegramId };
return new service(customer) as InstanceType<T>;
};
}

View File

@ -0,0 +1,18 @@
'use server';
import { useService } from './lib/service';
import { OrdersService } from '@repo/graphql/api/orders';
const getServicesService = useService(OrdersService);
export async function createOrder(...variables: Parameters<OrdersService['createOrder']>) {
const service = await getServicesService();
return service.createOrder(...variables);
}
export async function getOrder(...variables: Parameters<OrdersService['getOrder']>) {
const service = await getServicesService();
return service.getOrder(...variables);
}

View File

@ -0,0 +1,18 @@
'use server';
import { useService } from './lib/service';
import { ServicesService } from '@repo/graphql/api/services';
const getServicesService = useService(ServicesService);
export async function getService(...variables: Parameters<ServicesService['getService']>) {
const service = await getServicesService();
return service.getService(...variables);
}
export async function getServices(...variables: Parameters<ServicesService['getServices']>) {
const service = await getServicesService();
return service.getServices(...variables);
}

View File

@ -0,0 +1,36 @@
'use server';
import { useService } from './lib/service';
import { SlotsService } from '@repo/graphql/api/slots';
const getService = useService(SlotsService);
export async function createSlot(...variables: Parameters<SlotsService['createSlot']>) {
const service = await getService();
return service.createSlot(...variables);
}
export async function deleteSlot(...variables: Parameters<SlotsService['deleteSlot']>) {
const service = await getService();
return service.deleteSlot(...variables);
}
export async function getSlot(...variables: Parameters<SlotsService['getSlot']>) {
const service = await getService();
return service.getSlot(...variables);
}
export async function getSlots(...variables: Parameters<SlotsService['getSlots']>) {
const service = await getService();
return service.getSlots(...variables);
}
export async function updateSlot(...variables: Parameters<SlotsService['updateSlot']>) {
const service = await getService();
return service.updateSlot(...variables);
}

View File

@ -1,26 +0,0 @@
'use server';
import { authOptions } from '@/config/auth';
import { getCustomerClients, getCustomerMasters } from '@repo/graphql/api';
import { getServerSession } from 'next-auth/next';
export async function getClients() {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const response = await getCustomerClients({ telegramId: user?.telegramId });
return response.data?.customers?.at(0);
}
export async function getMasters() {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const response = await getCustomerMasters({ telegramId: user?.telegramId });
return response.data?.customers?.at(0);
}

View File

@ -1,4 +0,0 @@
'use server';
import * as api from '@repo/graphql/api';
export const getOrder = api.getOrder;

View File

@ -1,34 +0,0 @@
'use server';
import { authOptions } from '@/config/auth';
import { getCustomer, updateCustomerProfile } from '@repo/graphql/api';
import { type CustomerInput, type GetCustomerQueryVariables } from '@repo/graphql/types';
import { getServerSession } from 'next-auth/next';
export async function getProfile(input?: GetCustomerQueryVariables) {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const telegramId = input?.telegramId || user?.telegramId;
const { data } = await getCustomer({ telegramId });
const customer = data?.customers?.at(0);
return customer;
}
export async function updateProfile(input: CustomerInput) {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const { data } = await getCustomer({ telegramId: user?.telegramId });
const customer = data.customers.at(0);
if (!customer) throw new Error('Customer not found');
await updateCustomerProfile({
data: input,
documentId: customer?.documentId,
});
}

View File

@ -0,0 +1,13 @@
'use server';
import { authOptions } from '@/config/auth';
import { getServerSession } from 'next-auth/next';
export async function getSessionUser() {
const session = await getServerSession(authOptions);
const user = session?.user;
if (!user?.telegramId) throw new Error('Missing session');
return user;
}

View File

@ -1,60 +0,0 @@
'use server';
// eslint-disable-next-line sonarjs/no-internal-api-use
import type * as ApolloTypes from '../../../packages/graphql/node_modules/@apollo/client/core';
import { getProfile } from './profile';
import { formatDate, formatTime } from '@/utils/date';
import * as api from '@repo/graphql/api';
import type * as GQL from '@repo/graphql/types';
type AddSlotInput = Omit<GQL.CreateSlotMutationVariables['input'], 'master'>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type FixTypescriptCringe = ApolloTypes.FetchResult;
export async function addSlot(input: AddSlotInput) {
const customer = await getProfile();
return api.createSlot({
...input,
date: formatDate(input.date).db(),
master: customer?.documentId,
time_end: formatTime(input.time_end).db(),
time_start: formatTime(input.time_start).db(),
});
}
export async function getSlots(input: GQL.GetSlotsQueryVariables) {
const customer = await getProfile();
if (!customer?.documentId) throw new Error('Customer not found');
return api.getSlots({
filters: {
...input.filters,
master: {
documentId: {
eq: customer.documentId,
},
},
},
});
}
export async function updateSlot(input: GQL.UpdateSlotMutationVariables) {
const customer = await getProfile();
if (!customer?.documentId) throw new Error('Customer not found');
return api.updateSlot({
...input,
data: {
...input.data,
date: input.data?.date ? formatDate(input.data.date).db() : undefined,
time_end: input.data?.time_end ? formatTime(input.data.time_end).db() : undefined,
time_start: input.data?.time_start ? formatTime(input.data.time_start).db() : undefined,
},
});
}
export const getSlot = api.getSlot;
export const deleteSlot = api.deleteSlot;

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { getTelegramUser } from '@/mocks/get-telegram-user';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { signIn, useSession } from 'next-auth/react';
@ -21,7 +22,7 @@ export default function Auth() {
signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: String(user?.id),
telegramId: user?.id,
});
});
}

View File

@ -1,4 +1,5 @@
'use client';
import { useClientOnce } from '@/hooks/telegram';
import { isTMA } from '@telegram-apps/sdk-react';
import { redirect } from 'next/navigation';

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react';
import { signIn, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
@ -28,7 +29,7 @@ function useAuth() {
signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: String(initDataUser.id),
telegramId: initDataUser.id,
}).then(() => redirect('/profile'));
}
}, [initDataUser?.id, status]);

View File

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

View File

@ -7,7 +7,7 @@ type Props = { params: Promise<{ telegramId: string }> };
export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params;
const { telegramId } = parameters;
const telegramId = Number.parseInt(parameters.telegramId, 10);
const queryClient = new QueryClient();

View File

@ -1,9 +1,10 @@
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
import { type SlotComponentProps } from '@/components/schedule/types';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<{ documentId: string }> };
type Props = { params: Promise<SlotComponentProps> };
export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params;

View File

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

View File

@ -1,6 +1,7 @@
'use client';
import { LoadingSpinner } from '../common/spinner';
import { useCustomerContacts } from '@/hooks/contacts';
import { useCustomerContacts } from '@/hooks/api/contacts';
import * as GQL from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import Link from 'next/link';

View File

@ -1,4 +1,5 @@
'use client';
import { ContactsFilterContext, type FilterType } from '@/context/contacts-filter';
import { Button } from '@repo/ui/components/ui/button';
import {

View File

@ -1,4 +1,5 @@
'use client';
import { NavButton } from './components/nav-button';
import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react';
import { usePathname } from 'next/navigation';

View File

@ -1,4 +1,5 @@
'use client';
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';

View File

@ -1,4 +1,5 @@
'use client';
import { Button } from '@repo/ui/components/ui/button';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

View File

@ -1,4 +1,5 @@
'use client';
import { BackButton } from './components/back-button';
type Props = { title: string | undefined };

View File

@ -0,0 +1,26 @@
'use client';
import { useOrderCreate } from '@/hooks/api/orders';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
export function BackButton() {
const step = useOrderStore((store) => store.step);
const previousStep = useOrderStore((store) => store.prevStep);
const { isPending } = useOrderCreate();
if (['master-select', 'success'].includes(step)) return null;
return (
<Button
className="w-full"
disabled={isPending}
onClick={previousStep}
type="button"
variant="ghost"
>
Назад
</Button>
);
}

View File

@ -0,0 +1,79 @@
import { CardSectionHeader } from '@/components/ui';
// eslint-disable-next-line import/extensions
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label';
import { cn } from '@repo/ui/lib/utils';
import Image from 'next/image';
type ContactsGridProps = {
readonly contacts: CustomerFieldsFragment[];
readonly onSelect: (contactId: null | string) => void;
readonly selected?: null | string;
readonly title: string;
};
export function ContactsGridBase({ contacts, onSelect, selected, title }: ContactsGridProps) {
return (
<Card className="p-4">
<div className="flex flex-col gap-4">
<CardSectionHeader title={title} />
<div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto">
{contacts.map((contact) => {
if (!contact) return null;
const isCurrentUser = contact?.name === 'Я';
return (
<Label
className="flex cursor-pointer flex-col items-center"
key={contact?.documentId}
>
<input
checked={selected === contact?.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact?.documentId)}
type="radio"
value={contact?.documentId}
/>
<div
className={cn(
'w-20 h-20 rounded-full border-2 transition-all duration-75',
selected === contact?.documentId ? 'border-primary' : 'border-transparent',
)}
>
<div
className={cn(
'size-full rounded-full p-1',
isCurrentUser
? 'bg-gradient-to-r from-purple-500 to-pink-500'
: 'bg-transparent',
)}
>
<Image
alt={contact?.name}
className="size-full rounded-full object-cover"
height={80}
src={contact?.photoUrl || AvatarPlaceholder}
width={80}
/>
</div>
</div>
<span
className={cn(
'mt-2 max-w-20 break-words text-center text-sm font-medium',
isCurrentUser && 'font-bold',
)}
>
{contact?.name}
</span>
</Label>
);
})}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1 @@
export * from './contacts-grid-base';

View File

@ -0,0 +1,51 @@
'use client';
import { ContactsGridBase } from './components';
import { LoadingSpinner } from '@/components/common/spinner';
import { ContactsFilterProvider } from '@/context/contacts-filter';
import { useCustomerContacts } from '@/hooks/api/contacts';
import { useOrderStore } from '@/stores/order';
import { withContext } from '@/utils/context';
import { useEffect } from 'react';
export const MastersGrid = withContext(ContactsFilterProvider)(function () {
const { contacts, isLoading, setFilter } = useCustomerContacts();
const masterId = useOrderStore((store) => store.masterId);
const setMasterId = useOrderStore((store) => store.setMasterId);
useEffect(() => {
setFilter('masters');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
return (
<ContactsGridBase
contacts={contacts}
onSelect={(contactId) => setMasterId(contactId)}
selected={masterId}
title="Мастера"
/>
);
});
export const ClientsGrid = withContext(ContactsFilterProvider)(function () {
const { contacts, isLoading, setFilter } = useCustomerContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
useEffect(() => {
setFilter('clients');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
return (
<ContactsGridBase
contacts={contacts}
onSelect={(contactId) => setClientId(contactId)}
selected={clientId}
title="Клиенты"
/>
);
});

View File

@ -0,0 +1,28 @@
'use client';
import { useOrderStore } from '@/stores/order';
import { Calendar } from '@repo/ui/components/ui/calendar';
import dayjs from 'dayjs';
export function DateSelect() {
const selectedDate = useOrderStore((store) => store.date);
const setDate = useOrderStore((store) => store.setDate);
const setTime = useOrderStore((store) => store.setTime);
const setSlot = useOrderStore((store) => store.setSlotId);
return (
<Calendar
className="bg-background"
disabled={(date) => {
return dayjs().isAfter(dayjs(date), 'day');
}}
mode="single"
onSelect={(date) => {
if (date) setDate(date);
setTime(null);
setSlot(null);
}}
selected={selectedDate}
/>
);
}

View File

@ -0,0 +1,87 @@
/* eslint-disable canonical/id-match */
'use client';
import { useSlotsQuery } from '@/hooks/api/slots';
import { useOrderStore } from '@/stores/order';
import { Enum_Slot_State, type SlotFieldsFragment } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import dayjs, { type Dayjs } from 'dayjs';
import { sift } from 'radash';
const generateTimeSlots = (slots: SlotFieldsFragment[]): Array<{ slotId: string; time: Dayjs }> => {
const times: Array<{ slotId: string; time: Dayjs }> = [];
for (const slot of slots) {
let currentTime = dayjs(`${slot.date} ${slot.time_start}`);
const endTime = dayjs(`${slot.date} ${slot.time_end}`);
while (currentTime.isBefore(endTime) || currentTime.isSame(endTime)) {
times.push({ slotId: slot.documentId, time: currentTime });
currentTime = currentTime.add(30, 'minute');
}
}
return times;
};
export function TimeSelect() {
const masterId = useOrderStore((store) => store.masterId);
const date = useOrderStore((store) => store.date);
const { data: { slots } = {} } = useSlotsQuery({
filters: {
date: {
eq: date,
},
master: {
documentId: {
eq: masterId,
},
},
},
});
const openedSlots = slots?.filter((slot) => slot?.state === Enum_Slot_State.Open);
const timeSlots = generateTimeSlots(openedSlots ? sift(openedSlots) : []);
const morning = timeSlots.filter((time) => time.time.hour() < 12);
const afternoon = timeSlots.filter((time) => time.time.hour() >= 12 && time.time.hour() < 18);
const evening = timeSlots.filter((time) => time.time.hour() >= 18);
return (
<div className="space-y-2">
<TimeSlotsButtons times={morning} title="Утро" />
<TimeSlotsButtons times={afternoon} title="День" />
<TimeSlotsButtons times={evening} title="Вечер" />
</div>
);
}
function TimeSlotsButtons({
times,
title,
}: Readonly<{ times: Array<{ slotId: string; time: Dayjs }>; title: string }>) {
const setTime = useOrderStore((store) => store.setTime);
const setSlot = useOrderStore((store) => store.setSlotId);
if (!times.length) return null;
return (
<div className="space-y-2">
<h2 className="text-lg font-semibold">{title}</h2>
<div className="grid grid-cols-3 gap-2">
{times.map(({ slotId, time }) => (
<Button
className="mb-2"
key={time.toString()}
onClick={() => {
setTime(time.format('HH:mm'));
setSlot(slotId);
}}
variant="outline"
>
{time.format('HH:mm')}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { DateSelect } from './components/date-select';
import { TimeSelect } from './components/time-select';
export function DateTimeSelect() {
return (
<div className="space-y-4">
<DateSelect />
<TimeSelect />
</div>
);
}

View File

@ -0,0 +1,6 @@
export * from './back-button';
export * from './contacts-grid';
export * from './datetime-select';
export * from './next-button';
export * from './result';
export * from './service-select';

View File

@ -0,0 +1,22 @@
'use client';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
export function NextButton() {
const { clientId, date, masterId, nextStep, serviceId, step, time } = useOrderStore(
(store) => store,
);
const isDisabled =
(step === 'master-select' && !masterId) ||
(step === 'client-select' && !clientId) ||
(step === 'service-select' && !serviceId) ||
(step === 'datetime-select' && (!date || !time));
return (
<Button className="w-full" disabled={isDisabled} onClick={nextStep} type="button">
Далее
</Button>
);
}

View File

@ -0,0 +1,59 @@
'use client';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
import { Card, CardContent } from '@repo/ui/components/ui/card';
import { AlertCircle, CheckCircle2, Home, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export function ErrorPage() {
const setStep = useOrderStore((store) => store.setStep);
const handleRetry = () => {
setStep('datetime-select');
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm border-none bg-card text-card-foreground shadow-none">
<CardContent className="flex flex-col items-center space-y-5 py-8">
<div className="rounded-full bg-red-100 p-3 dark:bg-red-900">
<AlertCircle className="size-12 text-red-600 dark:text-red-400" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Ошибка!</h1>
<p className="text-muted-foreground">Произошла ошибка при выполнении операции.</p>
</div>
<Button className="w-full" onClick={handleRetry} variant="destructive">
<RefreshCw className="mr-2 size-4" />
Повторить
</Button>
</CardContent>
</Card>
</div>
);
}
export function SuccessPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm border-none bg-card text-card-foreground shadow-none">
<CardContent className="flex flex-col items-center space-y-5 py-8">
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900">
<CheckCircle2 className="size-12 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Готово!</h1>
<p className="text-muted-foreground">Запись успешно создана</p>
</div>
<Button asChild className="w-full">
<Link href="/">
<Home className="mr-2 size-4" />
На главный экран
</Link>
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,61 @@
'use client';
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';
export function ServiceSelect() {
const masterId = useOrderStore((store) => store.masterId);
const { data: { services } = {} } = useServicesQuery({
filters: {
master: {
documentId: {
eq: masterId,
},
},
},
});
if (!services?.length) return null;
return (
<div>
{services.map((service) => service && <ServiceCard key={service.documentId} {...service} />)}
</div>
);
}
function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
const serviceId = useOrderStore((store) => store.serviceId);
const setServiceId = useOrderStore((store) => store.setServiceId);
const selected = serviceId === documentId;
function handleOnSelect() {
setServiceId(documentId);
}
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',
selected ? 'border-primary' : 'border-transparent',
)}
>
<input
checked={selected}
className="hidden"
name="service"
onChange={() => handleOnSelect()}
type="radio"
value={documentId}
/>
<div className="flex flex-col gap-2">
{name}
{/* <span className={cn('text-xs font-normal', 'text-muted-foreground')} /> */}
</div>
</label>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { useOrderCreate } from '@/hooks/api/orders';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { useEffect } from 'react';
export function SubmitButton() {
const { clientId, date, serviceId, setStep, slotId, time } = useOrderStore((store) => store);
const isDisabled = !clientId || !serviceId || !date || !time || !slotId;
const { isError, isPending, isSuccess, mutate: createOrder } = useOrderCreate();
const handleSubmit = () => {
if (isDisabled) return;
createOrder({
input: {
client: clientId,
date,
services: [serviceId],
slot: slotId,
time_start: time,
},
});
};
useEffect(() => {
if (isSuccess) setStep('success');
if (isError) setStep('error');
}, [isError, isSuccess, setStep]);
return (
<Button
className="w-full"
disabled={isPending || isDisabled}
onClick={handleSubmit}
type="button"
>
{isPending ? <LoadingSpinner /> : 'Завершить'}
</Button>
);
}

View File

@ -0,0 +1 @@
export * from './order-form';

View File

@ -0,0 +1,57 @@
'use client';
import { LoadingSpinner } from '../common/spinner';
import {
BackButton,
ClientsGrid,
DateTimeSelect,
ErrorPage,
MastersGrid,
NextButton,
ServiceSelect,
SuccessPage,
} from './components';
import { SubmitButton } from './components/submit-button';
import { OrderStoreProvider, useOrderStore } from '@/stores/order';
import { useInitOrderStore } from '@/stores/order/hooks';
import { withContext } from '@/utils/context';
import { type JSX } from 'react';
const STEP_COMPONENTS: Record<string, JSX.Element> = {
'client-select': <ClientsGrid />,
'datetime-select': <DateTimeSelect />,
error: <ErrorPage />,
'master-select': <MastersGrid />,
'service-select': <ServiceSelect />,
success: <SuccessPage />,
};
function getStepComponent(step: string) {
return STEP_COMPONENTS[step] ?? null;
}
const BUTTON_COMPONENTS: Record<string, JSX.Element> = {
'': <NextButton />,
'datetime-select': <SubmitButton />,
};
function getButtonComponent(step: string) {
return BUTTON_COMPONENTS[step] ?? <NextButton />;
}
export const OrderForm = withContext(OrderStoreProvider)(function () {
useInitOrderStore();
const step = useOrderStore((store) => store.step);
if (step === 'loading') return <LoadingSpinner />;
return (
<div className="space-y-4 [&>*]:px-4">
{getStepComponent(step)}
<div className="space-y-2">
{getButtonComponent(step)}
<BackButton />
</div>
</div>
);
});

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { Checkbox, type CheckboxProps } from '@repo/ui/components/ui/checkbox';
import { useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';

View File

@ -1,4 +1,3 @@
export * from './card-header';
export * from './checkbox-field';
export * from './link-button';
export * from './text-field';

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { type CustomerInput } from '@repo/graphql/types';
import { Input } from '@repo/ui/components/ui/input';
import { Label } from '@repo/ui/components/ui/label';

View File

@ -1,14 +1,16 @@
'use client';
import { CheckboxWithText, DataField, ProfileCardHeader } from './components';
import { CardSectionHeader } from '../ui';
import { CheckboxWithText, DataField } from './components';
import { type ProfileProps } from './types';
import { useProfileMutation, useProfileQuery } from '@/hooks/profile';
import { useCustomerMutation, useCustomerQuery } from '@/hooks/api/customers';
import { Enum_Customer_Role as Role } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { Card } from '@repo/ui/components/ui/card';
import Link from 'next/link';
export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
const { data: customer } = useProfileQuery({ telegramId });
const { data: { customer } = {} } = useCustomerQuery({ telegramId });
if (!customer) return null;
@ -29,27 +31,31 @@ export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
}
export function ProfileDataCard() {
const { data: customer } = useProfileQuery({});
const { mutate: updateProfile } = useProfileMutation({});
const { data: { customer } = {} } = useCustomerQuery();
const { mutate: updateCustomer } = useCustomerMutation();
if (!customer) return null;
return (
<Card className="p-4">
<div className="flex flex-col gap-4">
<ProfileCardHeader title="Ваши данные" />
<CardSectionHeader title="Ваши данные" />
<DataField
fieldName="name"
id="name"
label="Имя"
onChange={updateProfile}
onChange={({ name }) => updateCustomer({ data: { name } })}
value={customer?.name ?? ''}
/>
<DataField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
<CheckboxWithText
checked={customer.role !== 'client'}
description="Разрешить другим пользователям записываться к вам"
onChange={(checked) => updateProfile({ role: checked ? Role.Master : Role.Client })}
onChange={(checked) =>
updateCustomer({
data: { role: checked ? Role.Master : Role.Client },
})
}
text="Быть мастером"
/>
</div>

View File

@ -1,12 +1,13 @@
/* eslint-disable canonical/id-match */
'use client';
import { LinkButton } from './components';
import { type ProfileProps } from './types';
import { useProfileQuery } from '@/hooks/profile';
import { useCustomerQuery } from '@/hooks/api/customers';
import { Enum_Customer_Role } from '@repo/graphql/types';
export function LinksCard({ telegramId }: Readonly<ProfileProps>) {
const { data: customer } = useProfileQuery({ telegramId });
const { data: { customer } = {} } = useCustomerQuery({ telegramId });
const isMaster = customer?.role === Enum_Customer_Role.Master;

View File

@ -1,12 +1,13 @@
'use client';
import { LoadingSpinner } from '../common/spinner';
import { type ProfileProps } from './types';
import { useProfileQuery } from '@/hooks/profile';
import { useCustomerQuery } from '@/hooks/api/customers';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { Card } from '@repo/ui/components/ui/card';
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
const { data: customer, isLoading } = useProfileQuery({ telegramId });
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
if (isLoading || !customer)
return (

View File

@ -1,3 +1,3 @@
export type ProfileProps = {
readonly telegramId?: string;
readonly telegramId?: number;
};

View File

@ -1,4 +1,5 @@
'use client';
import { ScheduleContext } from '@/context/schedule';
import { Calendar } from '@repo/ui/components/ui/calendar';
import dayjs from 'dayjs';

View File

@ -1,8 +1,9 @@
/* eslint-disable canonical/id-match */
'use client';
import { type OrderClient, type OrderComponentProps } from '../types';
import { ReadonlyTimeRange } from './time-range';
import { useOrderQuery } from '@/hooks/orders';
import { useOrderQuery } from '@/hooks/api/orders';
import { Enum_Order_State } from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { Badge } from '@repo/ui/components/ui/badge';
@ -10,8 +11,7 @@ import { cn } from '@repo/ui/lib/utils';
import Link from 'next/link';
export function OrderCard({ documentId }: Readonly<OrderComponentProps>) {
const { data } = useOrderQuery({ documentId });
const order = data?.data?.order;
const { data: { order } = {} } = useOrderQuery({ documentId });
if (!order) return null;

View File

@ -1,9 +1,9 @@
/* eslint-disable canonical/id-match */
'use client';
import { type SlotComponentProps } from '../types';
import { ReadonlyTimeRange } from './time-range';
import { useSlotQuery } from '@/hooks/slots';
import { Enum_Slot_State } from '@repo/graphql/types';
import { useSlotQuery } from '@/hooks/api/slots';
import { Enum_Slot_State, type SlotFieldsFragment } from '@repo/graphql/types';
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import Link from 'next/link';
@ -15,26 +15,23 @@ const MAP_BADGE_TEXT: Record<Enum_Slot_State, string> = {
reserved: 'Зарезервировано',
};
export function SlotCard(props: Readonly<SlotComponentProps>) {
export function SlotCard(props: Readonly<SlotFieldsFragment>) {
const pathname = usePathname();
const { documentId } = props;
const { data } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
const { data: { slot } = {} } = useSlotQuery({ documentId });
if (!slot) return null;
const ordersNumber = slot.orders?.length;
const ordersNumber = slot?.orders?.length;
const hasOrders = Boolean(ordersNumber);
const isOpened = slot?.state === Enum_Slot_State.Open;
const isClosed = slot?.state === Enum_Slot_State.Closed;
const isOpened = props?.state === Enum_Slot_State.Open;
const isClosed = props?.state === Enum_Slot_State.Closed;
return (
<Link href={`${pathname}/slots/${documentId}`} rel="noopener noreferrer">
<Link href={`${pathname}/slots/${props.documentId}`} rel="noopener noreferrer">
<div className="flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
<div className="flex flex-col">
<ReadonlyTimeRange {...slot} />
<ReadonlyTimeRange {...props} />
<span
className={cn(
'text-xs font-normal',
@ -44,14 +41,14 @@ export function SlotCard(props: Readonly<SlotComponentProps>) {
{hasOrders ? 'Есть записи' : 'Свободно'}
</span>
</div>
{slot.state && (
{props.state && (
<Badge
className={cn(
isOpened ? 'bg-green-100 text-green-500 dark:bg-green-700 dark:text-green-100' : '',
isClosed ? 'bg-red-100 text-red-500 dark:bg-red-700 dark:text-red-100' : '',
)}
>
{getBadgeText(slot.state)}
{getBadgeText(props.state)}
</Badge>
)}
</div>

View File

@ -1,11 +1,11 @@
'use client';
import { type SlotComponentProps } from '../types';
import { useSlotQuery } from '@/hooks/slots';
import { formatDate } from '@/utils/date';
import { useSlotQuery } from '@/hooks/api/slots';
import { formatDate } from '@repo/graphql/utils/datetime-format';
export function SlotDate({ documentId }: Readonly<SlotComponentProps>) {
const { data } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
const { data: { slot } = {} } = useSlotQuery({ documentId });
if (!slot) return null;

View File

@ -1,9 +1,10 @@
/* eslint-disable react/jsx-no-bind */
'use client';
import { ScheduleTimeContext } from '../context';
import { type SlotComponentProps } from '../types';
import { EditableTimeRangeForm, ReadonlyTimeRange } from './time-range';
import { useSlotMutation, useSlotQuery } from '@/hooks/slots';
import { useSlotMutation, useSlotQuery } from '@/hooks/api/slots';
import { Button } from '@repo/ui/components/ui/button';
import { PencilLine } from 'lucide-react';
import { use, useEffect } from 'react';
@ -19,8 +20,7 @@ function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) {
use(ScheduleTimeContext);
const { isPending: isMutationPending, mutate: updateSlot } = useSlotMutation({ documentId });
const { data, isPending: isQueryPending } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
const { data: { slot } = {}, isPending: isQueryPending } = useSlotQuery({ documentId });
const isPending = isMutationPending || isQueryPending;
@ -32,7 +32,7 @@ function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) {
}, [editMode, setEndTime, setStartTime, slot]);
function handleSubmit() {
updateSlot({ data: { time_end: endTime, time_start: startTime }, documentId });
updateSlot({ data: { time_end: endTime, time_start: startTime } });
resetTime();
setEditMode(false);
}
@ -46,11 +46,10 @@ function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) {
);
}
function SlotTimeReadonly(props: Readonly<SlotComponentProps>) {
function SlotTimeReadonly({ documentId }: Readonly<SlotComponentProps>) {
const { setEditMode } = use(ScheduleTimeContext);
const { data } = useSlotQuery(props);
const slot = data?.data?.slot;
const { data: { slot } = {} } = useSlotQuery({ documentId });
if (!slot) return null;

View File

@ -1,6 +1,7 @@
'use client';
import { ScheduleTimeContext } from '../context';
import { formatTime } from '@/utils/date';
import { formatTime } from '@repo/graphql/utils/datetime-format';
import { Input } from '@repo/ui/components/ui/input';
import { cn } from '@repo/ui/lib/utils';
import { type FormEvent, type PropsWithChildren, use } from 'react';

View File

@ -1,4 +1,5 @@
'use client';
import {
createContext,
type Dispatch,

View File

@ -1,9 +1,9 @@
/* eslint-disable canonical/id-match */
'use client';
import { EditableTimeRangeForm } from './components/time-range';
import { ScheduleTimeContext, ScheduleTimeContextProvider } from './context';
import { ScheduleContext } from '@/context/schedule';
import { useSlotAdd } from '@/hooks/slots';
import { useSlotCreate } from '@/hooks/api/slots';
import { withContext } from '@/utils/context';
import { Enum_Slot_State } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
@ -13,18 +13,17 @@ import { type FormEvent, use } from 'react';
export const DaySlotAddForm = withContext(ScheduleTimeContextProvider)(function () {
const { endTime, resetTime, startTime } = use(ScheduleTimeContext);
const { selectedDate } = use(ScheduleContext);
const { isPending, mutate: addSlot } = useSlotAdd();
const { isPending, mutate: addSlot } = useSlotCreate();
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (startTime && endTime) {
addSlot({
date: selectedDate,
state: Enum_Slot_State.Open,
time_end: endTime,
time_start: startTime,
input: {
state: Enum_Slot_State.Open,
time_end: endTime,
time_start: startTime,
},
});
resetTime();

View File

@ -1,12 +1,17 @@
'use client';
import { SlotCard } from './components/slot-card';
import { DaySlotAddForm } from './day-slot-add-form';
import { LoadingSpinner } from '@/components/common/spinner';
import { useSlots } from '@/hooks/slots';
import { ScheduleContext } from '@/context/schedule';
import { useSlotsQuery } from '@/hooks/api/slots';
import { use } from 'react';
export function DaySlotsList() {
const { data, isLoading } = useSlots();
const slots = data?.data.slots;
const { selectedDate } = use(ScheduleContext);
const { data: { slots } = {}, isLoading } = useSlotsQuery({
filters: { date: { eq: selectedDate } },
});
if (isLoading) return <LoadingSpinner />;

View File

@ -1,32 +1,33 @@
/* eslint-disable react/jsx-no-bind */
/* eslint-disable canonical/id-match */
'use client';
import { type SlotComponentProps } from './types';
import { useSlotDelete, useSlotMutation, useSlotQuery } from '@/hooks/slots';
import { useSlotDelete, useSlotMutation, useSlotQuery } from '@/hooks/api/slots';
import { Enum_Slot_State } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { useRouter } from 'next/navigation';
export function SlotButtons({ documentId }: Readonly<SlotComponentProps>) {
const { data } = useSlotQuery({ documentId });
const { data: { slot } = {} } = useSlotQuery({ documentId });
const { mutate: updateSlot } = useSlotMutation({ documentId });
const { mutate: deleteSlot } = useSlotDelete({ documentId });
const router = useRouter();
const slot = data?.data?.slot;
if (!slot) return null;
const isOpened = slot?.state === Enum_Slot_State.Open;
const isClosed = slot?.state === Enum_Slot_State.Closed;
function handleOpenSlot() {
return updateSlot({ data: { state: Enum_Slot_State.Open }, documentId });
return updateSlot({ data: { state: Enum_Slot_State.Open } });
}
function handleCloseSlot() {
return updateSlot({ data: { state: Enum_Slot_State.Closed }, documentId });
return updateSlot({ data: { state: Enum_Slot_State.Closed } });
}
function handleDeleteSlot() {

View File

@ -1,4 +1,5 @@
'use client';
import { SlotDate } from './components/slot-date';
import { SlotTime } from './components/slot-time';
import { ScheduleTimeContextProvider } from './context';

View File

@ -1,11 +1,11 @@
'use client';
import { OrderCard } from './components/order-card';
import { type SlotComponentProps } from './types';
import { useSlotQuery } from '@/hooks/slots';
import { useSlotQuery } from '@/hooks/api/slots';
export function SlotOrdersList({ documentId }: Readonly<SlotComponentProps>) {
const { data } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
const { data: { slot } = {} } = useSlotQuery({ documentId });
if (!slot) return null;

View File

@ -2,7 +2,7 @@ type Props = {
title: string;
};
export function ProfileCardHeader({ title }: Readonly<Props>) {
export function CardSectionHeader({ title }: Readonly<Props>) {
return (
<div className="flex flex-row justify-between">
<h1 className="font-bold text-primary">{title}</h1>

View File

@ -0,0 +1 @@
export * from './card-header';

View File

@ -12,7 +12,7 @@ export const authOptions: AuthOptions = {
},
async session({ session, token }) {
if (token?.id && session?.user) {
session.user.telegramId = token.id as string;
session.user.telegramId = token.id as number;
}
return session;

View File

@ -1,12 +1,13 @@
'use client';
import { createContext, useMemo, useState } from 'react';
import { createContext, type PropsWithChildren, useMemo, useState } from 'react';
export type FilterType = 'all' | 'clients' | 'masters';
type ContextType = { filter: FilterType; setFilter: (filter: FilterType) => void };
export const ContactsFilterContext = createContext<ContextType>({} as ContextType);
export function ContactsFilterProvider({ children }: { readonly children: React.ReactNode }) {
export function ContactsFilterProvider({ children }: Readonly<PropsWithChildren>) {
const [filter, setFilter] = useState<FilterType>('all');
const value = useMemo(() => ({ filter, setFilter }), [filter, setFilter]);

View File

@ -1,4 +1,5 @@
'use client';
import { createContext, useMemo, useState } from 'react';
type ContextType = {

View File

@ -0,0 +1,23 @@
import { getClients, getMasters } from '@/actions/api/customers';
import { useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export const useClientsQuery = (props?: Parameters<typeof getClients>[0]) => {
const { data: session } = useSession();
const telegramId = props?.telegramId || session?.user?.telegramId;
return useQuery({
queryFn: () => getClients({ telegramId }),
queryKey: ['customer', 'telegramId', telegramId, 'clients'],
});
};
export const useMastersQuery = (props?: Parameters<typeof getMasters>[0]) => {
const { data: session } = useSession();
const telegramId = props?.telegramId || session?.user?.telegramId;
return useQuery({
queryFn: () => getMasters({ telegramId }),
queryKey: ['customer', 'telegramId', telegramId, 'masters'],
});
};

View File

@ -0,0 +1,47 @@
'use client';
import { useClientsQuery, useMastersQuery } from './query';
import { ContactsFilterContext } from '@/context/contacts-filter';
import { sift } from 'radash';
import { use, useEffect, useMemo } from 'react';
export function useCustomerContacts() {
const { filter, setFilter } = use(ContactsFilterContext);
const {
data: clientsData,
isLoading: isLoadingClients,
refetch: refetchClients,
} = useClientsQuery();
const {
data: mastersData,
isLoading: isLoadingMasters,
refetch: refetchMasters,
} = useMastersQuery();
const clients = clientsData?.customers?.at(0)?.clients || [];
const masters = mastersData?.customers?.at(0)?.masters || [];
const isLoading = isLoadingClients || isLoadingMasters;
useEffect(() => {
if (filter === 'clients') {
refetchClients();
} else if (filter === 'masters') {
refetchMasters();
} else {
refetchClients();
refetchMasters();
}
}, [filter, refetchClients, refetchMasters]);
const contacts = useMemo(() => {
if (filter === 'clients') return sift(clients);
if (filter === 'masters') return sift(masters);
return [...sift(clients), ...sift(masters)];
}, [clients, masters, filter]);
return { contacts, filter, isLoading, setFilter };
}

View File

@ -0,0 +1,35 @@
'use client';
import { getCustomer, updateCustomer } from '@/actions/api/customers';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0]) => {
const { data: session } = useSession();
const telegramId = variables?.telegramId || session?.user?.telegramId;
return useQuery({
enabled: Boolean(telegramId),
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
};
export const useCustomerMutation = () => {
const { data: session } = useSession();
const telegramId = session?.user?.telegramId;
const queryClient = useQueryClient();
const handleOnSuccess = () => {
if (!telegramId) return;
queryClient.invalidateQueries({
queryKey: ['customer', telegramId],
});
};
return useMutation({
mutationFn: updateCustomer,
onSuccess: handleOnSuccess,
});
};

View File

@ -0,0 +1,16 @@
'use client';
import { createOrder, getOrder } from '@/actions/api/orders';
import { useMutation, useQuery } from '@tanstack/react-query';
export const useOrderQuery = ({ documentId }: Parameters<typeof getOrder>[0]) =>
useQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
export const useOrderCreate = () =>
useMutation({
mutationFn: createOrder,
mutationKey: ['order', 'create'],
});

View File

@ -0,0 +1,18 @@
'use client';
import { getService, getServices } from '@/actions/api/services';
import { useQuery } from '@tanstack/react-query';
export const useServicesQuery = (input: Parameters<typeof getServices>[0]) => {
return useQuery({
queryFn: () => getServices(input),
queryKey: ['services', input],
});
};
export const useServiceQuery = (input: Parameters<typeof getService>[0]) => {
return useQuery({
queryFn: () => getService(input),
queryKey: ['service', input.documentId],
});
};

View File

@ -0,0 +1,87 @@
'use client';
import { useCustomerQuery } from './customers';
import { createSlot, deleteSlot, getSlot, getSlots, updateSlot } from '@/actions/api/slots';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export const useSlotsQuery = (variables: Parameters<typeof getSlots>[0]) => {
const { data: { customer } = {} } = useCustomerQuery();
const masterId = variables.filters?.master?.documentId?.eq || customer?.documentId;
const date = variables.filters?.date?.eq;
return useQuery({
queryFn: () => getSlots(variables),
queryKey: ['slots', { date: date?.toISOString(), masterId }],
});
};
export const useSlotQuery = (variables: Parameters<typeof getSlot>[0]) => {
const { documentId } = variables;
return useQuery({
queryFn: () => getSlot(variables),
queryKey: ['slot', documentId],
});
};
export const useSlotMutation = ({
documentId,
}: Pick<Parameters<typeof updateSlot>[0], 'documentId'>) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ data }: Pick<Parameters<typeof updateSlot>[0], 'data'>) =>
updateSlot({ data, documentId }),
mutationKey: ['slot', 'update', documentId],
onSuccess: () => {
if (documentId) {
queryClient.invalidateQueries({ queryKey: ['slot', documentId] });
}
},
});
};
export const useSlotCreate = () => {
const { data: { customer } = {} } = useCustomerQuery();
const masterId = customer?.documentId;
const queryClient = useQueryClient();
return useMutation({
mutationFn: createSlot,
mutationKey: ['slot', 'create'],
onSuccess: masterId
? (data) => {
queryClient.invalidateQueries({
queryKey: ['slots', { date: data?.createSlot?.date?.toISOString(), masterId }],
});
}
: undefined,
});
};
export const useSlotDelete = ({ documentId }: Parameters<typeof deleteSlot>[0]) => {
const { data: { slot } = {} } = useSlotQuery({ documentId });
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => deleteSlot({ documentId }),
mutationKey: ['slot', 'delete', documentId],
onSuccess: () => {
const date = slot?.date;
const masterId = slot?.master;
if (date && masterId) {
queryClient.invalidateQueries({
queryKey: ['slots', { date: date?.toISOString(), masterId }],
});
queryClient.invalidateQueries({
queryKey: ['slot', documentId],
});
}
},
});
};

View File

@ -1,8 +0,0 @@
import { getClients, getMasters } from '@/actions/contacts';
import { useQuery } from '@tanstack/react-query';
export const useClientsQuery = () =>
useQuery({ queryFn: getClients, queryKey: ['contacts', 'clients', 'get'] });
export const useMastersQuery = () =>
useQuery({ queryFn: getMasters, queryKey: ['contacts', 'masters', 'get'] });

View File

@ -1,29 +0,0 @@
'use client';
import { useClientsQuery, useMastersQuery } from './query';
import { ContactsFilterContext } from '@/context/contacts-filter';
import { sift } from 'radash';
import { use, useMemo } from 'react';
export function useCustomerContacts() {
const { filter } = use(ContactsFilterContext);
const { data: clientsData, isLoading: isLoadingClients } = useClientsQuery();
const { data: mastersData, isLoading: isLoadingMasters } = useMastersQuery();
const clients = clientsData?.clients;
const masters = mastersData?.masters;
const isLoading = isLoadingClients || isLoadingMasters;
const contacts = useMemo(() => {
switch (filter) {
case 'clients':
return clients ? sift(clients) : [];
case 'masters':
return masters ? sift(masters) : [];
default:
return [...(clients ? sift(clients) : []), ...(masters ? sift(masters) : [])];
}
}, [clients, masters, filter]);
return { contacts, isLoading };
}

View File

@ -1,18 +0,0 @@
'use client';
import { getOrder } from '@/actions/orders';
// eslint-disable-next-line sonarjs/no-internal-api-use
import type * as ApolloTypes from '@repo/graphql/node_modules/@apollo/client/core';
import { useQuery } from '@tanstack/react-query';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type FixTypescriptCringe = ApolloTypes.FetchResult;
type Props = {
documentId: string;
};
export const useOrderQuery = ({ documentId }: Props) =>
useQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['orders', 'get', documentId],
});

View File

@ -1,21 +0,0 @@
'use client';
import { getProfile, updateProfile } from '@/actions/profile';
import { type ProfileProps } from '@/components/profile/types';
import { useMutation, useQuery } from '@tanstack/react-query';
export const useProfileQuery = ({ telegramId }: ProfileProps) => {
return useQuery({
queryFn: () => getProfile({ telegramId }),
queryKey: telegramId ? ['profile', 'telegramId', telegramId, 'get'] : ['profile', 'get'],
});
};
export const useProfileMutation = ({ telegramId }: ProfileProps) => {
const { refetch } = useProfileQuery({ telegramId });
return useMutation({
mutationFn: updateProfile,
mutationKey: ['profile', 'telegramId', telegramId, 'update'],
onSuccess: () => refetch(),
});
};

View File

@ -1,67 +0,0 @@
'use client';
import { addSlot, deleteSlot, getSlot, getSlots, updateSlot } from '@/actions/slots';
import { ScheduleContext } from '@/context/schedule';
import { formatDate } from '@/utils/date';
// eslint-disable-next-line sonarjs/no-internal-api-use
import type * as ApolloTypes from '@repo/graphql/node_modules/@apollo/client/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { use } from 'react';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type FixTypescriptCringe = ApolloTypes.FetchResult;
export const useSlots = () => {
const { selectedDate } = use(ScheduleContext);
return useQuery({
queryFn: () =>
getSlots({
filters: {
date: {
eq: formatDate(selectedDate).db(),
},
},
}),
queryKey: ['slots', 'list', selectedDate],
});
};
type Props = {
documentId: string;
};
export const useSlotQuery = ({ documentId }: Props) =>
useQuery({
queryFn: () => getSlot({ documentId }),
queryKey: ['slots', 'get', documentId],
});
export const useSlotMutation = ({ documentId }: Props) => {
const { refetch } = useSlotQuery({ documentId });
return useMutation({
mutationFn: updateSlot,
mutationKey: ['slots', 'update', documentId],
onSuccess: () => refetch(),
});
};
export const useSlotAdd = () => {
const { refetch } = useSlots();
return useMutation({
mutationFn: addSlot,
mutationKey: ['slots', 'add'],
onSuccess: () => refetch(),
});
};
export const useSlotDelete = ({ documentId }: Props) => {
const { refetch } = useSlots();
return useMutation({
mutationFn: () => deleteSlot({ documentId }),
mutationKey: ['slots', 'delete', documentId],
onSuccess: () => refetch(),
});
};

View File

@ -5,7 +5,7 @@ import { env } from '@/config/env';
export async function getTelegramUser() {
if (process.env.NODE_ENV !== 'production')
return {
id: env.__DEV_TELEGRAM_ID,
id: Number.parseInt(env.__DEV_TELEGRAM_ID, 10),
};
return null;

View File

@ -3,6 +3,9 @@ import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./utils/i18n/i18n.ts');
const nextConfig = withNextIntl({
eslint: {
ignoreDuringBuilds: true,
},
reactStrictMode: true,
transpilePackages: ['@repo/ui'],
});

View File

@ -17,10 +17,10 @@
"@repo/ui": "workspace:*",
"@tanstack/react-query": "^5.64.1",
"@telegram-apps/sdk-react": "^2.0.19",
"dayjs": "^1.11.13",
"dayjs": "catalog:",
"graphql": "catalog:",
"lucide-react": "catalog:",
"next": "15.2.0",
"next": "15.3.0",
"next-auth": "^4.24.11",
"next-intl": "^3.26.0",
"next-themes": "^0.4.4",
@ -28,7 +28,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"use-debounce": "^10.0.4",
"zod": "catalog:"
"zod": "catalog:",
"zustand": "^5.0.3"
},
"devDependencies": {
"@playwright/test": "^1.49.1",

View File

@ -1,4 +1,5 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export function AuthProvider({ children }: { readonly children: React.ReactNode }) {

View File

@ -1,5 +1,6 @@
/* eslint-disable sonarjs/function-return-type */
'use client';
import { useClientOnce, useDidMount } from '@/hooks/telegram';
import { setLocale } from '@/utils/i18n/locale';
import { init } from '@/utils/telegram/init';

View File

@ -1,4 +1,5 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ComponentProps, useEffect, useState } from 'react';

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,17 @@
'use client';
import { createOrderStore } from './store';
import { createContext, type PropsWithChildren, useRef } from 'react';
export type OrderStoreApi = ReturnType<typeof createOrderStore>;
export const OrderStoreContext = createContext<OrderStoreApi | undefined>(undefined);
export function OrderStoreProvider({ children }: Readonly<PropsWithChildren>) {
const storeRef = useRef<null | OrderStoreApi>(null);
if (storeRef.current === null) {
storeRef.current = createOrderStore();
}
return <OrderStoreContext value={storeRef.current}>{children}</OrderStoreContext>;
}

View File

@ -0,0 +1,56 @@
/* eslint-disable canonical/id-match */
'use client';
import { OrderStoreContext } from './context';
import { type OrderStore, type Steps } from './types';
import { useCustomerQuery } from '@/hooks/api/customers';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { useContext, useEffect } from 'react';
import { useStore } from 'zustand';
export const useOrderStore = <T,>(selector: (store: OrderStore) => T): T => {
const orderStoreContext = useContext(OrderStoreContext);
if (!orderStoreContext) {
throw new Error(`useOrderStore must be used within OrderStoreProvider`);
}
return useStore(orderStoreContext, selector);
};
const STEPS: Steps[] = [
'master-select',
'client-select',
'service-select',
'datetime-select',
'success',
];
export const MASTER_STEPS: Steps[] = STEPS.filter((step) => step !== 'master-select');
export const CLIENT_STEPS: Steps[] = STEPS.filter((step) => step !== 'client-select');
export function useInitOrderStore() {
const { data: { customer } = {} } = useCustomerQuery();
const setMasterId = useOrderStore((store) => store.setMasterId);
const setClientId = useOrderStore((store) => store.setClientId);
const setStep = useOrderStore((store) => store.setStep);
const setStepsSequence = useOrderStore((store) => store._setStepSequence);
useEffect(() => {
const role = customer?.role;
if (role === Enum_Customer_Role.Master && customer) {
setMasterId(customer?.documentId);
}
if (role === Enum_Customer_Role.Client && customer) {
setClientId(customer?.documentId);
}
const steps = role === Enum_Customer_Role.Master ? MASTER_STEPS : CLIENT_STEPS;
const initialStep = steps[0] as Steps;
setStepsSequence(steps);
setStep(initialStep);
}, [customer, setClientId, setMasterId, setStep, setStepsSequence]);
}

View File

@ -0,0 +1,2 @@
export * from './context';
export { useOrderStore } from './hooks';

View File

@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable canonical/id-match */
import { type OrderStore } from './types';
import { createStore } from 'zustand';
export function createOrderStore() {
return createStore<OrderStore>((set, get) => ({
_setStepSequence: (steps) => set({ _stepSequence: steps }),
_stepSequence: [],
clientId: null,
date: new Date(),
masterId: null,
nextStep: () => {
const { _stepSequence, step } = get();
const index = _stepSequence.indexOf(step);
const next = _stepSequence[index + 1];
if (next) set({ step: next });
},
prevStep: () => {
const { _stepSequence, step } = get();
const index = _stepSequence.indexOf(step);
const previous = _stepSequence[index - 1];
if (previous) set({ step: previous });
},
serviceId: null,
setClientId: (id) => set({ clientId: id }),
setDate: (date) => set({ date }),
setMasterId: (id) => set({ masterId: id }),
setServiceId: (id) => set({ serviceId: id }),
setSlotId: (id) => set({ slotId: id }),
setStep: (step) => set({ step }),
setTime: (time) => set({ time }),
slotId: null,
step: 'loading',
time: null,
}));
}

View File

@ -0,0 +1,29 @@
export type OrderStore = {
_setStepSequence: (steps: Steps[]) => void;
_stepSequence: Steps[];
clientId: null | string;
date: Date;
masterId: null | string;
nextStep: () => void;
prevStep: () => void;
serviceId: null | string;
setClientId: (id: null | string) => void;
setDate: (date: Date) => void;
setMasterId: (id: null | string) => void;
setServiceId: (id: null | string) => void;
setSlotId: (slot: null | string) => void;
setStep: (step: Steps) => void;
setTime: (time: null | string) => void;
slotId: null | string;
step: Steps;
time: null | string;
};
export type Steps =
| 'client-select'
| 'datetime-select'
| 'error'
| 'loading'
| 'master-select'
| 'service-select'
| 'success';

View File

@ -4,11 +4,11 @@ import { type DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session extends DefaultSession {
user?: {
telegramId?: null | string;
telegramId?: null | number;
};
}
interface User extends DefaultUser {
telegramId?: null | string;
telegramId?: null | number;
}
}

View File

@ -7,7 +7,8 @@
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js",
"./tailwind": "./tailwind.js"
"./tailwind": "./tailwind.js",
"./typescript": "./typescript.js"
},
"devDependencies": {
"@vchikalkin/eslint-config-awesome": "catalog:",

View File

@ -0,0 +1,12 @@
import { config as baseConfig } from './base.js';
import awesome from '@vchikalkin/eslint-config-awesome';
/**
* A custom ESLint configuration for libraries that use TypeScript.
*
* @type {import("eslint").Linter.Config}
* */
export const typescript = [
...baseConfig,
...awesome['typescript'],
];

View File

@ -0,0 +1,15 @@
type CustomerProfile = {
telegramId: number;
};
export class BaseService {
protected customer: CustomerProfile;
constructor(customer: CustomerProfile) {
if (!customer?.telegramId) {
throw new Error('Invalid customer profile: telegramId required');
}
this.customer = customer;
}
}

View File

@ -1,108 +0,0 @@
'use server';
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
export async function createOrUpdateUser(input: GQL.CreateCustomerMutationVariables) {
if (!input.phone && !input.telegramId) throw new Error('Missing phone and telegramId');
const { query, mutate } = await getClientWithToken();
const response = await query({
query: GQL.GetCustomerDocument,
variables: input,
});
const customer = response?.data?.customers?.at(0);
if (customer && customer.phone === input.phone) {
return mutate({
mutation: GQL.UpdateCustomerProfileDocument,
variables: {
documentId: customer.documentId,
data: { ...input },
},
});
}
return mutate({
mutation: GQL.CreateCustomerDocument,
variables: input,
});
}
export async function getCustomer(input: GQL.GetCustomerQueryVariables) {
const { query } = await getClientWithToken();
return query({
query: GQL.GetCustomerDocument,
variables: input,
});
}
export async function getCustomerMasters(input: GQL.GetCustomerMastersQueryVariables) {
const { query } = await getClientWithToken();
return query({
query: GQL.GetCustomerMastersDocument,
variables: input,
});
}
export async function getCustomerClients(input: GQL.GetCustomerClientsQueryVariables) {
const { query } = await getClientWithToken();
return query({
query: GQL.GetCustomerClientsDocument,
variables: input,
});
}
type AddCustomerMasterInput = Pick<GQL.CreateCustomerMutationVariables, 'phone' | 'telegramId'> & {
masterId: GQL.Scalars['ID']['input'];
operation: 'add' | 'remove';
};
export async function updateCustomerMaster(input: AddCustomerMasterInput) {
if (!input.phone && !input.telegramId) throw new Error('Missing phone and telegramId');
const { query } = await getClientWithToken();
const response = await query({
query: GQL.GetCustomerMastersDocument,
variables: input,
});
const customer = response?.data?.customers?.at(0);
if (customer) {
let newMastersIds = customer.masters.map((x) => x?.documentId);
switch (input.operation) {
case 'add':
if (newMastersIds.includes(input.masterId)) return;
newMastersIds = [...newMastersIds, input.masterId];
break;
case 'remove':
newMastersIds = newMastersIds.filter((x) => x !== input.masterId);
break;
default:
break;
}
return updateCustomerProfile({
documentId: customer.documentId,
data: {
masters: newMastersIds,
},
});
}
}
export async function updateCustomerProfile(input: GQL.UpdateCustomerProfileMutationVariables) {
const { mutate } = await getClientWithToken();
return mutate({
mutation: GQL.UpdateCustomerProfileDocument,
variables: input,
});
}

View File

@ -0,0 +1,116 @@
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
import { BaseService } from './base';
import { type VariablesOf } from '@graphql-typed-document-node/core';
export class CustomersService extends BaseService {
async addMasters(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
const newMasterIds = variables.data.masters;
const { mutate, query } = await getClientWithToken();
const getMastersResult = await query({
query: GQL.GetMastersDocument,
variables,
});
const existingMasterIds = getMastersResult?.data?.customers
?.at(0)
?.masters.map((x) => x?.documentId);
const newMastersIds = [...new Set([...(existingMasterIds || []), ...(newMasterIds || [])])];
const mutationResult = await mutate({
mutation: GQL.UpdateCustomerDocument,
variables: {
data: { masters: newMastersIds },
documentId: variables.documentId,
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async createCustomer(variables: VariablesOf<typeof GQL.CreateCustomerDocument>) {
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateCustomerDocument,
variables,
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetCustomerDocument,
variables,
});
if (result.error) throw new Error(result.error.message);
const customer = result.data.customers.at(0);
return { customer };
}
async getClients(variables?: VariablesOf<typeof GQL.GetClientsDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetClientsDocument,
variables: {
telegramId: variables?.telegramId || this.customer.telegramId,
},
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
async getMasters(variables?: VariablesOf<typeof GQL.GetMastersDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetMastersDocument,
variables: {
telegramId: variables?.telegramId || this.customer.telegramId,
},
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
async updateCustomer(
variables: Omit<VariablesOf<typeof GQL.UpdateCustomerDocument>, 'documentId'>,
) {
const { customer } = await this.getCustomer(this.customer);
if (!customer) throw new Error('Customer not found');
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.UpdateCustomerDocument,
variables: {
data: variables.data,
documentId: customer.documentId,
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
}

View File

@ -1,4 +0,0 @@
export * from './auth';
export * from './customer';
export * from './slot';
export * from './order';

View File

@ -1,12 +0,0 @@
'use server';
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
export async function getOrder(input: GQL.GetOrderQueryVariables) {
const { query } = await getClientWithToken();
return query({
query: GQL.GetOrderDocument,
variables: input,
});
}

View File

@ -0,0 +1,100 @@
/* 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 { formatDate, formatTime, sumTime } from '../utils/datetime-format';
import { BaseService } from './base';
import { CustomersService } from './customers';
import { ServicesService } from './services';
import { SlotsService } from './slots';
import { type VariablesOf } from '@graphql-typed-document-node/core';
const ERRORS = {
INVALID_CLIENT: 'Invalid client',
INVALID_MASTER: 'Invalid master',
MISSING_CLIENT: 'Missing client id',
MISSING_SERVICE_ID: 'Missing service id',
MISSING_SERVICES: 'Missing services',
MISSING_SLOT: 'Missing slot id',
MISSING_START_TIME: 'Missing time start',
SLOT_CLOSED: 'Slot is closed',
};
export class OrdersService extends BaseService {
async createOrder(variables: {
input: Omit<VariablesOf<typeof GQL.CreateOrderDocument>['input'], 'time_end'>;
}) {
if (!variables.input.slot) throw new Error(ERRORS.MISSING_SLOT);
if (!variables.input.client) throw new Error(ERRORS.MISSING_CLIENT);
if (!variables.input.services?.length) throw new Error(ERRORS.MISSING_SERVICES);
if (!variables.input.services[0]) throw new Error(ERRORS.MISSING_SERVICE_ID);
if (!variables.input.time_start) throw new Error(ERRORS.MISSING_START_TIME);
const customersService = new CustomersService(this.customer);
const slotsService = new SlotsService(this.customer);
const servicesService = new ServicesService(this.customer);
const { customer } = await customersService.getCustomer(this.customer);
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) {
if (customer.documentId !== variables.input.client) {
throw new Error(ERRORS.INVALID_CLIENT);
}
const masters = await customersService.getMasters(this.customer);
const masterId = slot?.master?.documentId;
if (!masters.customers.some((master) => master?.documentId === masterId)) {
throw new Error(ERRORS.INVALID_MASTER);
}
}
if (
customer?.role === Enum_Customer_Role.Master &&
slot?.master?.documentId !== customer.documentId
) {
throw new Error(ERRORS.INVALID_MASTER);
}
const { service } = await servicesService.getService({
documentId: variables.input.services[0],
});
const endTime = sumTime(variables.input.time_start, service?.duration);
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateOrderDocument,
variables: {
input: {
client: variables.input.client,
date: formatDate(variables.input.date).db(),
services: variables.input.services,
slot: variables.input.slot,
time_end: formatTime(endTime).db(),
time_start: formatTime(variables.input.time_start).db(),
},
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async getOrder(variables: VariablesOf<typeof GQL.GetOrderDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetOrderDocument,
variables,
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
}

View File

@ -0,0 +1,32 @@
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
import { BaseService } from './base';
import { type VariablesOf } from '@graphql-typed-document-node/core';
export class ServicesService extends BaseService {
async getService(variables: VariablesOf<typeof GQL.GetServiceDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetServiceDocument,
variables,
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
async getServices(variables: VariablesOf<typeof GQL.GetServicesDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetServicesDocument,
variables,
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
}

View File

@ -1,48 +0,0 @@
'use server';
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
export async function createSlot(input: GQL.CreateSlotMutationVariables['input']) {
const { mutate } = await getClientWithToken();
return mutate({
mutation: GQL.CreateSlotDocument,
variables: { input },
});
}
export async function getSlots(input: GQL.GetSlotsQueryVariables) {
const { query } = await getClientWithToken();
return query({
query: GQL.GetSlotsDocument,
variables: input,
});
}
export async function getSlot(input: GQL.GetSlotQueryVariables) {
const { query } = await getClientWithToken();
return query({
query: GQL.GetSlotDocument,
variables: input,
});
}
export async function updateSlot(input: GQL.UpdateSlotMutationVariables) {
const { mutate } = await getClientWithToken();
return mutate({
mutation: GQL.UpdateSlotDocument,
variables: input,
});
}
export async function deleteSlot(input: GQL.DeleteSlotMutationVariables) {
const { mutate } = await getClientWithToken();
return mutate({
mutation: GQL.DeleteSlotDocument,
variables: input,
});
}

View File

@ -0,0 +1,98 @@
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
import { formatDate, formatTime } from '../utils/datetime-format';
import { BaseService } from './base';
import { CustomersService } from './customers';
import { type VariablesOf } from '@graphql-typed-document-node/core';
export class SlotsService extends BaseService {
async createSlot(variables: VariablesOf<typeof GQL.CreateSlotDocument>) {
const customerService = new CustomersService(this.customer);
const { customer } = await customerService.getCustomer(this.customer);
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateSlotDocument,
variables: {
...variables,
date: formatDate(variables.input.date).db(),
master: customer?.documentId,
time_end: formatTime(variables.input.time_end).db(),
time_start: formatTime(variables.input.time_start).db(),
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async deleteSlot(variables: VariablesOf<typeof GQL.DeleteSlotDocument>) {
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.DeleteSlotDocument,
variables,
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async getSlot(variables: VariablesOf<typeof GQL.GetSlotDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetSlotDocument,
variables,
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
async getSlots(variables: VariablesOf<typeof GQL.GetSlotsDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetSlotsDocument,
variables: {
filters: {
...variables.filters,
date: { eq: formatDate(variables?.filters?.date?.eq).db() },
},
},
});
if (result.error) throw new Error(result.error.message);
return result.data;
}
async updateSlot(variables: VariablesOf<typeof GQL.UpdateSlotDocument>) {
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.UpdateSlotDocument,
variables: {
...variables,
date: variables.data?.date ? formatDate(variables.data.date).db() : undefined,
time_end: variables.data?.time_end ? formatTime(variables.data.time_end).db() : undefined,
time_start: variables.data?.time_start
? formatTime(variables.data.time_start).db()
: undefined,
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
}

View File

@ -1,4 +1,4 @@
import { login } from '../api';
import { login } from '../api/auth';
import { isTokenExpired } from '../utils/jwt';
export const token: null | string = null;

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