Files
QR-master/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx
2026-04-27 11:42:09 +02:00

826 lines
30 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table';
import {
BarChart3,
Copy,
Download,
Filter,
Loader2,
Lock,
LogOut,
Mail,
Search,
TrendingUp,
Users,
} from 'lucide-react';
import {
getGoalLabel,
getRoleLabel,
getSourceLabel,
getTeamSizeLabel,
getUseCaseLabel,
} from '@/lib/revops';
type SegmentRow = {
id: string;
name: string | null;
email: string;
emailDomain: string | null;
plan: string;
lifecycleStage: string;
lifecycleStageLabel: string;
fitScore: number;
intentScore: number;
leadScore: number;
signupSource: string | null;
signupSourceLabel: string;
signupSourceSelfReported: string | null;
signupSourceSelfReportedLabel: string;
signupCampaign: string | null;
signupLandingPath: string | null;
primaryUseCase: string | null;
primaryUseCaseLabel: string;
primaryGoal: string | null;
primaryGoalLabel: string;
jobRole: string | null;
jobRoleLabel: string;
companyName: string | null;
teamSizeBucket: string | null;
teamSizeLabel: string;
upgradeBadges: string[];
createdAt: string;
firstQrCreatedAt: string | null;
activationAt: string | null;
qrCount: number;
dynamicQrCount: number;
scanCount: number;
};
type DashboardData = {
overview: {
totalUsers: number;
mismatchCount: number;
activatedUsers: number;
paidUsers: number;
};
acquisition: {
bySource: Array<{
key: string;
label: string;
signups: number;
firstQr: number;
activated: number;
hot: number;
upgradeCandidates: number;
paid: number;
activationRate: number;
}>;
byCampaign: Array<{
key: string;
signups: number;
activated: number;
paid: number;
}>;
byLandingPath: Array<{
key: string;
signups: number;
activated: number;
paid: number;
}>;
};
funnel: {
signup: number;
sourceConfirmed: number;
useCaseSelected: number;
goalSelected: number;
profileCaptured: number;
firstQrCreated: number;
firstDynamicQrCreated: number;
activated: number;
};
funnelBreakdowns: {
bySource: Array<any>;
byUseCase: Array<any>;
byRole: Array<any>;
byTeamSize: Array<any>;
};
lifecycleSummary: Record<string, number>;
campaignSourceQuality: Array<any>;
upgradeCandidates: Array<SegmentRow>;
filterOptions: {
stages: string[];
sources: string[];
campaigns: string[];
landingPaths: string[];
useCases: string[];
goals: string[];
roles: string[];
teamSizes: string[];
plans: string[];
};
segments: {
total: number;
page: number;
pageSize: number;
totalPages: number;
rows: SegmentRow[];
};
};
type Filters = {
stage: string;
source: string;
campaign: string;
landingPath: string;
useCase: string;
goal: string;
role: string;
teamSize: string;
plan: string;
search: string;
sort: string;
page: number;
};
const defaultFilters: Filters = {
stage: '',
source: '',
campaign: '',
landingPath: '',
useCase: '',
goal: '',
role: '',
teamSize: '',
plan: '',
search: '',
sort: 'leadScore_desc',
page: 1,
};
function buildQuery(filters: Filters) {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) {
params.set(key, String(value));
}
});
params.set('pageSize', '25');
return params.toString();
}
function LifecycleCard({
label,
value,
active,
onClick,
}: {
label: string;
value: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-2xl border p-5 text-left transition-colors ${
active ? 'border-primary-600 bg-primary-50' : 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="text-sm text-slate-500">{label}</div>
<div className="mt-2 text-3xl font-semibold text-slate-900">{value}</div>
</button>
);
}
function BreakdownTable({
title,
rows,
}: {
title: string;
rows: Array<{ label?: string; key: string; signups: number; activated: number; paid: number }>;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Segment</TableHead>
<TableHead>Signups</TableHead>
<TableHead>Activated</TableHead>
<TableHead>Paid</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, 8).map((row) => (
<TableRow key={row.key}>
<TableCell>{row.label || row.key}</TableCell>
<TableCell>{row.signups}</TableCell>
<TableCell>{row.activated}</TableCell>
<TableCell>{row.paid}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default function NewsletterClient() {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(true);
const [loginError, setLoginError] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<Filters>(defaultFilters);
const queryString = useMemo(() => buildQuery(filters), [filters]);
const fetchDashboard = async (query: string) => {
setLoading(true);
try {
const response = await fetch(`/api/admin/revops?${query}`);
if (!response.ok) {
if (response.status === 401) {
setIsAuthenticated(false);
return;
}
throw new Error('Failed to load dashboard');
}
const payload = await response.json();
setData(payload);
setIsAuthenticated(true);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
setIsAuthenticating(false);
}
};
useEffect(() => {
fetchDashboard(queryString);
}, [queryString]);
const handleLogin = async (event: React.FormEvent) => {
event.preventDefault();
setLoginError('');
setIsAuthenticating(true);
try {
const response = await fetch('/api/newsletter/admin-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const body = await response.json();
setLoginError(body.error || 'Invalid credentials');
setIsAuthenticating(false);
return;
}
await fetchDashboard(queryString);
} catch (error) {
setLoginError('Login failed. Please try again.');
setIsAuthenticating(false);
}
};
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setIsAuthenticated(false);
setData(null);
router.refresh();
};
const updateFilter = (key: keyof Filters, value: string | number) => {
setFilters((current) => ({
...current,
[key]: value,
page: key === 'page' ? Number(value) : 1,
}));
};
const resetFilters = () => setFilters(defaultFilters);
const copyEmails = async () => {
if (!data?.segments.rows.length) return;
const text = data.segments.rows.map((row) => row.email).join(', ');
await navigator.clipboard.writeText(text);
};
const downloadCsv = () => {
window.location.href = `/api/admin/revops?${queryString}&format=csv`;
};
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-[linear-gradient(135deg,#fff7ed,#eff6ff)] px-4 py-12">
<div className="mx-auto max-w-md">
<Card className="border-white/80 shadow-xl shadow-slate-200/70">
<CardHeader className="text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-slate-900 text-white">
<Lock className="h-6 w-6" />
</div>
<CardTitle className="mt-4">QR Master Ops Cockpit</CardTitle>
<p className="text-sm text-slate-600">Internal access only.</p>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4" autoComplete="off">
<Input
label="Email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="off"
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
required
/>
{loginError && <p className="text-sm text-red-600">{loginError}</p>}
<Button type="submit" className="w-full" loading={isAuthenticating}>
Sign in
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-slate-500" />
</div>
);
}
const stageCards = [
{ key: 'cold', label: 'Cold' },
{ key: 'activated', label: 'Activated' },
{ key: 'warm', label: 'Warm' },
{ key: 'hot', label: 'Hot' },
{ key: 'upgrade_candidate', label: 'Upgrade Candidate' },
{ key: 'paid', label: 'Paid' },
];
const funnelSteps = [
['Signup', data.funnel.signup],
['Source confirmed', data.funnel.sourceConfirmed],
['Use case selected', data.funnel.useCaseSelected],
['Goal selected', data.funnel.goalSelected],
['Role/company/team captured', data.funnel.profileCaptured],
['First QR created', data.funnel.firstQrCreated],
['First dynamic QR created', data.funnel.firstDynamicQrCreated],
['Activated', data.funnel.activated],
];
return (
<div className="min-h-screen bg-slate-50">
<div className="mx-auto max-w-[1600px] px-4 py-8">
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold text-slate-900">Ops Cockpit</h1>
<p className="mt-2 text-slate-600">
Attribution, onboarding funnel, lifecycle quality, and filtered segments for QR Master.
</p>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">Total users</p>
<p className="mt-2 text-3xl font-semibold">{data.overview.totalUsers}</p>
</div>
<Users className="h-5 w-5 text-slate-400" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">Activated users</p>
<p className="mt-2 text-3xl font-semibold">{data.overview.activatedUsers}</p>
</div>
<TrendingUp className="h-5 w-5 text-slate-400" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">Paid users</p>
<p className="mt-2 text-3xl font-semibold">{data.overview.paidUsers}</p>
</div>
<BarChart3 className="h-5 w-5 text-slate-400" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">Tracked vs self-reported mismatch</p>
<p className="mt-2 text-3xl font-semibold">{data.overview.mismatchCount}</p>
</div>
<Mail className="h-5 w-5 text-slate-400" />
</div>
</CardContent>
</Card>
</div>
<div className="mb-8 grid gap-6 xl:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader>
<CardTitle>Acquisition Overview</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Source</TableHead>
<TableHead>Signups</TableHead>
<TableHead>First QR</TableHead>
<TableHead>Activated</TableHead>
<TableHead>Hot</TableHead>
<TableHead>Upgrade</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Activation rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.acquisition.bySource.map((row) => (
<TableRow key={row.key}>
<TableCell>{row.label}</TableCell>
<TableCell>{row.signups}</TableCell>
<TableCell>{row.firstQr}</TableCell>
<TableCell>{row.activated}</TableCell>
<TableCell>{row.hot}</TableCell>
<TableCell>{row.upgradeCandidates}</TableCell>
<TableCell>{row.paid}</TableCell>
<TableCell>{row.activationRate}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6">
<BreakdownTable title="Campaign quality" rows={data.acquisition.byCampaign.map((row) => ({ ...row, label: row.key || 'unknown' }))} />
<BreakdownTable title="Landing page quality" rows={data.acquisition.byLandingPath.map((row) => ({ ...row, label: row.key || 'unknown' }))} />
</div>
</div>
<div className="mb-8 grid gap-6 xl:grid-cols-[1.1fr_1fr]">
<Card>
<CardHeader>
<CardTitle>Onboarding Funnel</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{funnelSteps.map(([label, value]) => {
const percentage = data.funnel.signup ? Math.round((Number(value) / data.funnel.signup) * 100) : 0;
return (
<div key={label} className="rounded-2xl border border-slate-200 bg-white px-4 py-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-slate-800">{label}</span>
<span className="text-slate-500">{value} ({percentage}%)</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-primary-600" style={{ width: `${percentage}%` }} />
</div>
</div>
);
})}
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<BreakdownTable title="Funnel by source" rows={data.funnelBreakdowns.bySource} />
<BreakdownTable title="Funnel by use case" rows={data.funnelBreakdowns.byUseCase} />
<BreakdownTable title="Funnel by role" rows={data.funnelBreakdowns.byRole} />
<BreakdownTable title="Funnel by team size" rows={data.funnelBreakdowns.byTeamSize} />
</div>
</div>
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">Lifecycle Summary</h2>
<p className="text-sm text-slate-500">Click a card to open the filtered list below.</p>
</div>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
{stageCards.map((card) => (
<LifecycleCard
key={card.key}
label={card.label}
value={data.lifecycleSummary[card.key] || 0}
active={filters.stage === card.key}
onClick={() => updateFilter('stage', filters.stage === card.key ? '' : card.key)}
/>
))}
</div>
</div>
<Card className="mb-8">
<CardHeader>
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<CardTitle>Full Segment Lists</CardTitle>
<p className="mt-2 text-sm text-slate-600">
Filter and export cold, activated, warm, hot, upgrade_candidate, and paid users.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={copyEmails}>
<Copy className="mr-2 h-4 w-4" />
Copy email list
</Button>
<Button variant="outline" onClick={downloadCsv}>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<div className="xl:col-span-2">
<label className="mb-2 block text-sm font-medium text-slate-700">Search</label>
<div className="relative">
<Search className="absolute left-3 top-3.5 h-4 w-4 text-slate-400" />
<input
value={filters.search}
onChange={(event) => updateFilter('search', event.target.value)}
placeholder="Email, name, company"
className="h-11 w-full rounded-xl border border-slate-300 bg-white pl-10 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Source</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.source} onChange={(event) => updateFilter('source', event.target.value)}>
<option value="">All sources</option>
{data.filterOptions.sources.map((option) => (
<option key={option} value={option}>{getSourceLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Use case</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.useCase} onChange={(event) => updateFilter('useCase', event.target.value)}>
<option value="">All use cases</option>
{data.filterOptions.useCases.map((option) => (
<option key={option} value={option}>{getUseCaseLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Plan</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.plan} onChange={(event) => updateFilter('plan', event.target.value)}>
<option value="">All plans</option>
{data.filterOptions.plans.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Campaign</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.campaign} onChange={(event) => updateFilter('campaign', event.target.value)}>
<option value="">All campaigns</option>
{data.filterOptions.campaigns.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Landing page</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.landingPath} onChange={(event) => updateFilter('landingPath', event.target.value)}>
<option value="">All landing pages</option>
{data.filterOptions.landingPaths.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Goal</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.goal} onChange={(event) => updateFilter('goal', event.target.value)}>
<option value="">All goals</option>
{data.filterOptions.goals.map((option) => (
<option key={option} value={option}>{getGoalLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Role</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.role} onChange={(event) => updateFilter('role', event.target.value)}>
<option value="">All roles</option>
{data.filterOptions.roles.map((option) => (
<option key={option} value={option}>{getRoleLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Team size</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.teamSize} onChange={(event) => updateFilter('teamSize', event.target.value)}>
<option value="">All team sizes</option>
{data.filterOptions.teamSizes.map((option) => (
<option key={option} value={option}>{getTeamSizeLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">Sort</label>
<select className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm" value={filters.sort} onChange={(event) => updateFilter('sort', event.target.value)}>
<option value="leadScore_desc">Lead score</option>
<option value="createdAt_asc">Created date asc</option>
<option value="activationAt_desc">Activation date</option>
<option value="fitScore_desc">Fit score</option>
<option value="intentScore_desc">Intent score</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={resetFilters}>
<Filter className="mr-2 h-4 w-4" />
Reset filters
</Button>
<span className="text-sm text-slate-500">
{data.segments.total} matching users
</span>
{loading && <Loader2 className="h-4 w-4 animate-spin text-slate-400" />}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Domain</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Fit</TableHead>
<TableHead>Intent</TableHead>
<TableHead>Lead</TableHead>
<TableHead>Source</TableHead>
<TableHead>Self-reported</TableHead>
<TableHead>Campaign</TableHead>
<TableHead>Landing page</TableHead>
<TableHead>Use case</TableHead>
<TableHead>Goal</TableHead>
<TableHead>Role</TableHead>
<TableHead>Company</TableHead>
<TableHead>Team</TableHead>
<TableHead>Created</TableHead>
<TableHead>First QR</TableHead>
<TableHead>Activated</TableHead>
<TableHead>QRs</TableHead>
<TableHead>Dynamic</TableHead>
<TableHead>Scans</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.segments.rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.name || '—'}</TableCell>
<TableCell>{row.email}</TableCell>
<TableCell>{row.emailDomain || '—'}</TableCell>
<TableCell>{row.plan}</TableCell>
<TableCell>{row.lifecycleStageLabel}</TableCell>
<TableCell>{row.fitScore}</TableCell>
<TableCell>{row.intentScore}</TableCell>
<TableCell>{row.leadScore}</TableCell>
<TableCell>{row.signupSourceLabel}</TableCell>
<TableCell>{row.signupSourceSelfReportedLabel}</TableCell>
<TableCell>{row.signupCampaign || '—'}</TableCell>
<TableCell>{row.signupLandingPath || '—'}</TableCell>
<TableCell>{row.primaryUseCaseLabel}</TableCell>
<TableCell>{row.primaryGoalLabel}</TableCell>
<TableCell>{row.jobRoleLabel}</TableCell>
<TableCell>{row.companyName || '—'}</TableCell>
<TableCell>{row.teamSizeLabel}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell>{row.firstQrCreatedAt ? new Date(row.firstQrCreatedAt).toLocaleDateString() : '—'}</TableCell>
<TableCell>{row.activationAt ? new Date(row.activationAt).toLocaleDateString() : '—'}</TableCell>
<TableCell>{row.qrCount}</TableCell>
<TableCell>{row.dynamicQrCount}</TableCell>
<TableCell>{row.scanCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-500">
Page {data.segments.page} of {data.segments.totalPages}
</span>
<div className="flex gap-3">
<Button variant="outline" disabled={data.segments.page <= 1} onClick={() => updateFilter('page', filters.page - 1)}>
Previous
</Button>
<Button variant="outline" disabled={data.segments.page >= data.segments.totalPages} onClick={() => updateFilter('page', filters.page + 1)}>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Upgrade Candidates</CardTitle>
<p className="text-sm text-slate-600">
Free users with strong fit and clear commercial intent.
</p>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Lead score</TableHead>
<TableHead>Use case</TableHead>
<TableHead>Role</TableHead>
<TableHead>Company</TableHead>
<TableHead>Reasons</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.upgradeCandidates.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div>
<div className="font-medium text-slate-900">{row.name || row.email}</div>
<div className="text-xs text-slate-500">{row.email}</div>
</div>
</TableCell>
<TableCell>{row.leadScore}</TableCell>
<TableCell>{getUseCaseLabel(row.primaryUseCase)}</TableCell>
<TableCell>{getRoleLabel(row.jobRole)}</TableCell>
<TableCell>{row.companyName || '—'}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{row.upgradeBadges.map((badge) => (
<Badge key={badge} variant="warning">{badge}</Badge>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
);
}