Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/web/src/app/(app)/leads/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<LeadDetail leadId={leadId} />
</HydrationBoundary>
);
};

export default LeadPage;
220 changes: 220 additions & 0 deletions apps/web/src/components/Leads/LeadDetail.tsx
Original file line number Diff line number Diff line change
@@ -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<string, 'green' | 'blue' | 'red' | 'zinc'> = {
replied: 'blue',
interested: 'green',
not_interested: 'red',
converted: 'zinc',
};

function SidebarField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<Text className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{label}
</Text>
<div className="mt-1.5">{children}</div>
</div>
);
}

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 <LoadingState message="Loading lead…" />;
if (!lead)
return (
<p className="py-8 text-center text-sm text-zinc-500">Lead not found.</p>
);

return (
<div className="flex min-h-0 flex-col">
<BackLink href="/leads">Leads</BackLink>

<div className="mt-4 flex items-start justify-between gap-4">
<div>
<Heading>{lead.name}</Heading>
{lead.title && (
<Text className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{lead.title}
{lead.companyName ? ` · ${lead.companyName}` : ''}
</Text>
)}
</div>

<div className="flex shrink-0 items-center gap-2">
{lead.status !== 'converted' && (
<Button
outline
disabled={convertMutation.isPending}
onClick={() => convertMutation.mutate()}
>
{convertMutation.isPending ? 'Converting…' : '→ Convert to Deal'}
</Button>
)}
<Button
color="red"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
Delete
</Button>
</div>
</div>

<div className="mt-6 flex items-start gap-8">
{/* Contact info */}
<div className="min-w-0 flex-1 space-y-4">
{lead.email && (
<a
href={`mailto:${lead.email}`}
className="flex items-center gap-2 text-sm text-zinc-700 hover:underline dark:text-zinc-300"
>
<EnvelopeIcon className="size-4 shrink-0 text-zinc-400" />
{lead.email}
</a>
)}
{lead.phone && (
<a
href={`tel:${lead.phone}`}
className="flex items-center gap-2 text-sm text-zinc-700 hover:underline dark:text-zinc-300"
>
<PhoneIcon className="size-4 shrink-0 text-zinc-400" />
{lead.phone}
</a>
)}
{lead.companyName && (
<div className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<BuildingOfficeIcon className="size-4 shrink-0 text-zinc-400" />
{lead.companyName}
</div>
)}
{lead.title && (
<div className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<BriefcaseIcon className="size-4 shrink-0 text-zinc-400" />
{lead.title}
</div>
)}
{lead.linkedinUrl && (
<a
href={lead.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-500 hover:underline"
>
LinkedIn →
</a>
)}
</div>

{/* Sidebar */}
<div className="w-64 shrink-0 space-y-5 border-l border-zinc-200 pl-8 dark:border-zinc-700/50">
<SidebarField label="Status">
<Badge color={STATUS_COLORS[lead.status] ?? 'zinc'}>
{lead.status.replace(/_/g, ' ')}
</Badge>
</SidebarField>

<SidebarField label="Source">
<Text className="text-sm capitalize text-zinc-700 dark:text-zinc-300">
{lead.source}
</Text>
</SidebarField>

{lead.icpProfile && (
<SidebarField label="ICP Profile">
<Link
href={`/icps/${lead.icpProfile.id}`}
className="text-sm text-zinc-700 hover:underline dark:text-zinc-300"
>
{lead.icpProfile.name}
</Link>
</SidebarField>
)}

{lead.campaign && (
<SidebarField label="Campaign">
<Link
href={`/campaigns/${lead.campaign.id}`}
className="text-sm text-zinc-700 hover:underline dark:text-zinc-300"
>
{lead.campaign.name}
</Link>
</SidebarField>
)}

{lead.contact && (
<SidebarField label="Contact">
<Link
href={`/contacts/${lead.contact.id}`}
className="text-sm text-zinc-700 hover:underline dark:text-zinc-300"
>
{lead.contact.name}
</Link>
</SidebarField>
)}

{lead.deal && (
<SidebarField label="Deal">
<Link
href={`/deals/${lead.deal.id}`}
className="text-sm text-zinc-700 hover:underline dark:text-zinc-300"
>
{lead.deal.title}
</Link>
</SidebarField>
)}

<SidebarField label="Created">
<Text className="text-sm text-zinc-700 dark:text-zinc-300">
{dayjs(lead.createdAt).format('MMM D, YYYY')}
</Text>
</SidebarField>
</div>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/components/Leads/LeadsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/server/query-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading