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:
parent
de7cdefcd5
commit
30bdc0447f
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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="Выбор клиента"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
49
apps/web/components/shared/user-avatar.tsx
Normal file
49
apps/web/components/shared/user-avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user