Enhance contact management by adding surname input and updating customer handling

- Introduced a new input for capturing the surname of the user during contact addition.
- Updated the contact parsing logic to include surname alongside name and phone number.
- Modified the customer creation and update processes to accommodate surname, ensuring full name is used in confirmation messages.
- Adjusted localization files to reflect the new surname input prompt and updated confirmation messages.
- Refactored components to utilize a unified method for retrieving full customer names, improving consistency across the application.
This commit is contained in:
vchikalkin 2025-10-07 12:36:03 +03:00
parent 9244eaec26
commit c7648e8bf9
13 changed files with 127 additions and 69 deletions

View File

@ -79,9 +79,10 @@ msg-send-client-contact = 👤 Отправьте контакт пользов
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
msg-contact-added =
✅ Добавили { $name } в список ваших контактов
✅ Добавили { $fullname } в список ваших контактов
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи
msg-contact-forward = Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️

View File

@ -2,6 +2,7 @@
/* eslint-disable id-length */
import { type Context } from '@/bot/context';
import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact';
import { combine } from '@/utils/messages';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { type Conversation } from '@grammyjs/conversations';
@ -36,12 +37,15 @@ export async function addContact(conversation: Conversation<Context, Context>, c
const firstCtx = await conversation.wait();
let name = '';
let surname = '';
let phone = '';
if (firstCtx.message?.contact) {
const { contact } = firstCtx.message;
name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
phone = normalizePhoneNumber(contact.phone_number);
const parsedContact = parseContact(contact);
name = parsedContact.name;
surname = parsedContact.surname;
phone = normalizePhoneNumber(parsedContact.phone);
} else if (firstCtx.message?.text) {
const typedPhone = normalizePhoneNumber(firstCtx.message.text);
if (!isValidPhoneNumber(typedPhone)) {
@ -64,6 +68,16 @@ export async function addContact(conversation: Conversation<Context, Context>, c
}
name = typedName;
// Просим ввести фамилию клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-surname')));
const surnameCtx = await conversation.wait();
const typedSurname = surnameCtx.message?.text?.trim() || '';
if (!typedSurname) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-surname')));
}
surname = typedSurname;
} else {
return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone')));
}
@ -81,7 +95,9 @@ export async function addContact(conversation: Conversation<Context, Context>, c
// Если клиента нет, создаём нового
if (!documentId) {
const registrationService = new RegistrationService();
const createCustomerResult = await registrationService.createCustomer({ name, phone });
const createCustomerResult = await registrationService.createCustomer({
data: { name, phone, surname },
});
documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) throw new Error('Клиент не создан');
@ -92,7 +108,11 @@ export async function addContact(conversation: Conversation<Context, Context>, c
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
// Отправляем подтверждения и инструкции
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-added', { name })));
await ctx.reply(
await conversation.external(({ t }) =>
t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }),
),
);
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')));
await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT);
} catch (error) {

View File

@ -1,6 +1,7 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards';
import { parseContact } from '@/utils/contact';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration';
@ -14,7 +15,7 @@ const feature = composer.chatType('private');
feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
const telegramId = ctx.from.id;
const { contact } = ctx.message;
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const { name, surname } = parseContact(contact);
// Проверяем, не зарегистрирован ли уже пользователь
const customerService = new CustomersService({ telegramId });
@ -46,7 +47,7 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
if (customer && !customer.telegramId) {
// Пользователь добавлен ранее мастером — обновляем данные
await registrationService.updateCustomer({
data: { active: true, name, telegramId },
data: { active: true, name, surname, telegramId },
documentId: customer.documentId,
});
@ -56,7 +57,9 @@ feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
}
// Новый пользователь — создаём и активируем
const response = await registrationService.createCustomer({ name, phone, telegramId });
const response = await registrationService.createCustomer({
data: { name, phone, surname, telegramId },
});
const documentId = response?.createCustomer?.documentId;
if (!documentId) return ctx.reply(ctx.t('err-generic'));

View File

@ -0,0 +1,9 @@
import { type Contact } from '@grammyjs/types';
export function parseContact(contact: Contact) {
return {
name: contact?.first_name?.trim() || '',
phone: contact?.phone_number?.trim() || '',
surname: contact?.last_name?.trim() || '',
};
}

View File

@ -12,6 +12,7 @@ import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import { sift } from 'radashi';
type ContactsGridProps = {
@ -103,7 +104,7 @@ export function ContactsGridBase({
isCurrentUser && 'font-bold',
)}
>
{contact.name}
{getCustomerFullName(contact)}
</span>
</Label>
);
@ -162,6 +163,9 @@ function useContacts() {
return {
isLoading,
...query,
contacts: [{ ...customer, name: 'Я' } as CustomerFieldsFragment, ...contacts],
contacts: [
{ ...customer, name: 'Я', surname: undefined } as CustomerFieldsFragment,
...contacts,
],
};
}

View File

@ -75,6 +75,13 @@ export function ProfileDataCard() {
onChange={(value) => updateField('name', value)}
value={customer?.name ?? ''}
/>
<TextField
id="surname"
key={`surname-${resetTrigger}`}
label="Фамилия"
onChange={(value) => updateField('surname', value)}
value={customer?.surname ?? ''}
/>
<TextField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
<CheckboxWithText
checked={customer.role !== 'client'}

View File

@ -4,6 +4,7 @@ import { type ProfileProps } from './types';
import { UserAvatar } from '@/components/shared/user-avatar';
import { useCustomerQuery } from '@/hooks/api/customers';
import { Card } from '@repo/ui/components/ui/card';
import { getCustomerFullName } from '@repo/utils/customer';
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
@ -24,7 +25,7 @@ export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
<Card className="bg-transparent p-4 shadow-none">
<div className="flex flex-col items-center space-y-2">
<UserAvatar {...customer} size="lg" />
<h2 className="text-2xl font-bold">{customer?.name}</h2>
<h2 className="text-2xl font-bold">{getCustomerFullName(customer)}</h2>
</div>
</Card>
);

View File

@ -2,6 +2,7 @@ import { UserAvatar } from './user-avatar';
import type * as GQL from '@repo/graphql/types';
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import Link from 'next/link';
import { memo } from 'react';
@ -28,7 +29,7 @@ export const ContactRow = memo(function ({ className, description, ...contact }:
<div className={cn('flex items-center space-x-4 rounded-lg transition-colors')}>
<UserAvatar {...contact} size="sm" />
<div>
<p className="font-medium">{contact.name}</p>
<p className="font-medium">{getCustomerFullName(contact)}</p>
{description && (
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
)}

View File

@ -6,6 +6,7 @@ import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils';
import { getCustomerFullName } from '@repo/utils/customer';
import Image from 'next/image';
type Sizes = 'lg' | 'md' | 'sm' | 'xs';
@ -37,7 +38,7 @@ export function UserAvatar({ className, size = 'sm', telegramId = null }: UserAv
)}
>
<Image
alt={customer?.name || 'contact-avatar'}
alt={customer ? getCustomerFullName(customer) : 'contact-avatar'}
className="size-full rounded-full object-cover"
height={80}
src={customer?.photoUrl || AvatarPlaceholder}

View File

@ -4,13 +4,21 @@ import * as GQL from '../types';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import { isCustomerBanned } from '@repo/utils/customer';
const DEFAULT_CUSTOMER_ROLE = GQL.Enum_Customer_Role.Client;
export class RegistrationService {
async createCustomer(variables: VariablesOf<typeof GQL.CreateCustomerDocument>) {
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateCustomerDocument,
variables,
variables: {
...variables,
data: {
...variables.data,
role: DEFAULT_CUSTOMER_ROLE,
},
},
});
const error = mutationResult.errors?.at(0);

View File

@ -3,6 +3,7 @@ fragment CustomerFields on Customer {
bannedUntil
documentId
name
surname
phone
photoUrl
role
@ -13,8 +14,8 @@ fragment CustomerFields on Customer {
}
}
mutation CreateCustomer($name: String!, $telegramId: Long, $phone: String) {
createCustomer(data: { name: $name, telegramId: $telegramId, phone: $phone, role: client }) {
mutation CreateCustomer($data: CustomerInput!) {
createCustomer(data: $data) {
documentId
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,9 @@
import * as GQL from '../../graphql/types';
import type * as GQL from '../../graphql/types';
export function getCustomerFullName(customer: GQL.CustomerFieldsFragment) {
return [customer?.name?.trim(), customer?.surname?.trim()].filter(Boolean).join(' ');
}
export function isCustomerBanned(customer: GQL.CustomerFieldsFragment): boolean {
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
}
// isCustomerMaster удален - больше не нужен при равенстве пользователей