826 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|