feat(contacts): enhance contact display and improve user experience

- Updated ContactsList to include a description prop in ContactRow for better service representation.
- Renamed header in OrderContacts from "Контакты" to "Участники" for clarity.
- Replaced Avatar components with UserAvatar in various components for consistent user representation.
- Filtered active contacts in MastersGrid and ClientsGrid to improve data handling.
- Adjusted customer query logic to ensure proper handling of telegramId.
This commit is contained in:
vchikalkin 2025-09-09 11:23:35 +03:00
parent de7cdefcd5
commit 30bdc0447f
11 changed files with 95 additions and 79 deletions

View File

@ -15,7 +15,11 @@ export function ContactsList() {
return (
<div className="space-y-2">
{contacts.map((contact) => (
<ContactRow key={contact.documentId} showServices {...contact} />
<ContactRow
description={contact.services.map((service) => service?.name).join(', ')}
key={contact.documentId}
{...contact}
/>
))}
</div>
);

View File

@ -10,17 +10,19 @@ export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) {
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">Контакты</h1>
<h1 className="font-bold">Участники</h1>
<div className="space-y-2">
{order.slot?.master && (
<ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
description="Мастер"
{...order.slot?.master}
/>
)}
{order.client && (
<ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
description="Клиент"
{...order.client}
/>
)}

View File

@ -1,22 +1,18 @@
/* eslint-disable canonical/id-match */
'use client';
import { DataNotFound } from '@/components/shared/alert';
import { UserAvatar } from '@/components/shared/user-avatar';
import { CardSectionHeader } from '@/components/ui';
import { ContactsContextProvider } from '@/context/contacts';
import { useCustomerContacts } from '@/hooks/api/contacts';
import { useCustomerQuery } from '@/hooks/api/customers';
// eslint-disable-next-line import/extensions
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { useOrderStore } from '@/stores/order';
import { withContext } from '@/utils/context';
import { type CustomerFieldsFragment, Enum_Customer_Role } from '@repo/graphql/types';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
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 Image from 'next/image';
import { useEffect } from 'react';
type ContactsGridProps = {
readonly contacts: CustomerFieldsFragment[];
@ -53,26 +49,11 @@ export function ContactsGridBase({ contacts, onSelect, selected, title }: Contac
/>
<div
className={cn(
'w-20 h-20 rounded-full border-2 transition-all duration-75',
'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>
<UserAvatar {...contact} size="md" />
</div>
<span
className={cn(
@ -93,27 +74,21 @@ export function ContactsGridBase({ contacts, onSelect, selected, title }: Contac
export const MastersGrid = withContext(ContactsContextProvider)(function () {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
const { contacts, isLoading: isLoadingContacts, setFilter } = useCustomerContacts();
const { contacts, isLoading: isLoadingContacts } = useCustomerContacts();
const masterId = useOrderStore((store) => store.masterId);
const setMasterId = useOrderStore((store) => store.setMasterId);
const isLoading = isLoadingContacts || isLoadingCustomer;
useEffect(() => {
setFilter('invitedBy');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
const mastersContacts = (customer ? [{ ...customer, name: 'Я' }] : []).concat(
contacts.filter((contact) => contact.role === Enum_Customer_Role.Master),
);
const mastersContacts = (customer ? [{ ...customer, name: 'Я' }] : []).concat(contacts);
if (!mastersContacts.length) return <DataNotFound title="Контакты не найдены" />;
return (
<ContactsGridBase
contacts={mastersContacts}
contacts={mastersContacts.filter((contact) => contact.active)}
onSelect={(contactId) => setMasterId(contactId)}
selected={masterId}
title="Выбор мастера"
@ -122,21 +97,17 @@ export const MastersGrid = withContext(ContactsContextProvider)(function () {
});
export const ClientsGrid = withContext(ContactsContextProvider)(function () {
const { contacts, isLoading, setFilter } = useCustomerContacts();
const { contacts, isLoading } = useCustomerContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
useEffect(() => {
setFilter('invited');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
return (
<ContactsGridBase
contacts={contacts}
contacts={contacts.filter((contact) => contact.active)}
onSelect={(contactId) => setClientId(contactId)}
selected={clientId}
title="Выбор клиента"

View File

@ -1,28 +1,27 @@
'use client';
import { type ProfileProps } from './types';
import { UserAvatar } from '@/components/shared/user-avatar';
import { useCustomerQuery } from '@/hooks/api/customers';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { Card } from '@repo/ui/components/ui/card';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
if (isLoading || !customer)
if (isLoading)
return (
<div className="p-4">
<LoadingSpinner />
</div>
);
if (!customer) return null;
return (
<Card className="bg-transparent p-4 shadow-none">
<div className="flex flex-col items-center space-y-2">
<Avatar className="size-20">
<AvatarImage alt={customer?.name} src={customer.photoUrl || ''} />
<AvatarFallback>{customer?.name.charAt(0)}</AvatarFallback>
</Avatar>
<UserAvatar {...customer} size="xl" />
<h2 className="text-2xl font-bold">{customer?.name}</h2>
</div>
</Card>

View File

@ -37,7 +37,7 @@ export function SubscriptionInfoBar() {
return (
<Link href="/pro" rel="noopener noreferrer">
<div className="px-4">
<div className={cn('px-4', isLoading && 'animate-pulse')}>
<div
className={cn(
'flex w-full flex-col justify-center rounded-2xl p-4 px-6 shadow-lg backdrop-blur-2xl',

View File

@ -1,5 +1,5 @@
import { UserAvatar } from './user-avatar';
import type * as GQL from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import Link from 'next/link';
@ -7,10 +7,11 @@ import { memo } from 'react';
type ContactRowProps = GQL.CustomerFieldsFragment & {
readonly className?: string;
readonly description?: string;
readonly showServices?: boolean;
};
export const ContactRow = memo(function ({ className, showServices, ...contact }: ContactRowProps) {
export const ContactRow = memo(function ({ className, description, ...contact }: ContactRowProps) {
return (
<Link
className="block"
@ -24,17 +25,12 @@ export const ContactRow = memo(function ({ className, showServices, ...contact }
className,
)}
>
<div className={cn('flex items-center space-x-4 rounded-lg py-2 transition-colors')}>
<Avatar>
<AvatarImage alt={contact.name} src={contact.photoUrl || ''} />
<AvatarFallback>{contact.name.charAt(0)}</AvatarFallback>
</Avatar>
<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>
{showServices && (
<p className="max-w-52 truncate text-xs text-muted-foreground">
{contact.services.map((service) => service?.name).join(', ')}
</p>
{description && (
<p className="max-w-52 truncate text-xs text-muted-foreground">{description}</p>
)}
</div>
</div>

View File

@ -1,10 +1,10 @@
'use client';
import { ReadonlyTimeRange } from './time-range/readonly';
import { UserAvatar } from './user-avatar';
import { getBadge } from '@/components/shared/status';
import { useCustomerQuery } from '@/hooks/api/customers';
import type * as GQL from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { formatDate } from '@repo/utils/datetime-format';
import Link from 'next/link';
@ -12,8 +12,6 @@ type OrderComponentProps = GQL.OrderFieldsFragment & {
showDate?: boolean;
};
type OrderCustomer = GQL.CustomerFieldsFragment;
export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComponentProps>) {
const services = order?.services.map((service) => service?.name).join(', ');
const { data: { customer } = {} } = useCustomerQuery();
@ -25,7 +23,7 @@ export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComp
return (
<Link href={`/orders/${documentId}`} rel="noopener noreferrer">
<div className="relative flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
<div className="relative flex items-center justify-between rounded-2xl bg-background p-2 px-6 dark:bg-primary/5">
{order.order_number && (
<span className="absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-full border bg-background text-xs font-semibold text-muted-foreground shadow">
{order.order_number}
@ -33,7 +31,7 @@ export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComp
)}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-4">
{avatarSource && <CustomerAvatar customer={avatarSource} />}
{avatarSource && <UserAvatar {...avatarSource} size="sm" />}
<div className="flex min-w-0 flex-1 flex-col">
<ReadonlyTimeRange
datetimeEnd={order?.datetime_end}
@ -53,14 +51,3 @@ export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComp
</Link>
);
}
function CustomerAvatar({ customer }: { readonly customer: OrderCustomer }) {
if (!customer) return null;
return (
<Avatar>
<AvatarImage alt={customer.name} src={customer.photoUrl || ''} />
<AvatarFallback>{customer.name.charAt(0)}</AvatarFallback>
</Avatar>
);
}

View File

@ -0,0 +1,49 @@
'use client';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
// eslint-disable-next-line import/extensions
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils';
import Image from 'next/image';
type Sizes = 'lg' | 'md' | 'sm' | 'xl';
type UserAvatarProps = Pick<CustomerFieldsFragment, 'telegramId'> & {
readonly className?: string;
readonly size?: Sizes;
};
const sizeClasses: Record<Sizes, string> = {
lg: 'size-28',
md: 'size-20',
sm: 'size-16',
xl: 'size-32',
};
export function UserAvatar({ className, size = 'lg', telegramId = null }: UserAvatarProps) {
const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
const { data: subscriptionData } = useSubscriptionQuery({ telegramId });
const hasActiveSubscription = subscriptionData?.hasActiveSubscription;
const sizeClass = sizeClasses[size];
return (
<div className={cn(sizeClass, 'rounded-full', className, isLoading && 'animate-pulse')}>
<div
className={cn(
'size-full rounded-full p-1',
hasActiveSubscription ? 'bg-gradient-to-r from-purple-600 to-blue-600' : 'bg-transparent',
)}
>
<Image
alt={customer?.name || 'contact-avatar'}
className="size-full rounded-full object-cover"
height={80}
src={customer?.photoUrl || AvatarPlaceholder}
width={80}
/>
</div>
</div>
);
}

View File

@ -7,7 +7,8 @@ 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;
const telegramId =
variables?.telegramId === undefined ? session?.user?.telegramId : variables?.telegramId;
return useQuery({
enabled: Boolean(telegramId),

View File

@ -17,7 +17,8 @@ import { useSession } from 'next-auth/react';
export const useSubscriptionQuery = (variables?: Parameters<typeof getSubscription>[0]) => {
const { data: session } = useSession();
const telegramId = variables?.telegramId || session?.user?.telegramId;
const telegramId =
variables?.telegramId === undefined ? session?.user?.telegramId : variables?.telegramId;
return useQuery({
enabled: Boolean(telegramId),

View File

@ -55,7 +55,7 @@ export class SubscriptionsService extends BaseService {
// Проверяем, не использовал ли пользователь уже пробный период
const { subscription: existingSubscription } = await this.getSubscription({
telegramId: customer.telegramId,
telegramId: customer?.telegramId,
});
if (existingSubscription) {
// Проверяем, есть ли в истории успешная пробная подписка
@ -88,7 +88,7 @@ export class SubscriptionsService extends BaseService {
const subscriptionData = await this.createSubscription({
input: {
autoRenew: false,
customer: customer.documentId,
customer: customer?.documentId,
expiresAt: expiresAt.toISOString(),
isActive: true,
},
@ -130,9 +130,15 @@ export class SubscriptionsService extends BaseService {
const subscription = result.data.subscriptions.at(0);
const hasActiveSubscription = Boolean(
subscription?.isActive &&
subscription?.expiresAt &&
new Date() < new Date(subscription.expiresAt),
);
const { maxOrdersPerMonth, remainingOrdersCount } = await this.getRemainingOrdersCount();
return { maxOrdersPerMonth, remainingOrdersCount, subscription };
return { hasActiveSubscription, maxOrdersPerMonth, remainingOrdersCount, subscription };
}
async getSubscriptionHistory(variables: VariablesOf<typeof GQL.GetSubscriptionHistoryDocument>) {