All files / src/pages/admin/rrm referral-funnel.tsx

100% Statements 17/17
86.66% Branches 39/45
100% Functions 9/9
100% Lines 17/17

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203                                                        1x                         8x 64x 64x 64x                           8x     8x     56x       8x     64x                                                                     10x 10x     10x   10x                                 1x                             4x                                                                                     1x                              
import { AlertTriangle, TrendingDown } from "lucide-react";
import { useState } from "react";
import {
	Card,
	CardContent,
	CardHeader,
	CardTitle,
} from "../../../components/ui/card";
import { EmptyState } from "../../../components/ui/empty-state";
import { Input } from "../../../components/ui/input";
import { PageHeader } from "../../../components/ui/page-header";
import { useRrmReferralFunnel } from "../../../hooks/queries/useRrmQueries";
import type { FunnelStep } from "../../../lib/api/admin/rrm";
 
/**
 * FR-I-3 — the funnel diagnostic, as a first-class admin view.
 *
 * created → forwarded → link_opened → engaged → verified → contacted → quoted
 * → converted, over `referral_events` rather than over `referrals.status`: a
 * funnel step means "how many ever got this far", and a row's current status
 * has forgotten everywhere it has been.
 *
 * THE DROP BETWEEN STEPS IS THE POINT. A column chart of eight numbers tells an
 * operator nothing they could not get from a table; what they came for is which
 * gap is the big one, so every row carries the percentage that survived it and
 * the worst is called out by name.
 */
 
const STEP_LABEL: Record<string, string> = {
	created: "Added by a partner",
	forwarded: "Sent to the homeowner",
	opened: "Homeowner opened the link",
	engaged: "Homeowner messaged us",
	verified: "We confirmed them",
	contacted: "We got in touch",
	quoted: "Quoted",
	converted: "Went ahead",
};
 
/** What survived each step, and what it cost. */
function withDrop(steps: FunnelStep[]) {
	return steps.map((step, i) => {
		const previous = i === 0 ? null : steps[i - 1].referrals;
		const lost = previous === null ? 0 : previous - step.referrals;
		return {
			...step,
			previous,
			lost,
			// Of the FIRST step, so every bar is comparable at a glance.
			shareOfTop:
				steps[0].referrals > 0 ? step.referrals / steps[0].referrals : 0,
			// Of the step before it, which is the number that names the problem.
			survival: previous && previous > 0 ? step.referrals / previous : null,
		};
	});
}
 
function FunnelRows({ steps }: { steps: FunnelStep[] }) {
	const rows = withDrop(steps);
	// The biggest single loss, ignoring the first step, which has nothing to
	// drop from. Named rather than left for the reader to spot.
	const worst = rows
		.slice(1)
		.reduce<(typeof rows)[number] | null>(
			(acc, row) => (acc === null || row.lost > acc.lost ? row : acc),
			null,
		);
 
	return (
		<ul className="divide-y divide-border-default">
			{rows.map((row) => (
				<li key={row.key} className="px-4 py-3" data-testid="funnel-step">
					<div className="flex items-baseline justify-between gap-3">
						<p className="text-sm font-medium text-foreground-default">
							{STEP_LABEL[row.key] ?? row.key}
						</p>
						<p
							className="text-sm font-semibold tabular-nums"
							data-testid={`funnel-count-${row.key}`}
						>
							{row.referrals.toLocaleString("en-IN")}
						</p>
					</div>
					<div className="mt-2 h-2 overflow-hidden rounded-full bg-background-muted">
						<div
							className="h-full rounded-full bg-primary-500"
							style={{ width: `${Math.round(row.shareOfTop * 100)}%` }}
						/>
					</div>
					{row.survival !== null && (
						<p
							className="mt-1 text-xs text-foreground-muted"
							data-testid={`funnel-drop-${row.key}`}
						>
							{Math.round(row.survival * 100)}% of the step before
							{row.lost > 0 ? ` · ${row.lost} lost here` : ""}
							{worst?.key === row.key && row.lost > 0 ? " · biggest drop" : ""}
						</p>
					)}
				</li>
			))}
		</ul>
	);
}
 
export function ReferralFunnelPage() {
	const [partnerId, setPartnerId] = useState("");
	const { data, isLoading, error } = useRrmReferralFunnel(
		partnerId.trim() ? { partnerId: partnerId.trim() } : {},
	);
	const funnel = data?.data;
 
	return (
		<div className="space-y-6" data-testid="rrm-funnel-page">
			<PageHeader
				title="Referral funnel"
				description="Where referrals stop. Counted from events, so a referral that reached a step still counts even if it later went backwards."
			/>
 
			<Card>
				<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
					<CardTitle className="text-lg">
						{partnerId.trim() ? "One partner" : "Everyone"}
					</CardTitle>
					<Input
						value={partnerId}
						placeholder="Filter by partner id"
						data-testid="rrm-funnel-partner"
						className="sm:max-w-xs"
						onChange={(e) => setPartnerId(e.target.value)}
					/>
				</CardHeader>
				<CardContent className="p-0">
					{error ? (
						<div
							data-testid="rrm-funnel-error"
							className="flex items-start gap-2 p-6 text-sm text-error"
						>
							<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
							<span>Could not load the funnel. Refresh to try again.</span>
						</div>
					) : isLoading ? (
						<div className="space-y-3 p-4" data-testid="rrm-funnel-skeleton">
							{["a", "b", "c", "d"].map((k) => (
								<div
									key={k}
									className="h-10 animate-pulse rounded bg-background-muted"
								/>
							))}
						</div>
					) : !funnel || funnel.steps[0]?.referrals === 0 ? (
						<div className="px-4 py-12">
							<EmptyState
								icon={TrendingDown}
								title="No referrals in this window"
								description={
									partnerId.trim()
										? "This partner has not added a referral yet, or the id does not match one."
										: "Nobody has added a referral yet. The funnel fills in as they do."
								}
							/>
						</div>
					) : (
						<FunnelRows steps={funnel.steps} />
					)}
				</CardContent>
			</Card>
 
			{funnel && funnel.byTier.length > 0 && (
				<Card>
					<CardHeader>
						<CardTitle className="text-lg">By tier</CardTitle>
						{/* Not a footnote — a chart covering 12 of 400 partners looks
						    like a chart of the programme unless it says so, and tier
						    exists only on partners we recruited. */}
						<p
							className="text-sm text-foreground-muted"
							data-testid="rrm-funnel-coverage"
						>
							Covers {funnel.tierCoverage.recruitedPartners} of{" "}
							{funnel.tierCoverage.totalPartners} partners — only the ones we
							recruited have a tier. Self-registered partners are in the figures
							above and not below.
						</p>
					</CardHeader>
					<CardContent className="space-y-6 p-0">
						{funnel.byTier.map((tier) => (
							<div key={tier.tier ?? "none"} data-testid="rrm-funnel-tier">
								<p className="px-4 pt-4 text-sm font-medium text-foreground-muted">
									Tier {tier.tier ?? "—"}
								</p>
								<FunnelRows steps={tier.steps} />
							</div>
						))}
					</CardContent>
				</Card>
			)}
		</div>
	);
}
 
export default ReferralFunnelPage;