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
88 changes: 33 additions & 55 deletions src/components/ConversionDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ interface DailyCount {
count: number;
}

interface PropBreakdown {
interface ConversionDetail {
goal: string;
prop: string;
value: string;
amount: string;
variant: string;
count: number;
}

interface StatsResponse {
plausible: DailyCount[];
dropped: DailyCount[];
propBreakdowns: PropBreakdown[];
details: ConversionDetail[];
dateFrom: string;
dateTo: string;
error?: string;
Expand Down Expand Up @@ -335,28 +335,27 @@ export default function ConversionDashboard({
</div>
</div>

{/* Amount breakdowns per goal */}
{data.propBreakdowns.length > 0 && (
{/* Conversion details per goal */}
{data.details.length > 0 && (
<div className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{goalKeys.map((goal) => {
const allAmounts = data.propBreakdowns.filter(
(p) => p.goal === goal && p.prop === "amount"
);
if (allAmounts.length === 0) return null;
const tracked = allAmounts
.filter((p) => p.value && !isNaN(parseFloat(p.value)))
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
const untrackedCount = allAmounts
.filter((p) => !p.value || isNaN(parseFloat(p.value)))
.reduce((s, p) => s + p.count, 0);
const rows = data.details.filter((d) => d.goal === goal);
if (rows.length === 0) return null;
const hasVariant = goal === "Membership-complete";
const tracked = rows
.filter((r) => r.amount && !isNaN(parseFloat(r.amount)))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
const untrackedCount = rows
.filter((r) => !r.amount || isNaN(parseFloat(r.amount)))
.reduce((s, r) => s + r.count, 0);
const total = tracked.reduce(
(sum, p) => sum + parseFloat(p.value) * p.count,
(sum, r) => sum + parseFloat(r.amount) * r.count,
0
);
const colors = GOAL_COLORS[goal];
return (
<div
key={`amount-${goal}`}
key={`detail-${goal}`}
className="rounded-lg border border-gray-200 bg-white p-5"
>
<h3 className="text-sm font-medium text-gray-500">
Expand All @@ -368,24 +367,35 @@ export default function ConversionDashboard({
>
${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<div className="mt-3 max-h-48 overflow-y-auto">
<div className="mt-3 max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="pb-1 text-left font-medium text-gray-400">Amount</th>
{hasVariant && (
<th className="pb-1 text-left font-medium text-gray-400">Variant</th>
)}
<th className="pb-1 text-right font-medium text-gray-400">Count</th>
</tr>
</thead>
<tbody>
{tracked.map((a) => (
<tr key={a.value} className="border-b border-gray-50">
<td className="py-1 text-gray-700">${parseFloat(a.value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-1 text-right text-gray-900 font-medium">{a.count}</td>
{tracked.map((r, i) => (
<tr key={i} className="border-b border-gray-50">
<td className="py-1 text-gray-700">
${parseFloat(r.amount).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
{hasVariant && (
<td className="py-1 text-gray-600">
{r.variant || "—"}
</td>
)}
<td className="py-1 text-right text-gray-900 font-medium">{r.count}</td>
</tr>
))}
{untrackedCount > 0 && (
<tr className="border-b border-gray-50">
<td className="py-1 text-gray-400 italic">Untracked</td>
{hasVariant && <td />}
<td className="py-1 text-right text-gray-400 font-medium">{untrackedCount}</td>
</tr>
)}
Expand All @@ -397,38 +407,6 @@ export default function ConversionDashboard({
})}
</div>
)}

{/* Membership variant breakdown */}
{(() => {
const variants = data.propBreakdowns.filter(
(p) =>
p.goal === "Membership-complete" &&
p.prop === "membership_variant"
);
if (variants.length === 0) return null;
return (
<div className="mt-4 max-w-xs rounded-lg border border-gray-200 bg-white p-5">
<h3 className="text-sm font-medium text-gray-500">
Membership — Calculator Variant
</h3>
<div className="mt-3 space-y-2">
{variants.map((v) => (
<div
key={v.value}
className="flex items-center justify-between"
>
<span className="text-sm text-gray-700">
{v.value || "(not set)"}
</span>
<span className="text-sm font-semibold text-gray-900">
{v.count}
</span>
</div>
))}
</div>
</div>
);
})()}
</>
)}
</div>
Expand Down
57 changes: 29 additions & 28 deletions src/pages/api/admin/conversion-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,20 @@ async function fetchPlausibleStats(
return results.flat();
}

interface PropBreakdown {
interface ConversionDetail {
goal: string;
prop: string;
value: string;
amount: string;
variant: string;
count: number;
}

async function fetchPlausiblePropBreakdown(
async function fetchPlausibleConversionDetails(
apiKey: string,
goal: string,
prop: string,
props: string[],
dateFrom: string,
dateTo: string
): Promise<PropBreakdown[]> {
): Promise<ConversionDetail[]> {
const response = await fetch(PLAUSIBLE_API, {
method: "POST",
headers: {
Expand All @@ -102,7 +102,7 @@ async function fetchPlausiblePropBreakdown(
metrics: ["events"],
date_range: [dateFrom, dateTo],
filters: [["is", "event:goal", [goal]]],
dimensions: [`event:props:${prop}`],
dimensions: props.map((p) => `event:props:${p}`),
}),
});

Expand All @@ -112,20 +112,21 @@ async function fetchPlausiblePropBreakdown(

return data.results.map((r) => ({
goal,
prop,
value: r.dimensions[0],
amount: r.dimensions[props.indexOf("amount")] ?? "",
variant: r.dimensions[props.indexOf("membership_variant")] ?? "",
count: r.metrics[0],
}));
}

async function fetchPropertyBreakdowns(
async function fetchConversionDetails(
apiKey: string,
dateFrom: string,
dateTo: string
): Promise<PropBreakdown[]> {
): Promise<ConversionDetail[]> {
const queries = [
...GOALS.map((goal) => fetchPlausiblePropBreakdown(apiKey, goal, "amount", dateFrom, dateTo)),
fetchPlausiblePropBreakdown(apiKey, "Membership-complete", "membership_variant", dateFrom, dateTo),
fetchPlausibleConversionDetails(apiKey, "One-time-donate", ["amount"], dateFrom, dateTo),
fetchPlausibleConversionDetails(apiKey, "Monthly-donate", ["amount"], dateFrom, dateTo),
fetchPlausibleConversionDetails(apiKey, "Membership-complete", ["amount", "membership_variant"], dateFrom, dateTo),
];
const results = await Promise.all(queries);
return results.flat();
Expand All @@ -135,7 +136,7 @@ async function fetchDroppedEvents(
kv: KVNamespace,
dateFrom: string,
dateTo: string
): Promise<{ daily: DailyCount[]; propBreakdowns: PropBreakdown[] }> {
): Promise<{ daily: DailyCount[]; details: ConversionDetail[] }> {
const allKeys: string[] = [];
let cursor: string | undefined;
do {
Expand Down Expand Up @@ -182,21 +183,21 @@ async function fetchDroppedEvents(
daily.push({ date, goal: goalParts.join(":"), count });
}

const propCounts = new Map<string, number>();
const detailCounts = new Map<string, number>();
for (const e of filtered) {
for (const [prop, value] of Object.entries(e.props)) {
const key = `${e.eventName}:${prop}:${value}`;
propCounts.set(key, (propCounts.get(key) || 0) + 1);
}
const amount = String(e.props.amount ?? "");
const variant = String(e.props.membership_variant ?? "");
const key = `${e.eventName}:${amount}:${variant}`;
detailCounts.set(key, (detailCounts.get(key) || 0) + 1);
}

const propBreakdowns: PropBreakdown[] = [];
for (const [key, count] of propCounts) {
const [goal, prop, ...valueParts] = key.split(":");
propBreakdowns.push({ goal, prop, value: valueParts.join(":"), count });
const details: ConversionDetail[] = [];
for (const [key, count] of detailCounts) {
const [goal, amount, variant] = key.split(":");
details.push({ goal, amount, variant, count });
}

return { daily, propBreakdowns };
return { daily, details };
}

export const GET: APIRoute = async ({ request, locals }) => {
Expand All @@ -217,23 +218,23 @@ export const GET: APIRoute = async ({ request, locals }) => {

const ctx = locals.runtime?.ctx;
try {
const [plausible, dropped, propBreakdowns] = await Promise.all([
const [plausible, dropped, details] = await Promise.all([
apiKey
? fetchPlausibleStats(apiKey, dateFrom, dateTo)
: Promise.resolve([]),
kv
? fetchDroppedEvents(kv, dateFrom, dateTo)
: Promise.resolve({ daily: [], propBreakdowns: [] }),
: Promise.resolve({ daily: [], details: [] }),
apiKey
? fetchPropertyBreakdowns(apiKey, dateFrom, dateTo)
? fetchConversionDetails(apiKey, dateFrom, dateTo)
: Promise.resolve([]),
]);

return new Response(
JSON.stringify({
plausible,
dropped: dropped.daily,
propBreakdowns: [...propBreakdowns, ...dropped.propBreakdowns],
details: [...details, ...dropped.details],
dateFrom,
dateTo,
}),
Expand Down
Loading