diff --git a/apps/web/src/app/(app)/leads/[id]/page.tsx b/apps/web/src/app/(app)/leads/[id]/page.tsx new file mode 100644 index 00000000..80f5031a --- /dev/null +++ b/apps/web/src/app/(app)/leads/[id]/page.tsx @@ -0,0 +1,39 @@ +import LeadDetail from '@/components/Leads/LeadDetail'; +import { getQueryClient } from '@/lib/react-query/get-query-client'; +import { getLead } from '@/server/query-options'; +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; + +export const dynamic = 'force-dynamic'; + +interface LeadPageProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ params }: LeadPageProps) { + const { id } = await params; + const leadId = parseInt(id, 10); + + try { + const queryClient = getQueryClient(); + const lead = await queryClient.fetchQuery(getLead(leadId)); + return { title: lead.name || 'Lead' }; + } catch { + return { title: 'Lead' }; + } +} + +const LeadPage = async ({ params }: LeadPageProps) => { + const { id } = await params; + const leadId = parseInt(id, 10); + + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(getLead(leadId)); + + return ( + + + + ); +}; + +export default LeadPage; diff --git a/apps/web/src/components/Leads/LeadDetail.tsx b/apps/web/src/components/Leads/LeadDetail.tsx new file mode 100644 index 00000000..20103174 --- /dev/null +++ b/apps/web/src/components/Leads/LeadDetail.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getLead } from '@/server/query-options'; +import { leadsApi } from '@/lib/api/leads'; +import { BackLink, LoadingState } from '@/components/shared'; +import { Badge, Button, Heading, Text } from '@zuko/ui-kit'; +import { + EnvelopeIcon, + PhoneIcon, + BuildingOfficeIcon, + BriefcaseIcon, +} from '@heroicons/react/20/solid'; +import Link from 'next/link'; +import dayjs from 'dayjs'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; + +const STATUS_COLORS: Record = { + replied: 'blue', + interested: 'green', + not_interested: 'red', + converted: 'zinc', +}; + +function SidebarField({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +export default function LeadDetail({ leadId }: { leadId: number }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { data: lead, isLoading } = useQuery(getLead(leadId)); + + const convertMutation = useMutation({ + mutationFn: () => leadsApi.convert(leadId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lead', leadId] }); + queryClient.invalidateQueries({ queryKey: ['leads'] }); + toast.success('Lead converted to deal'); + }, + onError: () => toast.error('Failed to convert lead'), + }); + + const deleteMutation = useMutation({ + mutationFn: () => leadsApi.delete(leadId), + onSuccess: () => { + toast.success('Lead deleted'); + router.push('/leads'); + }, + onError: () => toast.error('Failed to delete lead'), + }); + + if (isLoading) return ; + if (!lead) + return ( +

Lead not found.

+ ); + + return ( +
+ Leads + +
+
+ {lead.name} + {lead.title && ( + + {lead.title} + {lead.companyName ? ` · ${lead.companyName}` : ''} + + )} +
+ +
+ {lead.status !== 'converted' && ( + + )} + +
+
+ +
+ {/* Contact info */} +
+ {lead.email && ( + + + {lead.email} + + )} + {lead.phone && ( + + + {lead.phone} + + )} + {lead.companyName && ( +
+ + {lead.companyName} +
+ )} + {lead.title && ( +
+ + {lead.title} +
+ )} + {lead.linkedinUrl && ( + + LinkedIn → + + )} +
+ + {/* Sidebar */} +
+ + + {lead.status.replace(/_/g, ' ')} + + + + + + {lead.source} + + + + {lead.icpProfile && ( + + + {lead.icpProfile.name} + + + )} + + {lead.campaign && ( + + + {lead.campaign.name} + + + )} + + {lead.contact && ( + + + {lead.contact.name} + + + )} + + {lead.deal && ( + + + {lead.deal.title} + + + )} + + + + {dayjs(lead.createdAt).format('MMM D, YYYY')} + + +
+
+
+ ); +} diff --git a/apps/web/src/components/Leads/LeadsList.tsx b/apps/web/src/components/Leads/LeadsList.tsx index 780c0b51..68893b82 100644 --- a/apps/web/src/components/Leads/LeadsList.tsx +++ b/apps/web/src/components/Leads/LeadsList.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useSheetState } from '@/hooks/use-sheet-state'; import { PlusIcon, XMarkIcon, FunnelIcon } from '@heroicons/react/24/outline'; import { Button, Sheet, SheetHeader, SheetTitle } from '@zuko/ui-kit'; @@ -28,6 +29,7 @@ import { ConfirmDialog } from '@/components/shared/ConfirmDialog'; import { useSearchParam } from '@/hooks/use-search-param'; const LeadsList = () => { + const router = useRouter(); const queryClient = useQueryClient(); const openAddColumnRef = useRef<(() => void) | undefined>(undefined); const [isSheetOpen, setIsSheetOpen] = useSheetState(); @@ -166,7 +168,7 @@ const LeadsList = () => { showAddColumn={false} onAddColumn={() => {}} openAddColumnRef={openAddColumnRef} - disableRowClick={true} + onRowClick={(row) => router.push(`/leads/${row.id}`)} onFetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} hasNextPage={hasNextPage} diff --git a/apps/web/src/server/query-options.ts b/apps/web/src/server/query-options.ts index f0124cca..543bfc7f 100644 --- a/apps/web/src/server/query-options.ts +++ b/apps/web/src/server/query-options.ts @@ -122,6 +122,12 @@ export const getTableViewTasksInfinite = (filters?: { search?: string }) => }, }); +export const getLead = (id: number) => + queryOptions({ + queryKey: ['lead', id], + queryFn: () => leadsApi.get(id), + }); + export const getTableViewLeadsInfinite = (filters?: { search?: string; status?: string;