From 10b36978fe2bf4a2832a423db37b1c2e5e5289b1 Mon Sep 17 00:00:00 2001 From: Vlad Chikalkin Date: Mon, 20 Jan 2025 18:11:33 +0300 Subject: [PATCH] Feature/10 contacts (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * apps/bot: add feature add contact * apps/bot: check role 'master' before add contact * apps/bot: rename createCustomer -> createUser * remove ';' * app/bot: add contact define name & phone * apps/bot: check user already exists w/o telegramId (invited) * Чтобы добавить контакт, сначала поделитесь своим номером телефона. * apps/bot: create or update functions * apps/bot: remove api.ts -> move getCustomer to packages/graphql/api * packages/graphql: add api/customer tests * tests for createOrUpdateClient * fix(apps/web): user is undefined * fix(apps/web): actions getCustomer * feat(apps/web): update user photo on app launch * rename page 'masters' -> 'contacts' * feat(apps/web): add basic /contacts page * fix app layout * refactor customer queries * add action getProfile * get customer contacts * use zustand for contacts * add loading spinner * rename filteredContacts -> contacts * replace zustand with @tanstack/react-query * profile: use react-query * refactor updateRole function * move updateRole closer to profile-card * beautify actions * add page 'profile/[telegramId]' * profile: add button "message to telegram" * profile: add call feature * app/bot: normalize phone before register * do not open keyboard on page load * contacts: loading spinner * telegram login: customer.active=true * update name on telegram first login --- apps/bot/src/index.ts | 113 ++-- apps/bot/src/message.ts | 26 + apps/bot/src/utils/phone.ts | 5 + apps/web/actions/contacts.ts | 30 + apps/web/actions/profile.ts | 55 +- apps/web/app/(auth)/telegram/layout.tsx | 2 +- apps/web/app/(auth)/telegram/page.tsx | 36 +- apps/web/app/(main)/contacts/page.tsx | 17 + apps/web/app/(main)/layout.tsx | 2 +- apps/web/app/(main)/masters/page.tsx | 3 - .../app/(main)/profile/[telegramId]/page.tsx | 27 + apps/web/app/(main)/profile/page.tsx | 51 +- apps/web/app/layout.tsx | 7 +- apps/web/components/common/spinner.tsx | 9 + .../web/components/contacts/contacts-list.tsx | 46 ++ .../components/contacts/dropdown-filter.tsx | 37 ++ apps/web/components/navigation/navbar.tsx | 4 +- .../components/profile/checkbox-with-text.tsx | 8 +- apps/web/components/profile/lib/actions.ts | 9 + apps/web/components/profile/profile-card.tsx | 80 +++ apps/web/components/profile/profile-field.tsx | 14 +- apps/web/context/contacts-filter.tsx | 15 + apps/web/hooks/contacts/index.ts | 1 + .../hooks/contacts/use-customer-contacts.ts | 37 ++ apps/web/package.json | 2 + apps/web/providers/index.ts | 2 - apps/web/providers/query.tsx | 42 ++ packages/graphql/api/customer.ts | 100 ++- packages/graphql/operations/customer.graphql | 42 +- .../graphql/types/operations.generated.ts | 37 +- packages/ui/package.json | 3 + .../ui/src/components/ui/dropdown-menu.tsx | 187 ++++++ packages/ui/src/components/ui/scroll-area.tsx | 45 ++ pnpm-lock.yaml | 609 ++++++++++++++++++ 34 files changed, 1546 insertions(+), 157 deletions(-) create mode 100644 apps/bot/src/message.ts create mode 100644 apps/bot/src/utils/phone.ts create mode 100644 apps/web/actions/contacts.ts create mode 100644 apps/web/app/(main)/contacts/page.tsx delete mode 100644 apps/web/app/(main)/masters/page.tsx create mode 100644 apps/web/app/(main)/profile/[telegramId]/page.tsx create mode 100644 apps/web/components/common/spinner.tsx create mode 100644 apps/web/components/contacts/contacts-list.tsx create mode 100644 apps/web/components/contacts/dropdown-filter.tsx create mode 100644 apps/web/components/profile/lib/actions.ts create mode 100644 apps/web/components/profile/profile-card.tsx create mode 100644 apps/web/context/contacts-filter.tsx create mode 100644 apps/web/hooks/contacts/index.ts create mode 100644 apps/web/hooks/contacts/use-customer-contacts.ts delete mode 100644 apps/web/providers/index.ts create mode 100644 apps/web/providers/query.tsx create mode 100644 packages/ui/src/components/ui/dropdown-menu.tsx create mode 100644 packages/ui/src/components/ui/scroll-area.tsx diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index b789740..1f2451c 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,61 +1,92 @@ +/* eslint-disable canonical/id-match */ +/* eslint-disable consistent-return */ import { env as environment } from './config/env'; -import { createCustomer, getCustomer } from '@repo/graphql/api'; +import { commandsList, KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE } from './message'; +import { normalizePhoneNumber } from './utils/phone'; +import { createOrUpdateUser, getCustomer, updateCustomerMaster } from '@repo/graphql/api'; +import { Enum_Customer_Role } from '@repo/graphql/types'; import { Telegraf } from 'telegraf'; import { message } from 'telegraf/filters'; const bot = new Telegraf(environment.BOT_TOKEN); bot.start(async (context) => { - const { id: telegramId } = context.from; + const customer = await getCustomer({ telegramId: context.from.id }); - const response = await getCustomer({ telegramId }); - const customer = response?.data?.customers?.at(0); - - if (customer && !response.error) { + if (customer) { return context.reply( - `Приветствуем снова, ${customer.name}. Чтобы воспользоваться сервисом, откройте приложение.`, - { - reply_markup: { - remove_keyboard: true, - }, - }, + `Приветствуем снова, ${customer.name} 👋. +Чтобы воспользоваться сервисом, откройте приложение.` + commandsList, + KEYBOARD_REMOVE, ); } - return context.reply('Добро пожаловать! Пожалуйста, поделитесь своим номером телефона.', { - reply_markup: { - keyboard: [ - [ - { - request_contact: true, - text: 'Отправить номер телефона', - }, - ], - ], - one_time_keyboard: true, - }, - }); + return context.reply( + 'Добро пожаловать! Пожалуйста, поделитесь своим номером телефона.', + KEYBOARD_SHARE_PHONE, + ); +}); + +bot.command('addcontact', async (context) => { + const customer = await getCustomer({ telegramId: context.from.id }); + + if (!customer) { + return context.reply( + 'Чтобы добавить контакт, сначала поделитесь своим номером телефона.', + KEYBOARD_SHARE_PHONE, + ); + } + + return context.reply('Отправьте контакт клиента, которого вы хотите добавить'); }); bot.on(message('contact'), async (context) => { - const response = await createCustomer({ - name: context.from.first_name, - phone: context.message.contact.phone_number, - telegramId: context.from.id, - }); + const customer = await getCustomer({ telegramId: context.from.id }); + const isRegistration = !customer; - if (response.errors?.length) { - return context.reply('Произошла ошибка, попробуйте позже.'); + const { contact } = context.message; + const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim(); + const phone = normalizePhoneNumber(contact.phone_number); + + if (isRegistration) { + const response = await createOrUpdateUser({ + name, + phone, + telegramId: context.from.id, + }).catch((error) => { + context.reply('Произошла ошибка.\n' + error); + }); + + if (response) { + return context.reply( + `Спасибо! Мы сохранили ваш номер телефона. Теперь можете открыть приложение или воспользоваться командами бота.` + + commandsList, + KEYBOARD_REMOVE, + ); + } + } else { + if (customer.role !== Enum_Customer_Role.Master) { + return context.reply( + 'Только мастер может добавлять контакты. \nСтать мастером можно на странице профиля в приложении.', + ); + } + + try { + await createOrUpdateUser({ name, phone }); + + await updateCustomerMaster({ + masterId: customer.documentId, + operation: 'add', + phone, + }); + + return context.reply( + `Добавили контакт ${name}. Пригласите пользователя в приложение и тогда вы сможете добавлять записи с этим контактом.`, + ); + } catch (error) { + context.reply('Произошла ошибка.\n' + error); + } } - - return context.reply( - 'Спасибо! Мы сохранили ваш номер телефона. Теперь можете открыть приложение.', - { - reply_markup: { - remove_keyboard: true, - }, - }, - ); }); bot.launch(); diff --git a/apps/bot/src/message.ts b/apps/bot/src/message.ts new file mode 100644 index 0000000..45f66c9 --- /dev/null +++ b/apps/bot/src/message.ts @@ -0,0 +1,26 @@ +import { type ReplyKeyboardRemove } from 'telegraf/types'; + +export const commandsList = ` +\nДоступные команды: + /addcontact - Добавить контакт клиента +`; + +export const KEYBOARD_SHARE_PHONE = { + reply_markup: { + keyboard: [ + [ + { + request_contact: true, + text: 'Отправить номер телефона', + }, + ], + ], + one_time_keyboard: true, + }, +}; + +export const KEYBOARD_REMOVE = { + reply_markup: { + remove_keyboard: true, + } as ReplyKeyboardRemove, +}; diff --git a/apps/bot/src/utils/phone.ts b/apps/bot/src/utils/phone.ts new file mode 100644 index 0000000..924ad3a --- /dev/null +++ b/apps/bot/src/utils/phone.ts @@ -0,0 +1,5 @@ +export function normalizePhoneNumber(phone: string): string { + const digitsOnly = phone.replaceAll(/\D/gu, ''); + + return `+${digitsOnly}`; +} diff --git a/apps/web/actions/contacts.ts b/apps/web/actions/contacts.ts new file mode 100644 index 0000000..8d9f530 --- /dev/null +++ b/apps/web/actions/contacts.ts @@ -0,0 +1,30 @@ +'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 getCustomerClientsResponse = await getCustomerClients({ telegramId: user?.telegramId }); + + return { + clients: getCustomerClientsResponse?.clients, + }; +} + +export async function getMasters() { + const session = await getServerSession(authOptions); + if (!session) throw new Error('Missing session'); + + const { user } = session; + + const getCustomerMastersResponse = await getCustomerMasters({ telegramId: user?.telegramId }); + + return { + masters: getCustomerMastersResponse?.masters, + }; +} diff --git a/apps/web/actions/profile.ts b/apps/web/actions/profile.ts index 93bd5f4..7d6043b 100644 --- a/apps/web/actions/profile.ts +++ b/apps/web/actions/profile.ts @@ -1,44 +1,35 @@ 'use server'; import { authOptions } from '@/config/auth'; import { getCustomer, updateCustomerProfile } from '@repo/graphql/api'; -import { type CustomerInput, type Enum_Customer_Role } from '@repo/graphql/types'; +import { type CustomerInput, type GetCustomerQueryVariables } from '@repo/graphql/types'; import { getServerSession } from 'next-auth/next'; import { revalidatePath } from 'next/cache'; +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 customer = await getCustomer({ telegramId }); + return customer; +} + export async function updateProfile(input: CustomerInput) { const session = await getServerSession(authOptions); + if (!session) throw new Error('Missing session'); - if (session) { - const { user } = session; - const getCustomerResponse = await getCustomer({ telegramId: user?.telegramId }); - const customer = getCustomerResponse.data.customers.at(0); - if (customer) { - await updateCustomerProfile({ - data: input, - documentId: customer.documentId, - }); - } - } - - revalidatePath('/profile'); -} - -export async function updateRole(role: Enum_Customer_Role) { - const session = await getServerSession(authOptions); - - if (session) { - const { user } = session; - const getCustomerResponse = await getCustomer({ telegramId: user?.telegramId }); - const customer = getCustomerResponse.data.customers.at(0); - if (customer) { - await updateCustomerProfile({ - data: { - role, - }, - documentId: customer.documentId, - }); - } - } + const { user } = session; + + const customer = await getCustomer({ telegramId: user?.telegramId }); + if (!customer) throw new Error('Customer not found'); + + await updateCustomerProfile({ + data: input, + documentId: customer.documentId, + }); revalidatePath('/profile'); } diff --git a/apps/web/app/(auth)/telegram/layout.tsx b/apps/web/app/(auth)/telegram/layout.tsx index eed2cc2..e75b962 100644 --- a/apps/web/app/(auth)/telegram/layout.tsx +++ b/apps/web/app/(auth)/telegram/layout.tsx @@ -1,4 +1,4 @@ -import { TelegramProvider } from '@/providers'; +import { TelegramProvider } from '@/providers/telegram'; import { type PropsWithChildren } from 'react'; export default async function Layout({ children }: Readonly) { diff --git a/apps/web/app/(auth)/telegram/page.tsx b/apps/web/app/(auth)/telegram/page.tsx index 3ac0030..38ca5e7 100644 --- a/apps/web/app/(auth)/telegram/page.tsx +++ b/apps/web/app/(auth)/telegram/page.tsx @@ -1,20 +1,52 @@ 'use client'; +import { getProfile, updateProfile } from '@/actions/profile'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react'; import { signIn, useSession } from 'next-auth/react'; import { useTheme } from 'next-themes'; import { redirect } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; export default function Auth() { const initDataUser = useSignal(initData.user); const isDark = isMiniAppDark(); const { status } = useSession(); const { setTheme } = useTheme(); + const [isUpdating, setIsUpdating] = useState(true); useEffect(() => { setTheme(isDark ? 'dark' : 'light'); + const update = async () => { + if (initDataUser?.photoUrl) { + await updateProfile({ photoUrl: initDataUser.photoUrl }); + } + + const customer = await getProfile({ telegramId: initDataUser?.id }); + + if (!customer?.active) { + await updateProfile({ + active: true, + name: `${initDataUser?.firstName || ''} + ' ' + ${initDataUser?.lastName}`.trim(), + }); + } + + setIsUpdating(false); + }; + + update(); + }, [ + initDataUser?.firstName, + initDataUser?.id, + initDataUser?.lastName, + initDataUser?.photoUrl, + isDark, + setTheme, + ]); + + useEffect(() => { + if (isUpdating) return; + if (status === 'authenticated') { redirect('/profile'); } @@ -26,7 +58,7 @@ export default function Auth() { telegramId: String(initDataUser.id), }); } - }, [initDataUser, isDark, setTheme, status]); + }, [initDataUser?.id, isUpdating, status]); return ; } diff --git a/apps/web/app/(main)/contacts/page.tsx b/apps/web/app/(main)/contacts/page.tsx new file mode 100644 index 0000000..6dfce36 --- /dev/null +++ b/apps/web/app/(main)/contacts/page.tsx @@ -0,0 +1,17 @@ +import { ContactsList } from '@/components/contacts/contacts-list'; +import { ContactsFilter } from '@/components/contacts/dropdown-filter'; +import { ContactsFilterProvider } from '@/context/contacts-filter'; + +export default function ContactsPage() { + return ( + +
+

Контакты

+ +
+
+ +
+
+ ); +} diff --git a/apps/web/app/(main)/layout.tsx b/apps/web/app/(main)/layout.tsx index 038c98f..69fff63 100644 --- a/apps/web/app/(main)/layout.tsx +++ b/apps/web/app/(main)/layout.tsx @@ -3,7 +3,7 @@ import { type PropsWithChildren } from 'react'; export default async function Layout({ children }: Readonly) { return ( -
+
{children}
diff --git a/apps/web/app/(main)/masters/page.tsx b/apps/web/app/(main)/masters/page.tsx deleted file mode 100644 index cfc23ee..0000000 --- a/apps/web/app/(main)/masters/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function MastersPage() { - return 'Masters'; -} diff --git a/apps/web/app/(main)/profile/[telegramId]/page.tsx b/apps/web/app/(main)/profile/[telegramId]/page.tsx new file mode 100644 index 0000000..c28c768 --- /dev/null +++ b/apps/web/app/(main)/profile/[telegramId]/page.tsx @@ -0,0 +1,27 @@ +import { getProfile } from '@/actions/profile'; +import { ProfileCard } from '@/components/profile/profile-card'; +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; + +type Props = { + params: Promise<{ + telegramId: string; + }>; +}; + +export default async function ProfilePage(props: Readonly) { + const parameters = await props.params; + const { telegramId } = parameters; + + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryFn: () => getProfile({ telegramId }), + queryKey: telegramId ? ['profile', 'telegramId', telegramId] : ['profile'], + }); + + return ( + + + + ); +} diff --git a/apps/web/app/(main)/profile/page.tsx b/apps/web/app/(main)/profile/page.tsx index cf1567c..1fbf2a9 100644 --- a/apps/web/app/(main)/profile/page.tsx +++ b/apps/web/app/(main)/profile/page.tsx @@ -1,47 +1,18 @@ -import { updateProfile, updateRole } from '@/actions/profile'; -import { CheckboxWithText } from '@/components/profile/checkbox-with-text'; -import { ProfileField } from '@/components/profile/profile-field'; -import { authOptions } from '@/config/auth'; -import { getCustomer } from '@repo/graphql/api'; -import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar'; -import { Card, CardContent, CardHeader } from '@repo/ui/components/ui/card'; -import { getServerSession } from 'next-auth/next'; +import { getProfile } from '@/actions/profile'; +import { ProfileCard } from '@/components/profile/profile-card'; +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; export default async function ProfilePage() { - const session = await getServerSession(authOptions); - const { data } = await getCustomer({ telegramId: session?.user?.telegramId }); - const user = data.customers.at(0); - const photoUrl = user?.photoUrl ?? 'https://github.com/shadcn.png'; + const queryClient = new QueryClient(); - if (!user) return 'Профиль не найден'; + await queryClient.prefetchQuery({ + queryFn: () => getProfile(), + queryKey: ['profile'], + }); return ( -
- - - - - {user?.name.charAt(0)} - -

{user?.name}

-
- - - - - -
-
+ + + ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index def6cb2..49adbfd 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,5 @@ -import { AuthProvider } from '@/providers'; +import { AuthProvider } from '@/providers/auth'; +import { QueryProvider } from '@/providers/query'; import { ThemeProvider } from '@/providers/theme-provider'; import { I18nProvider } from '@/utils/i18n/provider'; import '@repo/ui/globals.css'; @@ -18,7 +19,9 @@ export default async function RootLayout({ children }: Readonly - {children} + + {children} + diff --git a/apps/web/components/common/spinner.tsx b/apps/web/components/common/spinner.tsx new file mode 100644 index 0000000..2d31776 --- /dev/null +++ b/apps/web/components/common/spinner.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from 'lucide-react'; + +export function LoadingSpinner() { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/contacts/contacts-list.tsx b/apps/web/components/contacts/contacts-list.tsx new file mode 100644 index 0000000..3db173d --- /dev/null +++ b/apps/web/components/contacts/contacts-list.tsx @@ -0,0 +1,46 @@ +'use client'; +import { LoadingSpinner } from '../common/spinner'; +import { useCustomerContacts } from '@/hooks/contacts'; +import * as GQL from '@repo/graphql/types'; +import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar'; +import Link from 'next/link'; +import { memo } from 'react'; + +type ContactRowProps = { + readonly contact: GQL.CustomerFieldsFragment; +}; + +const ContactRow = memo(function ({ contact }: ContactRowProps) { + return ( + +
+ + + {contact.name.charAt(0)} + +
+

{contact.name}

+

+ {contact.role === GQL.Enum_Customer_Role.Client ? 'Клиент' : 'Мастер'} +

+
+
+ + ); +}); + +export function ContactsList() { + const { contacts, isLoading } = useCustomerContacts(); + + if (isLoading) return ; + + if (!contacts.length) return
Контакты не найдены
; + + return ( +
+ {contacts.map((contact) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/contacts/dropdown-filter.tsx b/apps/web/components/contacts/dropdown-filter.tsx new file mode 100644 index 0000000..62645f9 --- /dev/null +++ b/apps/web/components/contacts/dropdown-filter.tsx @@ -0,0 +1,37 @@ +'use client'; +import { ContactsFilterContext, type FilterType } from '@/context/contacts-filter'; +import { Button } from '@repo/ui/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@repo/ui/components/ui/dropdown-menu'; +import { ChevronDown } from 'lucide-react'; +import { use } from 'react'; + +const filterLabels: Record = { + all: 'Все', + clients: 'Клиенты', + masters: 'Мастера', +}; + +export function ContactsFilter() { + const { filter, setFilter } = use(ContactsFilterContext); + + return ( + + + + + + setFilter('all')}>Все + setFilter('clients')}>Клиенты + setFilter('masters')}>Мастера + + + ); +} diff --git a/apps/web/components/navigation/navbar.tsx b/apps/web/components/navigation/navbar.tsx index 311dee3..3ef6747 100644 --- a/apps/web/components/navigation/navbar.tsx +++ b/apps/web/components/navigation/navbar.tsx @@ -3,12 +3,12 @@ import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react'; export function BottomNav() { return ( -